diff --git a/.dockerignore b/.dockerignore index 33be4b6ee1..492480e3a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,7 +27,6 @@ node_modules/ **/node_modules/ frontend/node_modules/ frontend/editor/dist/ -frontend/dist-portal/ frontend/editor/playwright-report/ .npm/ .yarn/ diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index d29002ac37..99b3b127a4 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright) # Enterprise Playwright suite — exercises premium-key gated features (audit, # teams, analytics) plus full OAuth + SAML logins via the Keycloak compose -# stacks under testing/compose. Slow and secret-gated, so it runs in three +# stacks under testing/compose. Slow and secret-gated, so it runs in four # situations: # # - PRs that touch proprietary / premium / SSO compose / enterprise tests @@ -12,8 +12,6 @@ name: Enterprise E2E (Playwright) # - on a nightly cron schedule (catches Keycloak image drift, license # expiry, upstream proprietary changes), # - manual workflow_dispatch. -# -# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot). on: workflow_call: @@ -52,6 +50,10 @@ jobs: playwright-e2e-enterprise: needs: pick + # Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE + # (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the + # header comment. GitHub reports the skipped reusable workflow as success. + if: needs.pick.outputs.is_fork != 'true' runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }} timeout-minutes: 45 env: @@ -285,5 +287,5 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-enterprise-${{ github.run_id }} - path: frontend/editor/playwright-report/ + path: frontend/playwright-report/ retention-days: 7 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7018a38120..0e0a702cc2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -202,6 +202,9 @@ jobs: contents: read uses: ./.github/workflows/coverage-aggregate.yml secrets: inherit + with: + frontend-validation-result: ${{ needs.frontend-validation.result }} + playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }} # Single status check that branch protection should mark as required. # Succeeds when every upstream job is either `success` or `skipped` (path- diff --git a/.github/workflows/coverage-aggregate.yml b/.github/workflows/coverage-aggregate.yml index bbfaf09363..ce792bcadc 100644 --- a/.github/workflows/coverage-aggregate.yml +++ b/.github/workflows/coverage-aggregate.yml @@ -13,6 +13,17 @@ name: Aggregate backend coverage # producers themselves on: workflow_call: + inputs: + frontend-validation-result: + description: Result of the frontend-validation producer job + required: false + type: string + default: skipped + playwright-e2e-live-result: + description: Result of the playwright-e2e-live producer job + required: false + type: string + default: skipped permissions: contents: read @@ -196,9 +207,9 @@ jobs: # -------------------------------------------------------------- - name: Download vitest coverage artifact # frontend-validation uploads as `frontend-coverage`. Tolerate - # absence so a backend-only PR still produces the matrix with - # just backend rows populated. - if: always() + # absence on backend-only runs by skipping the download entirely + # when the producer job was not part of this workflow run. + if: inputs.frontend-validation-result == 'success' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 with: name: frontend-coverage @@ -206,12 +217,12 @@ jobs: continue-on-error: true - name: Download Playwright frontend coverage artifact - # e2e-live uploads as `playwright-frontend-coverage-`. - # Same tolerance as vitest - matrix script handles missing inputs. - if: always() + # e2e-live uploads the artifact with a stable name. Skip the + # download entirely when the producer job did not run. + if: inputs.playwright-e2e-live-result == 'success' uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 with: - name: playwright-frontend-coverage-${{ github.run_id }} + name: playwright-frontend-coverage path: matrix-inputs/playwright/ continue-on-error: true diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index eb6d8d7be5..44d5443a73 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -169,7 +169,7 @@ jobs: if: always() && steps.pw-frontend-coverage.outputs.summary == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-frontend-coverage-${{ github.run_id }} + name: playwright-frontend-coverage path: | .test-state/playwright/coverage-pw-summary/ .test-state/playwright/coverage-pw/ diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index dd6bcc8ea7..33bb24ee4c 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -50,5 +50,5 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-stubbed-${{ github.run_id }} - path: frontend/editor/playwright-report/ + path: frontend/playwright-report/ retention-days: 7 diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 41d3f35c6e..cc9aa023e3 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -98,6 +98,13 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + + - name: Generate frontend license report (Push only) + if: github.event_name == 'push' + env: + PR_IS_FORK: "false" + run: task frontend:licenses:generate + - name: Generate frontend license report (internal PR) if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false env: @@ -353,6 +360,7 @@ jobs: - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + - name: Check licenses and generate report id: license-check run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 11a8ea2be5..1801876bcd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -53,8 +53,8 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: playwright-nightly-${{ github.run_id }} - path: frontend/editor/playwright-report/ + name: playwright-report-nightly-${{ github.run_id }} + path: frontend/playwright-report/ retention-days: 14 # Builds all desktop platforms on a schedule so the Rust dependency cache is diff --git a/.gitleaksignore b/.gitleaksignore index 783a098f68..089de4fedb 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -15,10 +15,10 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92 testing/compose/validate-mcp-test.sh:curl-auth-header:116 # Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...). -frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4 +frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5 # Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret. -frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31 +frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30 # False positive: generic-api-key matches the Java type name "X509Certificate" # in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret. diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 3ad0486759..52277b5b04 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -128,37 +128,6 @@ tasks: - task: dev:_run vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' } - dev:portal: - desc: "Start developer portal dev server" - ignore_error: true - deps: [install] - vars: - PORT: '{{.PORT | default "5173"}}' - BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' - EDITOR_URL: '{{.EDITOR_URL | default ""}}' - OPEN: '{{.OPEN | default ""}}' - SUBPATH: '{{.SUBPATH | default ""}}' - MOCKS: '{{.MOCKS | default ""}}' - env: - BACKEND_URL: '{{.BACKEND_URL}}' - cmds: - - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}' - - dev:portal:proxy:serve: - internal: true - vars: - PORT: '{{.PORT | default "3000"}}' - BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' - EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}' - PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}' - env: - PORT: '{{.PORT}}' - BACKEND_URL: '{{.BACKEND_URL}}' - EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}' - PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}' - cmds: - - npx tsx scripts/dev-origin-proxy.ts - # ============================================================ # Build # ============================================================ @@ -205,29 +174,6 @@ tasks: cmds: - npx vite build editor --mode prototypes - build:portal: - desc: "Build developer portal" - deps: [install] - vars: - SUBPATH: '{{.SUBPATH | default ""}}' - cmds: - - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal' - - preview:portal:proxy: - desc: "Build + serve editor + portal behind one origin (prod-like auth testing)" - deps: [prepare] - vars: - PORT: '{{.PORT | default "3000"}}' - BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' - env: - PORT: '{{.PORT}}' - BACKEND_URL: '{{.BACKEND_URL}}' - cmds: - - task: build:proprietary - vars: { PREVIEW: '1' } - - task: build:portal - vars: { SUBPATH: portal } - - npx tsx scripts/dev-origin-proxy.ts storybook: desc: "Start Storybook dev server" @@ -263,8 +209,8 @@ tasks: deps: [install] cmds: # Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is - # shell-agnostic. Covers editor, portal, and the shared design system. - - npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1 + # shell-agnostic. Covers the whole editor tree, including the portal layer. + - npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1 lint:fix: desc: "Auto-fix lint issues" @@ -345,8 +291,6 @@ tasks: desc: "Typecheck scripts" deps: [prepare] cmds: - - task: typecheck:_run - vars: { PROJECT: scripts/tsconfig.json } - task: typecheck:_run vars: { PROJECT: editor/scripts/tsconfig.json } @@ -362,14 +306,7 @@ tasks: deps: [install] cmds: - task: typecheck:_run - vars: { PROJECT: portal/tsconfig.json } - - typecheck:shared: - desc: "Typecheck the shared design system" - deps: [install] - cmds: - - task: typecheck:_run - vars: { PROJECT: shared/tsconfig.json } + vars: { PROJECT: editor/src/portal/tsconfig.json } typecheck:all: desc: "Typecheck all build variants" @@ -382,7 +319,6 @@ tasks: - task: typecheck:scripts - task: typecheck:prototypes - task: typecheck:portal - - task: typecheck:shared # ============================================================ # Quality Gate @@ -411,7 +347,6 @@ tasks: - task: lint - task: format:check - task: build - - task: build:portal - task: test - task: storybook:build @@ -423,7 +358,6 @@ tasks: desc: "Run tests" cmds: - task: test:editor - - task: test:portal test:editor: desc: "Run editor tests" @@ -431,12 +365,6 @@ tasks: cmds: - npx vitest run --root editor - test:portal: - desc: "Run portal tests" - deps: [prepare] - cmds: - - npx vitest run --root portal - test:watch: desc: "Run tests in watch mode" deps: [prepare] @@ -481,7 +409,7 @@ tasks: clean: desc: "Clean build artifacts and caches" cmds: - - cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal + - cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist platforms: [windows] - - cmd: rm -rf node_modules/.vite editor/dist dist dist-portal + - cmd: rm -rf node_modules/.vite editor/dist dist platforms: [linux, darwin] diff --git a/AGENTS.md b/AGENTS.md index 44647b0063..9afdad5937 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie #### Environment Variables - All `VITE_*` variables must be declared in the appropriate committed env file: - - `frontend/editor/.env` — core, proprietary, and shared vars + - `frontend/editor/.env` — core and shared vars (base, loaded in every mode) + - `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode) - `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode) - `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode) - These files are committed to Git and must not contain private keys diff --git a/DeveloperGuide.md b/DeveloperGuide.md index 23b2652ce1..fbbf6478ac 100644 --- a/DeveloperGuide.md +++ b/DeveloperGuide.md @@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in 5. Add environment variable For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step. -5. **Frontend Setup (Required for Stirling 2.0)** +6. **Frontend Setup (Required for Stirling 2.0)** Navigate to the frontend directory and install dependencies using npm. ### Verify Setup @@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build 1. Set the security environment variable: ```bash - export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds + export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds ``` 2. Build the project: @@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat . ``` -Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase +Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case ## 7. Testing diff --git a/LICENSE b/LICENSE index 2b2f7fc085..971c9d0b16 100644 --- a/LICENSE +++ b/LICENSE @@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE". * All content that resides under the "frontend/editor/src/prototypes/" directory of this repository, if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE". -* All content that resides under the "frontend/portal/" directory of this repository, -if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE". +* All content that resides under the "frontend/editor/src/portal/" directory of this repository, +if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE". * Content outside of the above mentioned directories or restrictions above is available under the MIT License as defined below. diff --git a/README.md b/README.md index c1d96eca1d..c6777f1032 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum ## Support -- **Community** [Discord](https://discord.gg/HYmhKj45pU) -- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues) +- **Community**: [Discord](https://discord.gg/HYmhKj45pU) +- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues) ## Contributing diff --git a/Taskfile.yml b/Taskfile.yml index 34c7c8bc28..26895723d0 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -79,86 +79,12 @@ tasks: OPEN: "true" dev:portal: - desc: "Start backend + developer portal concurrently on free ports" + desc: "Start backend + editor; the portal is an admin route at /portal" 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}}' - PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' - deps: - - task: backend:dev - vars: - PORT: '{{.BACKEND_PORT}}' - SECURITY_ENABLELOGIN: "true" - POLICIES_ENABLED: "true" - - task: frontend:dev:portal - vars: - PORT: '{{.PORTAL_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - MOCKS: 'false' - OPEN: "true" - - dev:portal:all: - desc: "Start backend + developer portal + editor concurrently on free ports" - vars: - PORTS: - sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}' - BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' - PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' - EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' - deps: - - task: backend:dev - vars: - PORT: '{{.BACKEND_PORT}}' - SECURITY_ENABLELOGIN: "true" - POLICIES_ENABLED: "true" - - task: frontend:dev:portal - vars: - PORT: '{{.PORTAL_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - # Point the portal's "Editor" app switcher at the editor we spawn here. - EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/' - MOCKS: 'false' - OPEN: "true" - - task: frontend:dev - vars: - PORT: '{{.EDITOR_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - - dev:portal:all:saas: - desc: "Start SaaS backend + developer portal + editor concurrently on free ports" - vars: - PORTS: - sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}' - BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' - PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' - EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' - deps: - - task: backend:dev:saas - vars: - PORT: '{{.BACKEND_PORT}}' - POLICIES_ENABLED: "true" - - task: frontend:dev:portal - vars: - PORT: '{{.PORTAL_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/' - MOCKS: 'false' - OPEN: "true" - - task: frontend:dev - vars: - PORT: '{{.EDITOR_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - - dev:portal:proxy: - desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)" - vars: - PORTS: - sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}' - BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' - PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}' - EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' - PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}' + EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}' deps: - task: backend:dev vars: @@ -169,18 +95,7 @@ tasks: vars: PORT: '{{.EDITOR_PORT}}' BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - - task: frontend:dev:portal - vars: - PORT: '{{.PORTAL_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - SUBPATH: portal - MOCKS: 'false' - - task: frontend:dev:portal:proxy:serve - vars: - PORT: '{{.PROXY_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}' - PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}' + OPEN: "true" dev:saas: desc: "Start SaaS backend + frontend concurrently on free ports" @@ -228,24 +143,6 @@ tasks: - task: backend:build - task: frontend:build - preview:portal:proxy: - desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)" - vars: - PORTS: - sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}' - BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' - PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}' - deps: - - task: backend:dev - vars: - PORT: '{{.BACKEND_PORT}}' - SECURITY_ENABLELOGIN: "true" - POLICIES_ENABLED: "true" - - task: frontend:preview:portal:proxy - vars: - PORT: '{{.PROXY_PORT}}' - BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' - # ============================================================ # Test # ============================================================ diff --git a/app/allowed-licenses.json b/app/allowed-licenses.json index cd2fe06a3b..9f1ff96359 100644 --- a/app/allowed-licenses.json +++ b/app/allowed-licenses.json @@ -80,10 +80,18 @@ "moduleName": ".*", "moduleLicense": "Apache License Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "Apache License version 2.0" + }, { "moduleName": ".*", "moduleLicense": "Apache License, Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "Apache License, version 2.0" + }, { "moduleName": ".*", "moduleLicense": "The Apache License, Version 2.0" @@ -108,6 +116,10 @@ "moduleName": ".*", "moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)" }, + { + "moduleName": ".*", + "moduleLicense": "Mozilla Public License Version 2.0" + }, { "moduleName": ".*", "moduleLicense": "CDDL+GPL License" @@ -172,6 +184,14 @@ "moduleName": ".*", "moduleLicense": "Eclipse Public License, Version 2.0" }, + { + "moduleName": ".*", + "moduleLicense": "EPL-2.0" + }, + { + "moduleName": ".*", + "moduleLicense": "LGPL-2.1-only" + }, { "moduleName": ".*", "moduleLicense": "Ubuntu Font Licence 1.0" diff --git a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java index ba896b4e34..40c1c98f23 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -132,7 +132,7 @@ public class AppConfig { return true; } Path mountInfo = Path.of("/proc/1/mountinfo"); - // this should always exist, if not some unknown usecase + // this should always exist, if not some unknown use case if (!Files.exists(mountInfo)) { return true; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java index 9d0108db0f..4b32d6ae6a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/ResourceType.java @@ -2,7 +2,8 @@ package stirling.software.proprietary.access.model; /** Types of resources whose access can be gated by {@link ResourceGrant}. */ public enum ResourceType { - // The admin portal / processor (frontend/portal). Singleton resource (empty resourceId). + // The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty + // resourceId). PORTAL, // A stored S3/MCP/API integration configuration. INTEGRATION_CONFIG diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java index a77f67f8a8..bdd9df10a8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkClient.java @@ -6,6 +6,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.time.LocalDateTime; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -14,25 +15,31 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; + import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; /** * Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode * A"). * - *

Two calls: + *

Calls: * *

* - *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see - * {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS - * endpoint. + *

Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see + * {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS. */ @Slf4j @Service @@ -86,11 +93,9 @@ public class AccountLinkClient { } /** - * Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked - * or invalid. Distinct from a transport/server failure (which returns {@code null} and fails - * open): the cache must BLOCK billable work on this rather than serve a stale entitled - * snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport - * try/catch. + * Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a + * transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on + * this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch. */ public static final class RevokedException extends RuntimeException { private final int status; @@ -142,11 +147,9 @@ public class AccountLinkClient { } /** - * Revokes this instance's own credential on the SaaS side ({@code POST - * /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is - * allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable - * or rejects the call, so the caller (local unlink) can still clear locally and log the orphan - * row for follow-up. Idempotent on SaaS (already-revoked → still 204). + * Revokes this instance's own credential on the SaaS side, authenticated by that credential. + * Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local + * unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS. */ public boolean revokeSelf(String deviceId, String deviceSecret) { try { @@ -218,6 +221,63 @@ public class AccountLinkClient { } } + /** + * Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and + * returns the fresh entitlement in the same reply — one round-trip both reports and refreshes. + * SaaS bills the delta against its last-seen cumulative, so resending the same totals is + * idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must + * not advance its last-synced markers so the usage retries next sync. + */ + public InstanceEntitlement reportUsage( + String deviceId, + String deviceSecret, + long syncSeq, + LocalDateTime periodStart, + long apiUnits, + long aiUnits, + long automationUnits) { + HttpResponse response; + try { + ObjectNode root = mapper.createObjectNode(); + root.put("syncSeq", syncSeq); + // Explicit ISO-8601 string so it round-trips regardless of the mapper's time config. + root.put("periodStart", periodStart.toString()); + ObjectNode units = root.putObject("cumulativeUnits"); + units.put("api", apiUnits); + units.put("ai", aiUnits); + units.put("automation", automationUnits); + String body = mapper.writeValueAsString(root); + HttpRequest request = + HttpRequest.newBuilder() + .uri(uri("/api/v1/instance/sync")) + .header(HEADER_DEVICE_ID, deviceId) + .header(HEADER_DEVICE_SECRET, deviceSecret) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .timeout(timeout()) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + response = send(request); + } catch (Exception e) { + log.debug("Usage sync failed: {}", e.getMessage()); + return null; + } + int status = response.statusCode(); + if (status == 401 || status == 403) { + throw new RevokedException(status); + } + if (status / 100 != 2) { + log.debug("Usage sync returned HTTP {}", status); + return null; + } + try { + return parseEntitlement(response.body()); + } catch (IOException e) { + log.debug("Usage sync parse failed: {}", e.getMessage()); + return null; + } + } + private InstanceEntitlement parseEntitlement(String body) throws IOException { JsonNode root = mapper.readTree(body); boolean subscribed = root.path("subscribed").asBoolean(false); @@ -226,7 +286,45 @@ public class AccountLinkClient { Long periodCap = root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null; EntitlementState state = mapState(root.path("state").asText(null)); - return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state); + return new InstanceEntitlement( + subscribed, + freeRemaining, + periodSpend, + periodCap, + state, + parseUnitCalcPolicy(root), + parseDateTime(root, "periodStart"), + parseDateTime(root, "periodEnd")); + } + + /** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */ + private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) { + if (!root.hasNonNull("unitCalcPolicy")) { + return null; + } + JsonNode node = root.get("unitCalcPolicy"); + try { + return new UnitCalcPolicy( + node.path("docPagesPerUnit").asInt(), + node.path("docBytesPerUnit").asLong(), + node.path("minChargeUnits").asInt(), + node.path("fileUnitCap").asInt()); + } catch (RuntimeException e) { + // Malformed policy → degrade to "none" rather than fail the whole entitlement parse. + return null; + } + } + + /** ISO date-time field → LocalDateTime; null if absent or unparseable. */ + private static LocalDateTime parseDateTime(JsonNode root, String field) { + if (!root.hasNonNull(field)) { + return null; + } + try { + return LocalDateTime.parse(root.get(field).asText(null)); + } catch (RuntimeException e) { + return null; + } } /** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java index 1d1a8aa77d..52af366df4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkController.java @@ -2,6 +2,7 @@ package stirling.software.proprietary.accountlink; import java.io.IOException; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -23,7 +24,9 @@ import lombok.extern.slf4j.Slf4j; *

The portal (served from this same origin, admin authenticated by the existing self-hosted * security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS * backend, which mints + returns a device credential we store locally. {@code GET /status} backs - * the portal's link card. + * the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds + * to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now" + * / test aid). * *

Admin-only, {@code @Profile("!saas")}, gated behind {@code * stirling.billing.account-link.enabled} — off → bean absent → 404. @@ -38,9 +41,17 @@ import lombok.extern.slf4j.Slf4j; public class AccountLinkController { private final AccountLinkService service; + private final LocalUsageService localUsageService; + // Present only when metering is on (its own flag); absent → /sync-now reports 409. + private final ObjectProvider syncServiceProvider; - public AccountLinkController(AccountLinkService service) { + public AccountLinkController( + AccountLinkService service, + LocalUsageService localUsageService, + ObjectProvider syncServiceProvider) { this.service = service; + this.localUsageService = localUsageService; + this.syncServiceProvider = syncServiceProvider; } /** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */ @@ -85,4 +96,29 @@ public class AccountLinkController { service.unlink(); return ResponseEntity.noContent().build(); } + + /** + * Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend + * so "current usage" includes work done since the last daily sync. + */ + @GetMapping("/usage") + public ResponseEntity usage() { + return ResponseEntity.ok(localUsageService.currentPeriodUnsynced()); + } + + /** + * Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin + * "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent: + * re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run; + * {@code 409} when metering is off (the sync bean is absent). + */ + @PostMapping("/sync-now") + public ResponseEntity syncNow() { + UsageSyncService sync = syncServiceProvider.getIfAvailable(); + if (sync == null) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + sync.syncNow(); + return ResponseEntity.noContent().build(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java index c12e56f06d..6d1f1fb151 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkProperties.java @@ -1,5 +1,7 @@ package stirling.software.proprietary.accountlink; +import java.time.Duration; + import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; @@ -36,4 +38,39 @@ public class AccountLinkProperties { /** Connect/read timeout for the outbound SaaS calls. */ private int requestTimeoutSeconds = 10; + + /** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */ + private final Metering metering = new Metering(); + + /** + * Dedicated billing switch, separate from {@link #enabled} so the link plumbing can be + * enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap + * enforcement. Both default off; metering requires the master flag too. This is the production + * safety key — flipping it on is what actually bills linked instances. + */ + @Getter + @Setter + public static class Metering { + + /** Turns on usage metering, the daily sync, and cap enforcement. Default off. */ + private boolean enabled = false; + + /** + * How often the instance syncs usage + refreshes entitlement (matches the licence sync). + */ + private int syncIntervalHours = 24; + + /** + * Block billable work after this many days with no successful sync (fail-open → closed). + */ + private int graceDays = 3; + + /** + * Dedup window for identical input sets. A re-run of the same inputs within this window is + * treated as workflow chaining and not re-charged; the same inputs run again after it are + * billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op + * costs the same on the instance and in the cloud. + */ + private Duration workflowWindow = Duration.ofMinutes(5); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java new file mode 100644 index 0000000000..fbac6a8603 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncState.java @@ -0,0 +1,46 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A"). + * + *

{@link #lastSyncSeq} is reserved (incremented + persisted) before each report so it + * is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it, + * so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last + * sync SaaS accepted and drives the fail-open→closed grace window. + * + *

Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync. + */ +@Entity +@Table(name = "account_link_sync_state") +@Getter +@Setter +@NoArgsConstructor +public class AccountLinkSyncState { + + /** One instance links to one team → one bookkeeping row. */ + public static final long SINGLETON_ID = 1L; + + @Id private Long id; + + // columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres. + @Column( + name = "last_sync_seq", + nullable = false, + columnDefinition = "bigint not null default 0") + private long lastSyncSeq; + + /** Null until the first sync SaaS accepts. */ + @Column(name = "last_success_at") + private LocalDateTime lastSuccessAt; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java new file mode 100644 index 0000000000..15b5e3842d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/AccountLinkSyncStateRepository.java @@ -0,0 +1,6 @@ +package stirling.software.proprietary.accountlink; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */ +public interface AccountLinkSyncStateRepository extends JpaRepository {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java index 136e4c3214..476fbd111a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/BillableOperationClassifier.java @@ -3,14 +3,26 @@ package stirling.software.proprietary.accountlink; import jakarta.servlet.http.HttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.BillingCategoryClassifier; /** - * Classifies a request as billable (AI / automation) or free (a manual tool). + * Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only + * HTTP-level signals (no dependency on the saas module): * - *

Mirrors the saas billing categorisation at a coarse level, without depending on the saas - * module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation - * marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy - * sub-steps). Everything else — interactive manual PDF tools — is always free. + *

+ * + *

Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared + * {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the + * saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the + * caller (resolved from the security context), so this class stays free of any security-type + * dependency. */ public final class BillableOperationClassifier { @@ -18,16 +30,22 @@ public final class BillableOperationClassifier { private BillableOperationClassifier() {} - public static boolean isBillable(HttpServletRequest request) { - if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) { - return true; - } + /** + * @param apiKey whether the request authenticated via an API key (an {@code + * ApiKeyAuthenticationToken} principal), resolved by the caller from the security context. + */ + public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) { + boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null; + return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey); + } + + private static boolean isAiSurface(HttpServletRequest request) { String uri = request.getRequestURI(); if (uri == null) { return false; } // Prefix-match the AI surface (not a loose substring contains), stripping a deployment - // context path so //api/v1/ai/** still classifies as billable. + // context path so //api/v1/ai/** still classifies as AI. String ctx = request.getContextPath(); String path = ctx != null && !ctx.isEmpty() && uri.startsWith(ctx) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java index 63818c1f98..898fb54b3a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/EntitlementCache.java @@ -12,18 +12,13 @@ import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j; /** - * Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on - * every billable request. Single-slot (one instance = one linked team), TTL-based. + * Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable + * request. Single-slot (one instance = one linked team), TTL-based. * - *

Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it - * has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has - * ever been fetched and the latest refresh failed (the gate treats empty as "unknown → - * allow"). - * - *

But an AUTHORITATIVE deny (revoked/invalid credential → {@link - * AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a - * {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately - * rather than serving a stale entitled snapshot. + *

A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has + * and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An + * authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is + * replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately. */ @Slf4j @Service @@ -63,9 +58,8 @@ public class EntitlementCache { * not linked or the SaaS side is unreachable and we have no prior snapshot. */ public Optional current() { - // Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS - // call) while concurrent callers serve the last snapshot — no thundering herd of - // synchronous round-trips on the billable hot path. Safe because the gate fails open. + // Single-flight: when stale, exactly one thread refreshes while concurrent callers serve + // the last snapshot — no thundering herd of round-trips on the billable hot path. if (isStale(snapshot) && refreshing.compareAndSet(false, true)) { try { refresh(); @@ -77,16 +71,15 @@ public class EntitlementCache { } private boolean isStale(Snapshot snap) { - // fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed - // fetch backs off for a full TTL instead of every billable request re-triggering a - // blocking round-trip against a dead/slow SaaS endpoint. + // fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch + // backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS. return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0; } /** - * Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but - * still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny - * (revoked credential) replaces it with a blocked snapshot so the gate stops billable work. + * Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the + * attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a + * blocked snapshot. */ void refresh() { Optional cred = credentialStore.get(); @@ -101,15 +94,15 @@ public class EntitlementCache { if (fresh != null) { snapshot = new Snapshot(fresh, Instant.now()); } else { - // Unreachable / server error: keep the last known entitlement (may be null) but - // stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime. + // Unreachable / server error: keep the last known entitlement but stamp the attempt + // so we don't hammer SaaS; the gate fails open meanwhile. log.debug( "Entitlement refresh failed; reusing last known snapshot, backing off a TTL"); snapshot = new Snapshot(snapshot.entitlement(), Instant.now()); } } catch (AccountLinkClient.RevokedException e) { - // Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately - // rather than serving the stale entitled snapshot until the next unlink. + // Authoritative deny — block immediately rather than serving the stale entitled + // snapshot. log.info( "Entitlement denied (HTTP {}); blocking billable work for the revoked credential", e.status()); @@ -121,4 +114,14 @@ public class EntitlementCache { public void invalidate() { snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH); } + + /** + * Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh + * one), saving a redundant fetch. No-op on null. + */ + public void accept(InstanceEntitlement fresh) { + if (fresh != null) { + snapshot = new Snapshot(fresh, Instant.now()); + } + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java index 677183278b..87b4ddcde9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/GateDecision.java @@ -16,6 +16,11 @@ public record GateDecision(boolean allowed, Reason reason) { ENTITLED, /** Entitlement source unreachable — fail open, allow. */ FAIL_OPEN, + /** + * Linked + metering, but SaaS has been unreachable past the grace window — block (the + * fail-open backstop expired) so unbounded free/unbilled billable work can't continue. + */ + GRACE_EXPIRED, /** Not linked — block billable work; FE should prompt to link. */ NOT_LINKED, /** Linked but over the limit / no subscription — block billable work. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java index 6445d886e9..8760239957 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlement.java @@ -1,19 +1,53 @@ package stirling.software.proprietary.accountlink; +import java.time.LocalDateTime; + +import stirling.software.proprietary.billing.UnitCalcPolicy; + /** - * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response — - * just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no - * saas types. + * Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response. + * Mirrors the saas {@code EntitlementResponse} shape but carries no saas types. + * + *

The first five fields are what the gate enforces against; the trailing three are the + * metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its + * per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the + * revoked sentinel and unit tests that don't exercise metering. * * @param subscribed team has an active subscription * @param freeRemainingUnits remaining free-pool units (>0 means free work is available) * @param periodSpendUnits paid units spent this period * @param periodCapUnits paid cap for the period; {@code null} = uncapped * @param state coarse state classification (see {@link EntitlementState}) + * @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not + * supplied (older SaaS / gate-only sentinel) + * @param periodStart inclusive start of the current billing period; {@code null} if not supplied + * @param periodEnd exclusive end of the current billing period; {@code null} if not supplied */ public record InstanceEntitlement( boolean subscribed, long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - EntitlementState state) {} + EntitlementState state, + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) { + + /** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */ + public InstanceEntitlement( + boolean subscribed, + long freeRemainingUnits, + long periodSpendUnits, + Long periodCapUnits, + EntitlementState state) { + this( + subscribed, + freeRemainingUnits, + periodSpendUnits, + periodCapUnits, + state, + null, + null, + null); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java index c975bad055..018684b73f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementGate.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.accountlink; +import java.time.LocalDateTime; import java.util.Optional; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -15,14 +16,17 @@ import org.springframework.stereotype.Service; *

  • Flag off → always allow (feature inert). *
  • Manual tool → always allow (manual tools are free, never metered). *
  • Billable + not linked → block with {@code NOT_LINKED} ("link to activate"). - *
  • Billable + linked + entitlement unknown (unreachable) → fail open, allow. + *
  • Billable + linked + entitlement unknown (unreachable) → fail open, allow — unless + * metering is on and SaaS has been unreachable past the grace window, then block with {@code + * GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever. *
  • Billable + linked + entitled → allow. *
  • Billable + linked + credential revoked → block with {@code REVOKED}. *
  • Billable + linked + over limit → block with {@code OVER_LIMIT}. * * - *

    The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the - * live flag / linked-state / entitlement. This is the unit-tested core. + *

    The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live + * flag / linked-state / entitlement and computes whether the grace window has expired. This is the + * unit-tested core. */ @Service @Profile("!saas") @@ -32,14 +36,20 @@ public class InstanceEntitlementGate { private final AccountLinkProperties properties; private final DeviceCredentialStore credentialStore; private final EntitlementCache entitlementCache; + private final AccountLinkSyncStateRepository syncStateRepository; + private final LocalUsageService localUsageService; public InstanceEntitlementGate( AccountLinkProperties properties, DeviceCredentialStore credentialStore, - EntitlementCache entitlementCache) { + EntitlementCache entitlementCache, + AccountLinkSyncStateRepository syncStateRepository, + LocalUsageService localUsageService) { this.properties = properties; this.credentialStore = credentialStore; this.entitlementCache = entitlementCache; + this.syncStateRepository = syncStateRepository; + this.localUsageService = localUsageService; } /** Evaluates the gate for a request, resolving live state from the store + cache. */ @@ -53,18 +63,39 @@ public class InstanceEntitlementGate { boolean linked = credentialStore.isLinked(); Optional entitlement = linked ? entitlementCache.current() : Optional.empty(); - return decide(true, true, linked, entitlement); + boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired(); + // Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped + // subscription) — by local usage not yet synced, so the gate stops in real time instead of + // overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0. + long pendingUnsynced = + entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false) + ? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits() + : 0L; + return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced); + } + + /** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */ + private static boolean depletesCeiling(InstanceEntitlement e) { + return !e.subscribed() || e.periodCapUnits() != null; } /** * Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown" - * (unreachable): when linked, that fails open. + * (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace + * window elapsed with no authoritative contact), in which case it blocks. + * + * @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the + * free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate + * stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement + * cases, where it has no effect). */ public static GateDecision decide( boolean flagEnabled, boolean billable, boolean linked, - Optional entitlement) { + Optional entitlement, + boolean graceExpired, + long pendingUnsyncedUnits) { if (!flagEnabled) { return GateDecision.allow(GateDecision.Reason.FLAG_OFF); } @@ -75,30 +106,69 @@ public class InstanceEntitlementGate { return GateDecision.block(GateDecision.Reason.NOT_LINKED); } if (entitlement.isEmpty()) { - // Linked but entitlement source unreachable — never hard-block billable work on our - // inability to reach billing. - return GateDecision.allow(GateDecision.Reason.FAIL_OPEN); + // Linked but entitlement unreachable: fail open, unless the grace window has expired + // (so + // the fail-open can't grant unbounded unbilled work forever). + return graceExpired + ? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED) + : GateDecision.allow(GateDecision.Reason.FAIL_OPEN); } InstanceEntitlement e = entitlement.get(); if (e.state() == EntitlementState.REVOKED) { // Credential revoked/invalid (authoritative deny) — block, distinct from over-limit. return GateDecision.block(GateDecision.Reason.REVOKED); } - return entitled(e) + return entitled(e, pendingUnsyncedUnits) ? GateDecision.allow(GateDecision.Reason.ENTITLED) : GateDecision.block(GateDecision.Reason.OVER_LIMIT); } + /** + * True when metering is on and it's been {@code graceDays} since the last authoritative contact + * (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off + * disables the backstop. + */ + private boolean isGraceExpired() { + AccountLinkProperties.Metering metering = properties.getMetering(); + if (!metering.isEnabled() || metering.getGraceDays() <= 0) { + return false; + } + LocalDateTime reference = lastAuthoritativeContact(); + if (reference == null) { + return false; // can't determine elapsed time → fail open + } + return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now()); + } + + private LocalDateTime lastAuthoritativeContact() { + LocalDateTime lastSuccess = + syncStateRepository + .findById(AccountLinkSyncState.SINGLETON_ID) + .map(AccountLinkSyncState::getLastSuccessAt) + .orElse(null); + if (lastSuccess != null) { + return lastSuccess; + } + return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null); + } + /** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */ - private static boolean entitled(InstanceEntitlement e) { + private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) { if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) { return false; } if (e.subscribed()) { - // Subscribed: allowed unless a period cap is set and exceeded. - return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits(); + if (e.periodCapUnits() == null) { + return true; // uncapped subscription + } + // Project the cap the way the grant is projected: synced paid spend plus the paid part + // of local usage not yet synced (free grant is consumed first, so only the excess + // bills) — stops at the cap in real time instead of overshooting until the next sync. + long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits()); + return e.periodSpendUnits() + pendingPaid < e.periodCapUnits(); } - // Unsubscribed: only the free pool covers billable work. - return e.freeRemainingUnits() > 0; + // Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local + // usage not yet synced — deplete by the pending delta so we stop at the grant in real time. + return e.freeRemainingUnits() - pendingUnsyncedUnits > 0; } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java index 8597813a85..285943ee49 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptor.java @@ -1,27 +1,51 @@ package stirling.software.proprietary.accountlink; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.multipart.MultipartHttpServletRequest; import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.util.WebUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.jpdfium.PdfDocument; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.ContentHasher; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; +import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken; + /** - * Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and - * blocks it when the instance is unlinked or over its limit; manual tools pass straight through. + * Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API / + * AI / automation) work when the instance is unlinked or over its limit; manual tools pass through. + * {@code afterCompletion} meters a successful billable op into the per-period cumulative counter. * - *

    Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code - * {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to - * activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit - * responses). Fail-open and flag-off both let the request continue. - * - *

    Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link - * AccountLinkWebMvcConfig} never registers it, so there is no per-request cost. + *

    Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate" + * prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind + * {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link + * UsageMeterService} bean is absent and nothing accrues, while the gate still works. */ @Slf4j @Component @@ -29,10 +53,23 @@ import lombok.extern.slf4j.Slf4j; @ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") public class InstanceEntitlementInterceptor implements HandlerInterceptor { - private final InstanceEntitlementGate gate; + private static final String ATTR_CATEGORY = + InstanceEntitlementInterceptor.class.getName() + ".category"; - public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) { + private final InstanceEntitlementGate gate; + private final EntitlementCache entitlementCache; + private final ObjectProvider meterProvider; + private final TempFileManager tempFileManager; + + public InstanceEntitlementInterceptor( + InstanceEntitlementGate gate, + EntitlementCache entitlementCache, + ObjectProvider meterProvider, + TempFileManager tempFileManager) { this.gate = gate; + this.entitlementCache = entitlementCache; + this.meterProvider = meterProvider; + this.tempFileManager = tempFileManager; } @Override @@ -41,7 +78,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { throws Exception { GateDecision decision; try { - decision = gate.evaluate(BillableOperationClassifier.isBillable(request)); + // API-key tool calls are billable (category API); stash the category for the meter. + boolean apiKey = + SecurityContextHolder.getContext().getAuthentication() + instanceof ApiKeyAuthenticationToken; + BillingCategory category = BillableOperationClassifier.categorize(request, apiKey); + request.setAttribute(ATTR_CATEGORY, category); + decision = gate.evaluate(category != BillingCategory.BYPASSED); } catch (RuntimeException e) { // Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never // turn into a hard block on billable work. @@ -62,4 +105,139 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor { + "\"}"); return false; } + + @Override + public void afterCompletion( + HttpServletRequest request, + HttpServletResponse response, + Object handler, + Exception ex) { + // Meter successful billable ops only. + if (ex != null || response.getStatus() >= 400) { + return; + } + UsageMeterService meter = meterProvider.getIfAvailable(); + if (meter == null) { + return; // metering switch off + } + if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category) + || category == BillingCategory.BYPASSED) { + return; + } + try { + InstanceEntitlement ent = entitlementCache.current().orElse(null); + if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) { + // Not yet synced (no policy/period) — can't compute units; skip until next sync. + return; + } + meterRequest(request, category, ent, meter); + } catch (RuntimeException e) { + // Metering must never affect the response that already completed. + log.debug("Usage metering failed for {}", request.getRequestURI(), e); + } + } + + /** + * Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance + * is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy + * but small PDF must be page-counted or it under-bills. A fileless op has no input identity — + * null signature (no dedup), billed the 1-unit floor each time. + */ + private void meterRequest( + HttpServletRequest request, + BillingCategory category, + InstanceEntitlement ent, + UsageMeterService meter) { + UnitCalcPolicy policy = ent.unitCalcPolicy(); + MultipartHttpServletRequest mreq = + WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class); + if (mreq == null) { + long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy); + meter.accrue(ent.periodStart(), category, fileless, null); + return; + } + List temps = new ArrayList<>(); + try { + List sizes = new ArrayList<>(); + List hashes = new ArrayList<>(); + int fileCount = 0; + for (List files : mreq.getMultiFileMap().values()) { + for (MultipartFile f : files) { + fileCount++; + try { + TempFile temp = tempFileManager.createManagedTempFile(".bin"); + temps.add(temp); + // Hash in the same pass that writes the temp file — one read of the upload, + // not a second full read just to fingerprint it. + MessageDigest digest = ContentHasher.newSha256(); + try (InputStream in = f.getInputStream(); + DigestOutputStream out = + new DigestOutputStream( + Files.newOutputStream(temp.getPath()), digest)) { + in.transferTo(out); + } + sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize())); + hashes.add(ContentHasher.toHex(digest.digest())); + } catch (IOException | RuntimeException perFile) { + // Couldn't materialise/hash this input — bill on bytes only and, by leaving + // it out of `hashes`, drop dedup for the whole op rather than risk a + // mismatch. + log.debug( + "Metering materialise/hash failed for {}; bytes-only", + f.getOriginalFilename()); + sizes.add(new FileSize(0, f.getSize())); + } + } + } + long units = + sizes.isEmpty() + ? DocumentUnitCalculator.unitsForFile(0, 0, policy) + : DocumentUnitCalculator.unitsForGroup(sizes, policy); + // Only dedup when every input hashed; a partial signature could collide with a + // different input set, so fall back to no-dedup (bill it) if any file failed. + String opSignature = + fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null; + meter.accrue(ent.periodStart(), category, units, opSignature); + } finally { + for (TempFile temp : temps) { + try { + temp.close(); + } catch (RuntimeException cleanup) { + log.debug("Temp file cleanup failed: {}", cleanup.getMessage()); + } + } + } + } + + /** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */ + private static int pageCount(Path path, MultipartFile file) { + if (!isPdf(file)) { + return 0; + } + try (PdfDocument doc = PdfDocument.open(path)) { + return doc.pageCount(); + } catch (RuntimeException e) { + // Malformed / encrypted → byte axis only, matching the SaaS classifier. + log.debug( + "Page count unavailable for {}; metering on bytes only", + file.getOriginalFilename()); + return 0; + } + } + + /** Order-independent signature of the input set: sorted per-file hashes, hashed together. */ + private static String opSignature(List hashes) { + List sorted = new ArrayList<>(hashes); + Collections.sort(sorted); + return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8)); + } + + private static boolean isPdf(MultipartFile file) { + String contentType = file.getContentType(); + if (contentType != null && contentType.toLowerCase().contains("pdf")) { + return true; + } + String name = file.getOriginalFilename(); + return name != null && name.toLowerCase().endsWith(".pdf"); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java new file mode 100644 index 0000000000..58d365fe28 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/LocalUsageService.java @@ -0,0 +1,59 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.EnumMap; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal + * adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync. + * + *

    Unsynced per category = {@code cumulativeUnits − lastSyncedUnits} (floored at 0), scoped to + * the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown + * or metering is off. + */ +@Service +@Profile("!saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class LocalUsageService { + + private final UsageCounterRepository counters; + private final EntitlementCache entitlementCache; + + public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) { + this.counters = counters; + this.entitlementCache = entitlementCache; + } + + /** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */ + public record LocalUsage( + LocalDateTime periodStart, + long apiUnsyncedUnits, + long aiUnsyncedUnits, + long automationUnsyncedUnits, + long totalUnsyncedUnits) {} + + public LocalUsage currentPeriodUnsynced() { + LocalDateTime period = + entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null); + if (period == null) { + return new LocalUsage(null, 0, 0, 0, 0); + } + EnumMap unsynced = new EnumMap<>(BillingCategory.class); + for (UsageCounter c : counters.findByPeriodStart(period)) { + BillingCategory cat = c.billingCategory(); + if (cat != null && cat != BillingCategory.BYPASSED) { + unsynced.merge(cat, c.unsyncedUnits(), Long::sum); + } + } + long api = unsynced.getOrDefault(BillingCategory.API, 0L); + long ai = unsynced.getOrDefault(BillingCategory.AI, 0L); + long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L); + return new LocalUsage(period, api, ai, automation, api + ai + automation); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java new file mode 100644 index 0000000000..1ed49d6a8b --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignature.java @@ -0,0 +1,73 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +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 jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * The last time the instance metered a given input set this period — the local equivalent of the + * cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling workflow + * window: an identical input set re-submitted within the window (see {@link + * AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the + * same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job + * window so the same operation costs the same on the instance and in the cloud. + * + *

    {@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud + * artifact touches its job). One row per {@code (period, signature)}; the unique constraint also + * makes the first-sighting insert an atomic claim under concurrency. + * + *

    Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter. + */ +@Entity +@Table( + name = "account_link_metered_signature", + uniqueConstraints = + @UniqueConstraint( + name = "uk_account_link_metered_signature", + columnNames = {"period_start", "signature"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MeteredInputSignature { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */ + @Column(name = "signature", nullable = false, length = 64) + private String signature; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** + * When this input set was last metered — the anchor the workflow-window dedup compares against. + */ + @Column(name = "last_metered_at") + private LocalDateTime lastMeteredAt; + + public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) { + this.periodStart = periodStart; + this.signature = signature; + this.createdAt = at; + this.lastMeteredAt = at; + } + + /** Slides the window forward — the input set was seen again. */ + public void touch(LocalDateTime at) { + this.lastMeteredAt = at; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java new file mode 100644 index 0000000000..863f503f61 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/MeteredInputSignatureRepository.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */ +public interface MeteredInputSignatureRepository + extends JpaRepository { + + /** The existing row for a seen input set, so the meter can apply the workflow-window check. */ + Optional findByPeriodStartAndSignature( + LocalDateTime periodStart, String signature); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java new file mode 100644 index 0000000000..b90af07958 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounter.java @@ -0,0 +1,105 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; + +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 jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A". + * Each successful billable op increments its row; the daily sync reports the cumulative totals and + * SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills + * nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start, + * category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it. + */ +@Entity +@Table( + name = "account_link_usage_counter", + uniqueConstraints = + @UniqueConstraint( + name = "uk_usage_counter_period_category", + columnNames = {"period_start", "category"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UsageCounter { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * Inclusive start of the billing period this counter belongs to (from the entitlement sync). + */ + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */ + @Column(name = "category", nullable = false, length = 32) + private String category; + + /** Running total of metered units in this period+category. */ + @Column(name = "cumulative_units", nullable = false) + private long cumulativeUnits; + + /** + * {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported + * usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default + * keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already + * populated (NOT NULL with no default would fail the ALTER). + */ + @Column( + name = "last_synced_units", + nullable = false, + columnDefinition = "bigint not null default 0") + private long lastSyncedUnits; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** Fresh-accrual row: nothing synced yet. */ + public UsageCounter( + LocalDateTime periodStart, + String category, + long cumulativeUnits, + LocalDateTime updatedAt) { + this(periodStart, category, cumulativeUnits, 0L, updatedAt); + } + + public UsageCounter( + LocalDateTime periodStart, + String category, + long cumulativeUnits, + long lastSyncedUnits, + LocalDateTime updatedAt) { + this.periodStart = periodStart; + this.category = category; + this.cumulativeUnits = cumulativeUnits; + this.lastSyncedUnits = lastSyncedUnits; + this.updatedAt = updatedAt; + } + + /** This row's category as the enum, or {@code null} for an unrecognised stored value. */ + public BillingCategory billingCategory() { + try { + return BillingCategory.valueOf(category); + } catch (IllegalArgumentException unknown) { + return null; + } + } + + /** Units accrued but not yet accepted by SaaS (floored at 0). */ + public long unsyncedUnits() { + return Math.max(0, cumulativeUnits - lastSyncedUnits); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java new file mode 100644 index 0000000000..2140775abc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageCounterRepository.java @@ -0,0 +1,57 @@ +package stirling.software.proprietary.accountlink; + +import java.time.LocalDateTime; +import java.util.List; + +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.transaction.annotation.Transactional; + +/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */ +public interface UsageCounterRepository extends JpaRepository { + + /** + * Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated + * (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a + * read-modify-write race between concurrent billable requests. + */ + @Modifying + @Transactional + @Query( + "UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta," + + " c.updatedAt = :now" + + " WHERE c.periodStart = :periodStart AND c.category = :category") + int increment( + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category, + @Param("delta") long delta, + @Param("now") LocalDateTime now); + + /** All counters for a period — the daily sync reads these to report cumulative totals. */ + List findByPeriodStart(LocalDateTime periodStart); + + /** + * Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each + * so end-of-period usage isn't stranded when the billing period rolls over between syncs. + */ + @Query( + "SELECT DISTINCT c.periodStart FROM UsageCounter c" + + " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart") + List findPeriodsWithUnsyncedUsage(); + + /** + * Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by + * SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced. + */ + @Modifying + @Transactional + @Query( + "UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits" + + " WHERE c.periodStart = :periodStart AND c.category = :category") + int markSynced( + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category, + @Param("syncedUnits") long syncedUnits); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java new file mode 100644 index 0000000000..6aa93606fb --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageMeterService.java @@ -0,0 +1,115 @@ +package stirling.software.proprietary.accountlink; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily + * sync later reports the cumulative totals to SaaS. + * + *

    Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow- + * window} is treated as chaining and not re-charged; the same inputs run again after the window are + * billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the + * instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue} + * is best-effort: callers need not handle persistence errors. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty( + name = "stirling.billing.account-link.metering.enabled", + havingValue = "true") +public class UsageMeterService { + + private final UsageCounterRepository repo; + private final MeteredInputSignatureRepository signatureRepo; + private final Duration workflowWindow; + + public UsageMeterService( + UsageCounterRepository repo, + MeteredInputSignatureRepository signatureRepo, + AccountLinkProperties properties) { + this.repo = repo; + this.signatureRepo = signatureRepo; + this.workflowWindow = properties.getMetering().getWorkflowWindow(); + } + + /** + * Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first + * use), unless {@code opSignature} was already metered this period. No-ops for non-billable + * categories, non-positive units, or a missing period. + */ + public void accrue( + LocalDateTime periodStart, BillingCategory category, long units, String opSignature) { + if (periodStart == null + || category == null + || category == BillingCategory.BYPASSED + || units <= 0) { + return; + } + if (opSignature != null && !shouldCharge(periodStart, opSignature)) { + return; // identical inputs seen within the workflow window — chaining, already billed + } + incrementOrInsert(periodStart, category.name(), units); + } + + /** + * True when this input set should be charged: unseen this period, or last seen outside the + * workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and + * slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge. + */ + private boolean shouldCharge(LocalDateTime periodStart, String opSignature) { + LocalDateTime now = LocalDateTime.now(); + MeteredInputSignature seen = + signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null); + if (seen == null) { + try { + signatureRepo.saveAndFlush( + new MeteredInputSignature(periodStart, opSignature, now)); + return true; // first sighting this period + } catch (DataIntegrityViolationException raced) { + return false; // a concurrent op just claimed it — within window → chaining + } catch (RuntimeException e) { + log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage()); + return true; + } + } + LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now; + boolean withinWindow = last.isAfter(now.minus(workflowWindow)); + try { + seen.touch(now); + signatureRepo.save(seen); + } catch (RuntimeException e) { + log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage()); + } + return !withinWindow; + } + + private void incrementOrInsert(LocalDateTime periodStart, String category, long units) { + LocalDateTime now = LocalDateTime.now(); + try { + if (repo.increment(periodStart, category, units, now) > 0) { + return; + } + try { + repo.saveAndFlush(new UsageCounter(periodStart, category, units, now)); + } catch (DataIntegrityViolationException raceLostInsert) { + // A concurrent request inserted the row first — increment the now-existing row. + repo.increment(periodStart, category, units, now); + } + } catch (RuntimeException e) { + // Metering must never break the request it rode in on; a lost accrual self-heals on the + // next increment and the daily sync reports the cumulative total either way. + log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage()); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java new file mode 100644 index 0000000000..4c4ce2377c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/accountlink/UsageSyncService.java @@ -0,0 +1,189 @@ +package stirling.software.proprietary.accountlink; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Optional; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.FixedDelayTask; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.billing.BillingCategory; + +/** + * Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category + * usage to SaaS, which bills the delta against its own last-seen totals. + * + *

    Resilience: the sync seq is persisted before the report so it never regresses across + * restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so + * usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All + * periods with unsynced usage are reported so nothing is stranded when the period rolls over + * between syncs. + */ +@Slf4j +@Service +@Profile("!saas") +@ConditionalOnProperty( + name = "stirling.billing.account-link.metering.enabled", + havingValue = "true") +public class UsageSyncService implements SchedulingConfigurer { + + // First run waits out startup churn; then every interval. + private static final Duration INITIAL_DELAY = Duration.ofMinutes(5); + + private final UsageCounterRepository counters; + private final AccountLinkSyncStateRepository syncState; + private final DeviceCredentialStore credentialStore; + private final AccountLinkClient client; + private final EntitlementCache entitlementCache; + private final AccountLinkProperties properties; + + public UsageSyncService( + UsageCounterRepository counters, + AccountLinkSyncStateRepository syncState, + DeviceCredentialStore credentialStore, + AccountLinkClient client, + EntitlementCache entitlementCache, + AccountLinkProperties properties) { + this.counters = counters; + this.syncState = syncState; + this.credentialStore = credentialStore; + this.client = client; + this.entitlementCache = entitlementCache; + this.properties = properties; + } + + /** + * Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in + * code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather + * than only on a flags-on run. + */ + @Override + public void configureTasks(ScheduledTaskRegistrar registrar) { + Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours()); + registrar.addFixedDelayTask( + new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY)); + } + + public void scheduledSync() { + try { + syncNow(); + } catch (RuntimeException e) { + log.debug("Scheduled usage sync failed", e); + } + } + + /** + * Reports every period with unsynced usage and refreshes the cached entitlement from the reply. + * Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when + * unlinked or when nothing is pending. + */ + public void syncNow() { + Optional cred = credentialStore.get(); + if (cred.isEmpty()) { + return; // not linked + } + List periods = counters.findPeriodsWithUnsyncedUsage(); + if (periods.isEmpty()) { + // Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement + // change (e.g. the admin just subscribed) that otherwise wouldn't surface until the + // cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now. + entitlementCache.invalidate(); + entitlementCache.current(); + return; + } + InstanceEntitlement latest = null; + try { + for (LocalDateTime period : periods) { + InstanceEntitlement fresh = syncPeriod(cred.get(), period); + if (fresh != null) { + latest = fresh; + } + } + } catch (AccountLinkClient.RevokedException e) { + // Authoritative deny — stop reporting; the entitlement cache blocks billable work on + // its + // own next refresh, so we don't synthesise the blocked state here. + log.info( + "Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next" + + " refresh", + e.status()); + return; + } + // Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch. + entitlementCache.accept(latest); + } + + /** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */ + private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) { + EnumMap cumulative = new EnumMap<>(BillingCategory.class); + for (UsageCounter c : counters.findByPeriodStart(period)) { + BillingCategory cat = c.billingCategory(); + if (cat != null && cat != BillingCategory.BYPASSED) { + cumulative.merge(cat, c.getCumulativeUnits(), Long::sum); + } + } + AccountLinkSyncState state = loadState(); + long seq = reserveNextSeq(state); + InstanceEntitlement fresh = + client.reportUsage( + cred.getDeviceId(), + cred.getDeviceSecret(), + seq, + period, + cumulative.getOrDefault(BillingCategory.API, 0L), + cumulative.getOrDefault(BillingCategory.AI, 0L), + cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L)); + if (fresh == null) { + // Transport/server failure: leave the synced markers untouched. The burned seq is + // harmless (seqs need only be monotonic) and the delta bills on the next successful + // sync. + return null; + } + recordSuccess(period, cumulative, state); + return fresh; + } + + /** Reserves and persists the next strictly-increasing sequence before the report goes out. */ + private long reserveNextSeq(AccountLinkSyncState state) { + long next = state.getLastSyncSeq() + 1; + state.setLastSyncSeq(next); + syncState.save(state); + return next; + } + + /** + * Advances the per-category synced markers to the reported totals + stamps the success time. + */ + private void recordSuccess( + LocalDateTime period, + EnumMap cumulative, + AccountLinkSyncState state) { + cumulative.forEach( + (category, units) -> { + if (units > 0) { + counters.markSynced(period, category.name(), units); + } + }); + state.setLastSuccessAt(LocalDateTime.now()); + syncState.save(state); + } + + private AccountLinkSyncState loadState() { + return syncState + .findById(AccountLinkSyncState.SINGLETON_ID) + .orElseGet( + () -> { + AccountLinkSyncState s = new AccountLinkSyncState(); + s.setId(AccountLinkSyncState.SINGLETON_ID); + return s; + }); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java new file mode 100644 index 0000000000..51ca47ad64 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategory.java @@ -0,0 +1,22 @@ +package stirling.software.proprietary.billing; + +/** + * The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so + * category is metadata only and never affects price. + * + *

    Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link + * BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never + * billed. + * + *

    Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted + * instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} / + * {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the + * same names. (We deliberately do not share one enum across the modules: that would drag the SaaS + * billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.) + */ +public enum BillingCategory { + BYPASSED, + API, + AI, + AUTOMATION +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java new file mode 100644 index 0000000000..95b3abb99c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/BillingCategoryClassifier.java @@ -0,0 +1,29 @@ +package stirling.software.proprietary.billing; + +/** + * Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a + * linked self-hosted instance classify identically. Each backend resolves the three signals from + * its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature} + * annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this + * applies the order {@code AUTOMATION → AI → API → BYPASSED}. + * + *

    An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the + * automation header dominates), while a direct call to it bills as {@code AI}. + */ +public final class BillingCategoryClassifier { + + private BillingCategoryClassifier() {} + + public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) { + if (automation) { + return BillingCategory.AUTOMATION; + } + if (ai) { + return BillingCategory.AI; + } + if (apiKey) { + return BillingCategory.API; + } + return BillingCategory.BYPASSED; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java new file mode 100644 index 0000000000..232dd499dc --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/ContentHasher.java @@ -0,0 +1,68 @@ +package stirling.software.proprietary.billing; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's + * meter (combined-billing "Mode A"), so both derive an identical signature for the same + * bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent + * of file size), hardware-accelerated by the JVM where available. + * + *

    Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core + * build yet is reachable from {@code :saas} (which depends on {@code :proprietary}). + */ +public final class ContentHasher { + + private static final String ALGORITHM = "SHA-256"; + private static final int BUFFER_SIZE = 64 * 1024; + + private ContentHasher() {} + + /** Lower-case hex SHA-256 of the file's bytes. */ + public static String sha256(Path file) throws IOException { + MessageDigest digest = newDigest(); + try (InputStream raw = Files.newInputStream(file); + DigestInputStream in = new DigestInputStream(raw, digest)) { + byte[] buf = new byte[BUFFER_SIZE]; + while (in.read(buf) != -1) { + // drain through the digest; we only want the side effect + } + } + return HexFormat.of().formatHex(digest.digest()); + } + + /** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */ + public static String sha256(byte[] bytes) { + return HexFormat.of().formatHex(newDigest().digest(bytes)); + } + + /** + * A fresh SHA-256 digest, for callers that stream bytes through a {@link + * java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a + * second full read just to fingerprint it. Pair with {@link #toHex(byte[])}. + */ + public static MessageDigest newSha256() { + return newDigest(); + } + + /** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */ + public static String toHex(byte[] digest) { + return HexFormat.of().formatHex(digest); + } + + private static MessageDigest newDigest() { + try { + return MessageDigest.getInstance(ALGORITHM); + } catch (NoSuchAlgorithmException e) { + // SHA-256 is mandated by every JDK; unreachable in practice. + throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java new file mode 100644 index 0000000000..2cc22daf41 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/DocumentUnitCalculator.java @@ -0,0 +1,76 @@ +package stirling.software.proprietary.billing; + +import java.util.List; + +/** + * Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both + * cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however + * their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}. + * + *

    Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code + * ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis + * contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the + * raw per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not + * per-file-clamped, units so the group cap can actually bind). + * + *

    {@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this + * enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on + * "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code + * DefaultDocumentClassifier} to preserve behaviour. + */ +public final class DocumentUnitCalculator { + + /** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */ + public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; + + private DocumentUnitCalculator() {} + + /** One file's page count (0 for non-PDF / unreadable) and byte size. */ + public record FileSize(int pages, long bytes) {} + + /** Raw (unclamped) units for one file. */ + public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) { + long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L; + long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit()); + return Math.max(pageUnits, byteUnits); + } + + /** Units for a single file, clamped to {@code [1, fileUnitCap]}. */ + public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) { + long raw = rawUnits(pages, bytes, policy); + // toIntExact: fail loud on overflow rather than silently wrapping a billing number. + return Math.toIntExact( + Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw))); + } + + /** + * Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}. + */ + public static int unitsForGroup(List files, UnitCalcPolicy policy) { + if (files.isEmpty()) { + throw new IllegalArgumentException("files must not be empty"); + } + long rawSum = 0; + for (FileSize f : files) { + rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy)); + } + long groupCap = (long) policy.fileUnitCap() * files.size(); + return Math.toIntExact( + Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum))); + } + + private static long ceilDiv(long numerator, long divisor) { + if (numerator <= 0) { + return 0; + } + return (numerator + divisor - 1) / divisor; + } + + private static long saturatedAdd(long a, long b) { + try { + return Math.addExact(a, b); + } catch (ArithmeticException e) { + return Long.MAX_VALUE; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java new file mode 100644 index 0000000000..b7d773d465 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/billing/UnitCalcPolicy.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.billing; + +/** + * The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA + * entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and + * be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an + * operation identically. + * + *

    The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance + * receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for + * the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs). + */ +public record UnitCalcPolicy( + int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) { + + public UnitCalcPolicy { + if (docPagesPerUnit <= 0) { + throw new IllegalArgumentException("docPagesPerUnit must be > 0"); + } + if (docBytesPerUnit <= 0) { + throw new IllegalArgumentException("docBytesPerUnit must be > 0"); + } + if (minChargeUnits < 1) { + throw new IllegalArgumentException("minChargeUnits must be >= 1"); + } + if (fileUnitCap < 1) { + throw new IllegalArgumentException("fileUnitCap must be >= 1"); + } + } +} diff --git a/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md b/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md index 57cdce61b5..58702d3524 100644 --- a/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md +++ b/app/proprietary/src/main/resources/templates/AUDIT_USAGE.md @@ -223,7 +223,7 @@ Use consistent event types throughout the application: - `FILE_DOWNLOAD` - When a file is downloaded - `PDF_PROCESS` - When a PDF is processed (split, merged, etc.) - `USER_CREATE` - When a user is created -- `USER_UPDATE` - When a user details are updated +- `USER_UPDATE` - When a user's details are updated - `PASSWORD_CHANGE` - When a password is changed - `PERMISSION_CHANGE` - When permissions are modified - `SETTINGS_CHANGE` - When system settings are changed diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java index 7d954a4398..969111e9f5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkClientTest.java @@ -12,6 +12,7 @@ import java.net.ConnectException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -189,4 +190,73 @@ class AccountLinkClientTest { .thenThrow(new ConnectException("refused")); assertEquals(false, client.revokeSelf("dev-1", "sec-1")); } + + @Test + @SuppressWarnings("unchecked") + void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception { + HttpResponse resp = + response( + 200, + "{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}"); + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class))) + .thenReturn(resp); + + InstanceEntitlement e = + client.reportUsage( + "dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8); + + assertNotNull(e); + assertEquals(42, e.periodSpendUnits()); + assertEquals(EntitlementState.OK, e.state()); + + HttpRequest sent = captor.getValue(); + assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString()); + assertEquals("POST", sent.method()); + assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null)); + assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageThrowsRevokedOnDeny() throws Exception { + for (int status : new int[] {401, 403}) { + HttpResponse resp = response(status, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + AccountLinkClient.RevokedException ex = + assertThrows( + AccountLinkClient.RevokedException.class, + () -> + client.reportUsage( + "dev-1", + "sec-1", + 1L, + LocalDateTime.of(2026, 6, 1, 0, 0), + 1, + 0, + 0)); + assertEquals(status, ex.status()); + } + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullWhenUnreachable() throws Exception { + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))) + .thenThrow(new ConnectException("refused")); + // Null = don't advance synced markers; the usage retries on the next sync. + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } + + @Test + @SuppressWarnings("unchecked") + void reportUsageReturnsNullOnServerError() throws Exception { + HttpResponse resp = response(503, "{}"); + when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp); + assertNull( + client.reportUsage( + "dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0)); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java index 6e4a816bb0..41f544de7e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/AccountLinkControllerTest.java @@ -2,12 +2,15 @@ package stirling.software.proprietary.accountlink; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.IOException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -21,12 +24,18 @@ import stirling.software.proprietary.accountlink.AccountLinkController.LinkReque class AccountLinkControllerTest { private AccountLinkService service; + private UsageSyncService syncService; + private ObjectProvider syncProvider; private AccountLinkController controller; @BeforeEach + @SuppressWarnings("unchecked") void setUp() { service = mock(AccountLinkService.class); - controller = new AccountLinkController(service); + syncService = mock(UsageSyncService.class); + syncProvider = mock(ObjectProvider.class); + controller = + new AccountLinkController(service, mock(LocalUsageService.class), syncProvider); } @Test @@ -65,4 +74,24 @@ class AccountLinkControllerTest { ResponseEntity resp = controller.link(new LinkRequest("jwt", null)); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY); } + + @Test + void syncNow_triggersSyncWhenMeteringOn() { + when(syncProvider.getIfAvailable()).thenReturn(syncService); + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(syncService).syncNow(); + } + + @Test + void syncNow_returns409WhenMeteringOff() { + when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent + + ResponseEntity resp = controller.syncNow(); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + verify(syncService, never()).syncNow(); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java index 275026794a..0b43069ee9 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/BillableOperationClassifierTest.java @@ -1,49 +1,78 @@ package stirling.software.proprietary.accountlink; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import stirling.software.common.service.InternalApiClient; +import stirling.software.proprietary.billing.BillingCategory; class BillableOperationClassifierTest { - @Test - void aiPathIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo"); - assertTrue(BillableOperationClassifier.isBillable(req)); + private static MockHttpServletRequest req(String uri) { + return new MockHttpServletRequest("POST", uri); } @Test - void automationHeaderIsBillable() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); + void aiPathIsAi() { + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false)); + } + + @Test + void automationHeaderIsAutomation() { + MockHttpServletRequest req = req("/api/v1/general/merge"); req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals( + BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false)); } @Test - void plainManualToolIsFree() { - MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void apiKeyToolCallIsApi() { + assertEquals( + BillingCategory.API, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true)); } @Test - void aiSegmentNotAtPathStartIsFree() { - // Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a - // proxy prefix) must NOT classify a manual tool as billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo"); - assertFalse(BillableOperationClassifier.isBillable(req)); + void plainManualToolIsBypassed() { + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false)); } @Test - void aiPathUnderContextPathIsBillable() { - // A real context-path deployment still classifies: //api/v1/ai/** is billable. - MockHttpServletRequest req = - new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo"); + void automationDominatesAiAndApiKey() { + // An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION. + MockHttpServletRequest req = req("/api/v1/ai/tools/foo"); + req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true"); + assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true)); + } + + @Test + void aiDominatesApiKey() { + // A direct API-key call to an AI tool bills as AI, not API. + assertEquals( + BillingCategory.AI, + BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true)); + } + + @Test + void aiSegmentNotAtPathStartIsBypassed() { + // Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix) + // must NOT classify a manual tool as AI. + assertEquals( + BillingCategory.BYPASSED, + BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false)); + } + + @Test + void aiPathUnderContextPathIsAi() { + // A real context-path deployment still classifies: //api/v1/ai/** is AI. + MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo"); req.setContextPath("/stirling"); - assertTrue(BillableOperationClassifier.isBillable(req)); + assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false)); } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java index 0c250fd3e9..1838034a9a 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateTest.java @@ -3,20 +3,32 @@ package stirling.software.proprietary.accountlink; 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 java.time.LocalDateTime; 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.accountlink.GateDecision.Reason; /** - * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and - * over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is - * needed. + * Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired, + * linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no + * Spring; the grace-window reference computation is exercised through {@link + * InstanceEntitlementGate#evaluate} with mocked collaborators. */ +@ExtendWith(MockitoExtension.class) class InstanceEntitlementGateTest { + @Mock private DeviceCredentialStore credentialStore; + @Mock private EntitlementCache entitlementCache; + @Mock private AccountLinkSyncStateRepository syncStateRepository; + @Mock private LocalUsageService localUsageService; + private static InstanceEntitlement free() { return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK); } @@ -35,35 +47,67 @@ class InstanceEntitlementGateTest { @Test void flagOff_allowsEverything_evenBillableUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FLAG_OFF, d.reason()); } @Test void manualTool_alwaysFree_evenUnlinked() { - GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.MANUAL_FREE, d.reason()); } @Test void billable_notLinked_blocksWithLinkSignal() { - GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty()); + GateDecision d = + InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.NOT_LINKED, d.reason()); } @Test - void billable_linked_entitlementUnreachable_failsOpen() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty()); + void billable_linked_entitlementUnreachable_withinGrace_failsOpen() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.FAIL_OPEN, d.reason()); } + @Test + void billable_linked_entitlementUnreachable_graceExpired_blocks() { + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + @Test void billable_linked_freePoolAvailable_allows() { - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free())); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() { + // free() has 100 free units left per the last sync; 100 accrued locally since would exhaust + // it once charged, so the gate stops here in real time rather than waiting for the sync. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() { + // 99 pending against 100 remaining → one unit of grant still projected free → allow. + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -72,7 +116,7 @@ class InstanceEntitlementGateTest { void billable_linked_unsubscribedAndExhausted_blocksOverLimit() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(exhaustedUnsubscribed())); + true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } @@ -81,7 +125,7 @@ class InstanceEntitlementGateTest { void billable_linked_subscribedWithinCap_allows() { GateDecision d = InstanceEntitlementGate.decide( - true, true, true, Optional.of(subscribedWithinCap())); + true, true, true, Optional.of(subscribedWithinCap()), false, 0L); assertTrue(d.allowed()); assertEquals(Reason.ENTITLED, d.reason()); } @@ -89,18 +133,67 @@ class InstanceEntitlementGateTest { @Test void billable_linked_subscribedOverCap_blocks() { GateDecision d = - InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap())); + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedOverCap()), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } + @Test + void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() { + // Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would + // push + // projected spend to 105 → the gate stops now, not after the next sync reconciles. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 95L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() { + // 10 synced + 80 pending = 90 < 100 cap → still room. + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithinCap()), false, 80L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() { + // 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap → + // allow. + InstanceEntitlement subscribedWithGrant = + new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(subscribedWithGrant), false, 40L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + + @Test + void billable_linked_subscribedUncapped_pendingIgnored_allows() { + // No cap → local pending has no ceiling to hit → always allowed. + InstanceEntitlement uncapped = + new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(uncapped), false, 500L); + assertTrue(d.allowed()); + assertEquals(Reason.ENTITLED, d.reason()); + } + @Test void billable_linked_revoked_blocksWithRevokedSignal() { // Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED — // blocks distinctly from over-limit, even though the snapshot is "present". InstanceEntitlement revoked = new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked)); + GateDecision d = + InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L); assertFalse(d.allowed()); assertEquals(Reason.REVOKED, d.reason()); } @@ -110,7 +203,98 @@ class InstanceEntitlementGateTest { // Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive. InstanceEntitlement conflicting = new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT); - GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting)); + GateDecision d = + InstanceEntitlementGate.decide( + true, true, true, Optional.of(conflicting), false, 0L); + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + // --- grace window (evaluate()) --------------------------------------------------------------- + + private InstanceEntitlementGate gate(AccountLinkProperties props) { + return new InstanceEntitlementGate( + props, credentialStore, entitlementCache, syncStateRepository, localUsageService); + } + + private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) { + AccountLinkProperties p = new AccountLinkProperties(); + p.setEnabled(true); + p.getMetering().setEnabled(meteringEnabled); + p.getMetering().setGraceDays(graceDays); + return p; + } + + @Test + void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + + GateDecision d = gate(props(false, 3)).evaluate(true); + + // Metering off → grace never applies, even if a sync is ancient. + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_neverSynced_pastGraceSinceLink_blocks() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.empty()); + DeviceCredential cred = new DeviceCredential(); + cred.setLinkedAt(LocalDateTime.now().minusDays(5)); + when(credentialStore.get()).thenReturn(Optional.of(cred)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.GRACE_EXPIRED, d.reason()); + } + + @Test + void evaluate_recentSync_withinGrace_failsOpen() { + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.empty()); + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setLastSuccessAt(LocalDateTime.now().minusDays(1)); + when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID)) + .thenReturn(Optional.of(state)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertTrue(d.allowed()); + assertEquals(Reason.FAIL_OPEN, d.reason()); + } + + @Test + void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() { + // 100 free units remaining per the last sync, but 100 already accrued locally since — the + // gate subtracts the pending delta and blocks now, not after the next sync reconciles. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(free())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + + assertFalse(d.allowed()); + assertEquals(Reason.OVER_LIMIT, d.reason()); + } + + @Test + void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() { + // Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally + // since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so + // the gate stops now instead of overshooting the cap until the next sync. + when(credentialStore.isLinked()).thenReturn(true); + when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap())); + when(localUsageService.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90)); + + GateDecision d = gate(props(true, 3)).evaluate(true); + assertFalse(d.allowed()); assertEquals(Reason.OVER_LIMIT, d.reason()); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java index f764f5e836..06b99e0eb5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementGateWiringTest.java @@ -19,6 +19,7 @@ class InstanceEntitlementGateWiringTest { private AccountLinkProperties properties; private DeviceCredentialStore store; private EntitlementCache cache; + private LocalUsageService localUsage; private InstanceEntitlementGate gate; @BeforeEach @@ -27,7 +28,14 @@ class InstanceEntitlementGateWiringTest { properties.setEnabled(true); store = mock(DeviceCredentialStore.class); cache = mock(EntitlementCache.class); - gate = new InstanceEntitlementGate(properties, store, cache); + localUsage = mock(LocalUsageService.class); + gate = + new InstanceEntitlementGate( + properties, + store, + cache, + mock(AccountLinkSyncStateRepository.class), + localUsage); } @Test @@ -55,6 +63,10 @@ class InstanceEntitlementGateWiringTest { .thenReturn( Optional.of( new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK))); + // Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time; + // nothing pending here, so the 5 free units still allow the request. + when(localUsage.currentPeriodUnsynced()) + .thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0)); GateDecision d = gate.evaluate(true); assertTrue(d.allowed()); assertEquals(GateDecision.Reason.ENTITLED, d.reason()); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java index 709e3c0866..13a2606105 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/InstanceEntitlementInterceptorTest.java @@ -3,24 +3,55 @@ package stirling.software.proprietary.accountlink; 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.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.notNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; import org.junit.jupiter.api.Test; 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.springframework.beans.factory.ObjectProvider; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.mock.web.MockMultipartHttpServletRequest; + +import stirling.software.common.util.TempFile; +import stirling.software.common.util.TempFileManager; +import stirling.software.proprietary.billing.BillingCategory; +import stirling.software.proprietary.billing.UnitCalcPolicy; @ExtendWith(MockitoExtension.class) class InstanceEntitlementInterceptorTest { @Mock private InstanceEntitlementGate gate; + @Mock private EntitlementCache entitlementCache; + @Mock private ObjectProvider meterProvider; + @Mock private TempFileManager tempFileManager; + + private InstanceEntitlementInterceptor interceptor() { + return new InstanceEntitlementInterceptor( + gate, entitlementCache, meterProvider, tempFileManager); + } private boolean preHandle(MockHttpServletResponse response) throws Exception { - return new InstanceEntitlementInterceptor(gate) + return interceptor() .preHandle( new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object()); } @@ -58,4 +89,85 @@ class InstanceEntitlementInterceptorTest { assertTrue(preHandle(response)); assertEquals(200, response.getStatus()); } + + @Test + void metersSuccessfulBillableOp() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); // stashes AI category + interceptor.afterCompletion(req, resp, new Object(), null); + + // No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature. + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull()); + } + + @Test + void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + UsageMeterService meter = mock(UsageMeterService.class); + when(meterProvider.getIfAvailable()).thenReturn(meter); + // docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but + // only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly. + UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period))); + // Materialise to a real path under @TempDir; the interceptor writes the upload there and + // jpdfium + the hasher read it back. + TempFile temp = mock(TempFile.class); + when(temp.getPath()).thenReturn(tmp.resolve("input.bin")); + when(tempFileManager.createManagedTempFile(any())).thenReturn(temp); + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest(); + req.setRequestURI("/api/v1/ai/x"); + req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf())); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // 5 pages + a non-null input-set signature (file ops carry a dedup key). + verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull()); + } + + @Test + void doesNotMeterWhenMeteringSwitchOff() throws Exception { + when(gate.evaluate(anyBoolean())) + .thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED)); + when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false + + InstanceEntitlementInterceptor interceptor = interceptor(); + MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x"); + MockHttpServletResponse resp = new MockHttpServletResponse(); + interceptor.preHandle(req, resp, new Object()); + interceptor.afterCompletion(req, resp, new Object(), null); + + // Meter absent → no entitlement lookup, no accrual. + verifyNoInteractions(entitlementCache); + } + + private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) { + return new InstanceEntitlement( + true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1)); + } + + private static byte[] fivePagePdf() throws Exception { + try (PDDocument doc = new PDDocument(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + for (int i = 0; i < 5; i++) { + doc.addPage(new PDPage()); + } + doc.save(out); + return out.toByteArray(); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java new file mode 100644 index 0000000000..4605348666 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/LocalUsageServiceTest.java @@ -0,0 +1,75 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class LocalUsageServiceTest { + + @Mock private UsageCounterRepository counters; + @Mock private EntitlementCache entitlementCache; + + private LocalUsageService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new LocalUsageService(counters, entitlementCache); + } + + private static UsageCounter counter( + LocalDateTime period, String category, long cumulative, long synced) { + return new UsageCounter(period, category, cumulative, synced, LocalDateTime.now()); + } + + private static InstanceEntitlement entitledFor(LocalDateTime periodStart) { + return new InstanceEntitlement( + true, + 0, + 0, + null, + EntitlementState.OK, + null, + periodStart, + periodStart.plusMonths(1)); + } + + @Test + void unknownPeriodReturnsZeros() { + when(entitlementCache.current()).thenReturn(Optional.empty()); + + LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced(); + + assertThat(usage.periodStart()).isNull(); + assertThat(usage.totalUnsyncedUnits()).isZero(); + } + + @Test + void sumsPerCategoryUnsyncedDeltaForCurrentPeriod() { + when(entitlementCache.current()).thenReturn(Optional.of(entitledFor(period))); + when(counters.findByPeriodStart(period)) + .thenReturn( + List.of( + counter(period, "API", 30L, 10L), // 20 unsynced + counter(period, "AI", 4L, 4L), // 0 unsynced (all reported) + counter(period, "AUTOMATION", 7L, 2L))); // 5 unsynced + + LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced(); + + assertThat(usage.periodStart()).isEqualTo(period); + assertThat(usage.apiUnsyncedUnits()).isEqualTo(20L); + assertThat(usage.aiUnsyncedUnits()).isEqualTo(0L); + assertThat(usage.automationUnsyncedUnits()).isEqualTo(5L); + assertThat(usage.totalUnsyncedUnits()).isEqualTo(25L); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java new file mode 100644 index 0000000000..0c9ad2c62e --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageMeterServiceTest.java @@ -0,0 +1,133 @@ +package stirling.software.proprietary.accountlink; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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.time.LocalDateTime; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +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.dao.DataIntegrityViolationException; + +import stirling.software.proprietary.billing.BillingCategory; + +@ExtendWith(MockitoExtension.class) +class UsageMeterServiceTest { + + @Mock private UsageCounterRepository repo; + @Mock private MeteredInputSignatureRepository signatureRepo; + + private UsageMeterService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new UsageMeterService(repo, signatureRepo, new AccountLinkProperties()); + } + + @Test + void incrementsExistingCounter() { + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, null); + + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void insertsWhenNoRowExists() { + when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0); + + service.accrue(period, BillingCategory.API, 3, null); + + verify(repo).saveAndFlush(any(UsageCounter.class)); + } + + @Test + void retriesIncrementWhenInsertLosesRace() { + // First increment misses (no row); insert loses the race to a concurrent thread; the + // second increment then succeeds against the row that thread created. + when(repo.increment(eq(period), eq("AUTOMATION"), eq(2L), any())).thenReturn(0, 1); + when(repo.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup")); + + service.accrue(period, BillingCategory.AUTOMATION, 2, null); + + verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any()); + } + + @Test + void skipsBypassedNonPositiveAndNullPeriod() { + service.accrue(period, BillingCategory.BYPASSED, 5, null); + service.accrue(period, BillingCategory.AI, 0, null); + service.accrue(null, BillingCategory.AI, 5, null); + + verifyNoInteractions(repo, signatureRepo); + } + + @Test + void chargesNewSignatureThenAccrues() { + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-new")) + .thenReturn(Optional.empty()); + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, "op-sig-new"); + + verify(signatureRepo).saveAndFlush(any(MeteredInputSignature.class)); + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + } + + @Test + void skipsConcurrentDuplicateClaim() { + // Unseen this period, but a concurrent op wins the insert first → treated as within-window + // chaining, not re-charged. + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-race")) + .thenReturn(Optional.empty()); + when(signatureRepo.saveAndFlush(any())) + .thenThrow(new DataIntegrityViolationException("dup")); + + service.accrue(period, BillingCategory.AI, 5, "op-sig-race"); + + verify(repo, never()).increment(any(), any(), anyLong(), any()); + verify(repo, never()).saveAndFlush(any()); + } + + @Test + void skipsRepeatWithinWorkflowWindow() { + // Same input set seen moments ago → chaining → not re-charged; the window slides. + MeteredInputSignature recent = + new MeteredInputSignature(period, "op-sig", LocalDateTime.now()); + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig")) + .thenReturn(Optional.of(recent)); + + service.accrue(period, BillingCategory.AI, 5, "op-sig"); + + verify(repo, never()).increment(any(), any(), anyLong(), any()); + verify(signatureRepo).save(recent); // window touched + } + + @Test + void chargesRepeatOutsideWorkflowWindow() { + // Same input set last seen well past the 5-minute window → an independent re-run → charged. + MeteredInputSignature stale = + new MeteredInputSignature(period, "op-sig", LocalDateTime.now().minusMinutes(10)); + when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig")) + .thenReturn(Optional.of(stale)); + when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1); + + service.accrue(period, BillingCategory.AI, 5, "op-sig"); + + verify(repo).increment(eq(period), eq("AI"), eq(5L), any()); + verify(signatureRepo).save(stale); // window touched + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java new file mode 100644 index 0000000000..1b1ade7e80 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/accountlink/UsageSyncServiceTest.java @@ -0,0 +1,171 @@ +package stirling.software.proprietary.accountlink; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +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.scheduling.config.ScheduledTaskRegistrar; + +@ExtendWith(MockitoExtension.class) +class UsageSyncServiceTest { + + @Mock private UsageCounterRepository counters; + @Mock private AccountLinkSyncStateRepository syncState; + @Mock private DeviceCredentialStore credentialStore; + @Mock private AccountLinkClient client; + @Mock private EntitlementCache entitlementCache; + + private UsageSyncService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = + new UsageSyncService( + counters, + syncState, + credentialStore, + client, + entitlementCache, + new AccountLinkProperties()); + } + + @Test + void registersFixedDelayTaskWithConfiguredInterval() { + AccountLinkProperties props = new AccountLinkProperties(); + props.getMetering().setSyncIntervalHours(6); + UsageSyncService svc = + new UsageSyncService( + counters, syncState, credentialStore, client, entitlementCache, props); + + ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar(); + svc.configureTasks(registrar); + + // Pins the interval binding in CI — the old @Scheduled SpEL only resolved at flags-on boot. + assertThat(registrar.getFixedDelayTaskList()).hasSize(1); + assertThat(registrar.getFixedDelayTaskList().get(0).getIntervalDuration()) + .isEqualTo(Duration.ofHours(6)); + } + + private static DeviceCredential credential() { + DeviceCredential c = new DeviceCredential(); + c.setDeviceId("dev-1"); + c.setDeviceSecret("sec-1"); + return c; + } + + private static UsageCounter counter(LocalDateTime period, String category, long cumulative) { + return new UsageCounter(period, category, cumulative, LocalDateTime.now()); + } + + private static InstanceEntitlement entitled() { + return new InstanceEntitlement(true, 0, 0, null, EntitlementState.OK); + } + + @Test + void notLinkedSkipsEntirely() { + when(credentialStore.get()).thenReturn(Optional.empty()); + + service.syncNow(); + + verifyNoInteractions(client, entitlementCache); + verify(counters, never()).findPeriodsWithUnsyncedUsage(); + } + + @Test + void nothingPendingStillForcesEntitlementRefresh() { + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of()); + + service.syncNow(); + + // No usage to report, so nothing is sent and no markers advance — but the sync still forces + // an entitlement refresh so an out-of-band plan change (e.g. a just-completed subscription) + // surfaces on the gate immediately instead of waiting out the entitlement-cache TTL. + verifyNoInteractions(client); + verify(syncState, never()).save(any()); + verify(entitlementCache, never()).accept(any()); + verify(entitlementCache).invalidate(); + verify(entitlementCache).current(); + } + + @Test + void reportsCumulativePerCategoryAndAdvancesSyncedMarkers() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + state.setLastSyncSeq(5L); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)) + .thenReturn(List.of(counter(period, "API", 12L), counter(period, "AI", 4L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + InstanceEntitlement fresh = entitled(); + when(client.reportUsage( + eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L))) + .thenReturn(fresh); + + service.syncNow(); + + // Seq advanced from 5 → 6 and the report carried the per-category cumulative. + verify(client) + .reportUsage(eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L)); + // Only categories with usage are marked; AUTOMATION (0) is skipped. + verify(counters).markSynced(period, "API", 12L); + verify(counters).markSynced(period, "AI", 4L); + verify(counters, never()).markSynced(eq(period), eq("AUTOMATION"), anyLong()); + // Two saves: the pre-report seq reservation + the post-success timestamp. + verify(syncState, times(2)).save(state); + verify(entitlementCache).accept(fresh); + } + + @Test + void transportFailureReservesSeqButLeavesMarkersUntouched() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong())) + .thenReturn(null); + + service.syncNow(); + + verify(counters, never()).markSynced(any(), any(), anyLong()); + verify(syncState, times(1)).save(state); // seq reserved, success not recorded + verify(entitlementCache).accept(null); // nothing fresh adopted + } + + @Test + void revokedAbortsWithoutMarkingOrAdoptingEntitlement() { + AccountLinkSyncState state = new AccountLinkSyncState(); + state.setId(AccountLinkSyncState.SINGLETON_ID); + when(credentialStore.get()).thenReturn(Optional.of(credential())); + when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period)); + when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L))); + when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state)); + when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong())) + .thenThrow(new AccountLinkClient.RevokedException(403)); + + service.syncNow(); + + verify(counters, never()).markSynced(any(), any(), anyLong()); + verify(entitlementCache, never()).accept(any()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java new file mode 100644 index 0000000000..1eb6c3360a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/billing/BillingCategoryClassifierTest.java @@ -0,0 +1,30 @@ +package stirling.software.proprietary.billing; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class BillingCategoryClassifierTest { + + @Test + void automationWinsOverEverything() { + assertEquals( + BillingCategory.AUTOMATION, BillingCategoryClassifier.classify(true, true, true)); + } + + @Test + void aiWinsOverApiKey() { + assertEquals(BillingCategory.AI, BillingCategoryClassifier.classify(false, true, true)); + } + + @Test + void apiKeyWhenNotAutomationOrAi() { + assertEquals(BillingCategory.API, BillingCategoryClassifier.classify(false, false, true)); + } + + @Test + void bypassedWhenNoSignal() { + assertEquals( + BillingCategory.BYPASSED, BillingCategoryClassifier.classify(false, false, false)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java index 7392eb928a..7c98d5e59b 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/InstanceController.java @@ -1,5 +1,8 @@ package stirling.software.saas.accountlink; +import java.time.LocalDateTime; +import java.util.Map; + import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpStatus; @@ -9,6 +12,7 @@ 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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -16,11 +20,16 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.billing.UnitCalcPolicy; 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.instance.InstanceUsageIngestService; +import stirling.software.saas.payg.model.BillingCategory; import stirling.software.saas.payg.model.EntitlementState; +import stirling.software.saas.payg.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Instance-facing surface (combined-billing "Mode A"), authenticated by the device @@ -46,14 +55,23 @@ public class InstanceController { private final EntitlementService entitlementService; private final TeamBillingService billingService; private final AccountLinkService accountLinkService; + private final PricingPolicyService pricingPolicyService; + private final InstanceUsageIngestService usageIngestService; + private final LinkedInstanceRepository linkedInstanceRepository; public InstanceController( EntitlementService entitlementService, TeamBillingService billingService, - AccountLinkService accountLinkService) { + AccountLinkService accountLinkService, + PricingPolicyService pricingPolicyService, + InstanceUsageIngestService usageIngestService, + LinkedInstanceRepository linkedInstanceRepository) { this.entitlementService = entitlementService; this.billingService = billingService; this.accountLinkService = accountLinkService; + this.pricingPolicyService = pricingPolicyService; + this.usageIngestService = usageIngestService; + this.linkedInstanceRepository = linkedInstanceRepository; } public record WhoAmIResponse(Long instanceId, Long teamId) {} @@ -68,7 +86,13 @@ public class InstanceController { long freeRemainingUnits, long periodSpendUnits, Long periodCapUnits, - String state) {} + String state, + // Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The + // instance computes units locally with this policy and resets its per-period cumulative + // counters on the [periodStart, periodEnd) boundary. + UnitCalcPolicy unitCalcPolicy, + LocalDateTime periodStart, + LocalDateTime periodEnd) {} @GetMapping("/whoami") @PreAuthorize("hasRole('LINKED_INSTANCE')") @@ -103,20 +127,96 @@ public class InstanceController { if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - Long teamId = token.getTeamId(); + // Drop the cached snapshot first: this low-frequency read gates real-time billable work, so + // it must reflect a just-changed subscription/cap at once (the flip is a DB-function write + // with no Java event to invalidate on). + entitlementService.invalidate(token.getTeamId()); + return ResponseEntity.ok(buildEntitlement(token.getTeamId())); + } + /** Body for {@code POST /sync}: the instance's cumulative units per category this period. */ + public record UsageSyncRequest( + long syncSeq, LocalDateTime periodStart, CategoryUnits cumulativeUnits) { + public record CategoryUnits(long api, long ai, long automation) {} + } + + /** + * Daily usage sync: the instance reports its cumulative per-category unit totals for the + * period; SaaS bills the delta since the last sync (reusing the standard charge path) and + * returns the fresh entitlement — so one round-trip both reports usage and refreshes the gate + * state. + */ + @PostMapping("/sync") + @PreAuthorize("hasRole('LINKED_INSTANCE')") + @Transactional + public ResponseEntity sync( + Authentication auth, @RequestBody UsageSyncRequest req) { + if (!(auth instanceof LinkedInstanceAuthenticationToken token)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + if (req == null || req.periodStart() == null || req.cumulativeUnits() == null) { + return ResponseEntity.badRequest().build(); + } + Long teamId = token.getTeamId(); + // periodStart is the dedup/regression partition key, so bound a fabricated value to the + // snapshot window (current or immediately-prior period, never future). + EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); + LocalDateTime reported = req.periodStart(); + if (!reported.isBefore(snap.periodEnd()) + || reported.isBefore(snap.periodStart().minusMonths(1))) { + log.warn( + "Instance sync for team {} reported implausible periodStart {} (authoritative" + + " {}..{}); rejecting.", + teamId, + reported, + snap.periodStart(), + snap.periodEnd()); + return ResponseEntity.badRequest().build(); + } + // Attribute the charge to the admin who linked the instance (the device credential carries + // no user). Null is tolerated by the ingest service (it skips + retries next sync). + Long actorUserId = + linkedInstanceRepository + .findById(token.getInstanceId()) + .map(LinkedInstance::getCreatedByUserId) + .orElse(null); + UsageSyncRequest.CategoryUnits c = req.cumulativeUnits(); + usageIngestService.ingest( + teamId, + actorUserId, + req.syncSeq(), + req.periodStart(), + Map.of( + BillingCategory.API, c.api(), + BillingCategory.AI, c.ai(), + BillingCategory.AUTOMATION, c.automation())); + // Drop the cache so the buildEntitlement below (and the portal's next read) reflect the + // just-charged delta + moved free-grant balance now, not after the TTL. + entitlementService.invalidate(teamId); + return ResponseEntity.ok(buildEntitlement(teamId)); + } + + /** The entitlement view shared by {@code GET /entitlement} and the {@code /sync} response. */ + private EntitlementResponse buildEntitlement(Long teamId) { // Same composition the FE wallet uses: billing facts (subscription, free pool) from - // TeamBillingService, period spend/cap + state from the entitlement snapshot. + // TeamBillingService, period spend/cap + state from the entitlement snapshot, plus the + // unit-calc policy + period the instance needs to meter locally. TeamBillingContext billing = billingService.forTeam(teamId); EntitlementSnapshot snap = entitlementService.getSnapshot(teamId); - - return ResponseEntity.ok( - new EntitlementResponse( - billing.subscribed(), - billing.freeRemainingUnits(), - snap.periodSpendUnits(), - snap.periodCapUnits(), - coarseState(snap.state()))); + PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId); + return new EntitlementResponse( + billing.subscribed(), + billing.freeRemainingUnits(), + snap.periodSpendUnits(), + snap.periodCapUnits(), + coarseState(snap.state()), + new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()), + snap.periodStart(), + snap.periodEnd()); } /** 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 index 041eaa8f4a..b79d57117e 100644 --- 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 @@ -19,6 +19,7 @@ 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -328,6 +329,32 @@ public class PaygWalletController { /** Request body for {@link #updateCap}. */ public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {} + // --------------------------------------------------------------------------------------- + // POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh + // --------------------------------------------------------------------------------------- + + /** + * Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a + * billing state that just changed out-of-band. The subscription flip is written by a Postgres + * function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client + * that knows a change just happened — the portal while finalizing a checkout — pokes the cache + * here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only + * refresh its own team, and a no-team caller is a cheap no-op. + */ + @PostMapping("/wallet/refresh") + @PreAuthorize("isAuthenticated()") + public ResponseEntity refreshWallet(Authentication auth) { + User user; + try { + user = AuthenticationUtils.getCurrentUser(auth, userRepository); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + primaryMembership(user.getId()) + .ifPresent(m -> entitlementService.invalidate(m.getTeam().getId())); + return ResponseEntity.noContent().build(); + } + // --------------------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------------------- diff --git a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java index 603f490a4f..04b9b182a2 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/docs/DefaultDocumentClassifier.java @@ -5,6 +5,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -18,6 +19,9 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.util.TempFile; import stirling.software.common.util.TempFileManager; import stirling.software.jpdfium.PdfDocument; +import stirling.software.proprietary.billing.DocumentUnitCalculator; +import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.payg.policy.PricingPolicy; /** @@ -42,9 +46,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier { private static final String PDF_CONTENT_TYPE = "application/pdf"; private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream"; - /** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */ - private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1; - private final TempFileManager tempFileManager; @Override @@ -59,13 +60,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier { Objects.requireNonNull(policy, "policy"); FileFacts facts = inspect(file, materialisedPath); - long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy); - // toIntExact: fail loud on overflow rather than silently wrapping a billing number. - int units = - Math.toIntExact( - Math.max( - MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(policy.getFileUnitCap(), rawUnits))); + int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy)); return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units); } @@ -93,17 +88,16 @@ public class DefaultDocumentClassifier implements DocumentClassifier { int totalPages = 0; long totalBytes = 0; - long rawUnitsSum = 0; String firstContentType = null; + List sizes = new ArrayList<>(files.size()); for (int i = 0; i < files.size(); i++) { MultipartFile file = files.get(i); Path path = materialisedPaths == null ? null : materialisedPaths.get(i); FileFacts facts = inspect(file, path); - // Sum the *raw* (unclamped) per-file units so the group cap below can actually bind. - // Per-file clamping in this loop would make the group cap a no-op. - rawUnitsSum = - saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy)); + // Collect raw page/byte facts; the group cap is applied over the raw sum in the + // calculator (per-file clamping here would make the group cap a no-op). + sizes.add(new FileSize(facts.pages, facts.bytes)); totalPages = saturatedAdd(totalPages, facts.pages); totalBytes = saturatedAdd(totalBytes, facts.bytes); if (firstContentType == null) { @@ -111,13 +105,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier { } } - long groupCap = (long) policy.getFileUnitCap() * files.size(); - // toIntExact: fail loud on overflow rather than silently wrapping. - int totalUnits = - Math.toIntExact( - Math.max( - (long) MIN_UNITS_PER_NONEMPTY_FILE, - Math.min(groupCap, rawUnitsSum))); + int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy)); return new DocumentMetrics( totalPages, @@ -126,6 +114,14 @@ public class DefaultDocumentClassifier implements DocumentClassifier { totalUnits); } + private static UnitCalcPolicy unitCalc(PricingPolicy policy) { + return new UnitCalcPolicy( + policy.getDocPagesPerUnit(), + policy.getDocBytesPerUnit(), + policy.getMinChargeUnits(), + policy.getFileUnitCap()); + } + private FileFacts inspect(MultipartFile file, Path materialisedPath) { long bytes = file.getSize(); String contentType = @@ -140,19 +136,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier { return new FileFacts(pages, bytes, contentType); } - private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) { - long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L; - long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit()); - return Math.max(pageUnits, byteUnits); - } - - private static long ceilDiv(long numerator, long divisor) { - if (numerator <= 0) { - return 0; - } - return (numerator + divisor - 1) / divisor; - } - private static boolean isPdf(String contentType, String filename) { if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) { return true; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java new file mode 100644 index 0000000000..291a46880c --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/InstanceUsageIngestService.java @@ -0,0 +1,130 @@ +package stirling.software.saas.payg.instance; + +import java.time.LocalDateTime; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.extern.slf4j.Slf4j; + +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.JobSource; +import stirling.software.saas.payg.model.ProcessType; +import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; + +/** + * Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a + * monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the + * last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split, + * ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and + * tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The + * cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}. + */ +@Slf4j +@Service +@Profile("saas") +@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true") +public class InstanceUsageIngestService { + + private final PaygInstanceUsageRepository usageRepository; + private final JobChargeService chargeService; + + public InstanceUsageIngestService( + PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) { + this.usageRepository = usageRepository; + this.chargeService = chargeService; + } + + /** + * Bills the delta for each category and advances the last-seen cumulative + sync sequence. The + * delta-advance and the charge share this transaction, so a crash before commit re-bills + * cleanly on retry (delta unchanged) and a commit means the cumulative moved with the charge. + * + * @param actorUserId the linking admin ({@code linked_instance.created_by_user_id}); required + * to attribute the charge. If {@code null} we skip entirely (don't advance) so a later + * sync, once the actor is resolvable, still bills the usage. + */ + @Transactional + public void ingest( + Long teamId, + Long actorUserId, + long syncSeq, + LocalDateTime periodStart, + Map cumulativeByCategory) { + if (teamId == null || periodStart == null || cumulativeByCategory == null) { + return; + } + if (actorUserId == null) { + log.warn( + "Instance usage sync for team {} has no actor (created_by_user_id null); not" + + " billing — a later sync will pick it up.", + teamId); + return; + } + cumulativeByCategory.forEach( + (category, cumulative) -> { + if (category == null + || category == BillingCategory.BYPASSED + || cumulative == null + || cumulative < 0) { + return; + } + applyCategory(teamId, actorUserId, syncSeq, periodStart, category, cumulative); + }); + } + + private void applyCategory( + Long teamId, + Long actorUserId, + long syncSeq, + LocalDateTime periodStart, + BillingCategory category, + long cumulative) { + // Pessimistic row lock so a duplicate delivery can't have two txns read the same baseline + // and both charge: the second waits, then sees the advanced seq and replay-skips. + PaygInstanceUsage row = + usageRepository + .findByTeamIdAndPeriodStartAndCategoryForUpdate( + teamId, periodStart, category.name()) + .orElse(null); + if (row != null && syncSeq <= row.getLastSyncSeq()) { + return; // replay / out-of-order — already applied this or a later sync + } + long lastCumulative = row == null ? 0L : row.getLastCumulativeUnits(); + long delta = cumulative - lastCumulative; + if (delta < 0) { + // The cumulative counter went backwards — a reset or tampering. Refuse to credit; don't + // advance, so the discrepancy stays visible and a corrected resend can reconcile. + log.warn( + "Instance usage regression team={} category={} reported {} < last {}; ignoring.", + teamId, + category, + cumulative, + lastCumulative); + return; + } + if (delta > 0) { + int units = (int) Math.min(delta, Integer.MAX_VALUE); + chargeService.chargeStandalone( + new ChargeContext( + actorUserId, + teamId, + JobSource.LINKED_INSTANCE, + ProcessType.SINGLE_TOOL, + category), + units); + } + if (row == null) { + row = new PaygInstanceUsage(teamId, periodStart, category.name(), cumulative, syncSeq); + } else { + row.setLastCumulativeUnits(cumulative); + row.setLastSyncSeq(syncSeq); + } + usageRepository.save(row); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java new file mode 100644 index 0000000000..45a6435746 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/instance/PaygInstanceUsage.java @@ -0,0 +1,74 @@ +package stirling.software.saas.payg.instance; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.UpdateTimestamp; + +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 jakarta.persistence.UniqueConstraint; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team, + * billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative + * unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via + * the standard charge path and advances this row. {@code lastSyncSeq} dedups replays. + */ +@Entity +@Table( + name = "payg_instance_usage", + uniqueConstraints = + @UniqueConstraint( + name = "uk_payg_instance_usage", + columnNames = {"team_id", "period_start", "category"})) +@Getter +@Setter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaygInstanceUsage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "team_id", nullable = false) + private Long teamId; + + @Column(name = "period_start", nullable = false) + private LocalDateTime periodStart; + + /** {@code BillingCategory} name — API / AI / AUTOMATION. */ + @Column(name = "category", nullable = false, length = 32) + private String category; + + @Column(name = "last_cumulative_units", nullable = false) + private long lastCumulativeUnits; + + @Column(name = "last_sync_seq", nullable = false) + private long lastSyncSeq; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public PaygInstanceUsage( + Long teamId, + LocalDateTime periodStart, + String category, + long lastCumulativeUnits, + long lastSyncSeq) { + this.teamId = teamId; + this.periodStart = periodStart; + this.category = category; + this.lastCumulativeUnits = lastCumulativeUnits; + this.lastSyncSeq = lastSyncSeq; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java index 86e6e4ab47..4e62afcbd8 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/lineage/ByteHashSignatureExtractor.java @@ -1,22 +1,18 @@ package stirling.software.saas.payg.lineage; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; import java.nio.file.Path; -import java.security.DigestInputStream; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; import java.util.Set; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import stirling.software.proprietary.billing.ContentHasher; + /** * SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every - * content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read - * buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions). + * content type, doesn't parse. Delegates to the shared {@link ContentHasher} so the cloud charge + * path and a linked self-hosted instance's meter compute byte-identical signatures. * *

    Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code * PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed @@ -26,36 +22,15 @@ import org.springframework.stereotype.Component; @Profile("saas") public class ByteHashSignatureExtractor implements LineageSignatureExtractor { - private static final String ALGORITHM = "SHA-256"; private static final String SIGNATURE_TYPE = "sha256"; - private static final int BUFFER_SIZE = 64 * 1024; @Override public Set extract(Path file) throws IOException { - MessageDigest digest = newDigest(); - try (InputStream raw = Files.newInputStream(file); - DigestInputStream in = new DigestInputStream(raw, digest)) { - byte[] buf = new byte[BUFFER_SIZE]; - // Drain through the digest stream; we only care about side effects on the digest. - while (in.read(buf) != -1) { - // no-op - } - } - String hex = HexFormat.of().formatHex(digest.digest()); - return Set.of(new LineageSignature(SIGNATURE_TYPE, hex)); + return Set.of(new LineageSignature(SIGNATURE_TYPE, ContentHasher.sha256(file))); } @Override public String name() { return SIGNATURE_TYPE; } - - private static MessageDigest newDigest() { - try { - return MessageDigest.getInstance(ALGORITHM); - } catch (NoSuchAlgorithmException e) { - // SHA-256 is mandated by every JDK; unreachable in practice. - throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e); - } - } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java index 8f7d2b3df7..b023f08fb5 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/JobSource.java @@ -15,5 +15,12 @@ public enum JobSource { /** * The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend. */ - DESKTOP_APP + DESKTOP_APP, + /** + * Usage reported by a linked self-hosted instance via the daily sync (combined-billing "Mode + * A"). The per-request surface is lost in the aggregate — the instance reports cumulative units + * per {@code BillingCategory} — so this just marks the charge as instance-synced. No per-source + * step limit is seeded for it; the charge path's fallback applies. + */ + LINKED_INSTANCE } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java new file mode 100644 index 0000000000..283cc212b1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygInstanceUsageRepository.java @@ -0,0 +1,35 @@ +package stirling.software.saas.payg.repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; + +import stirling.software.saas.payg.instance.PaygInstanceUsage; + +/** Last-seen cumulative usage per (team, period, category) for linked-instance daily syncs. */ +public interface PaygInstanceUsageRepository extends JpaRepository { + + Optional findByTeamIdAndPeriodStartAndCategory( + Long teamId, LocalDateTime periodStart, String category); + + /** + * Pessimistic-write variant the ingest uses so two concurrent deliveries of the same sync (e.g. + * a proxy retry) can't both read the same baseline and double-charge the delta. Must run inside + * a transaction. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query( + "SELECT u FROM PaygInstanceUsage u" + + " WHERE u.teamId = :teamId AND u.periodStart = :periodStart" + + " AND u.category = :category") + Optional findByTeamIdAndPeriodStartAndCategoryForUpdate( + @Param("teamId") Long teamId, + @Param("periodStart") LocalDateTime periodStart, + @Param("category") String category); +} diff --git a/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql new file mode 100644 index 0000000000..7ab65b8b78 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V25__payg_instance_usage.sql @@ -0,0 +1,35 @@ +-- Twin of supabase/migrations/_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table +-- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS +-- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS, +-- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry). +-- +-- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted +-- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on +-- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and +-- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path +-- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow. +-- +-- Inert until release: written only by the InstanceController /sync endpoint, gated behind +-- stirling.billing.account-link.enabled (default off). Additive, idempotent table. + +CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage ( + id BIGSERIAL PRIMARY KEY, + team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE, + period_start TIMESTAMP NOT NULL, + category VARCHAR(32) NOT NULL, + -- Highest cumulative unit total seen for this (team, period, category); the next sync bills + -- (reported cumulative - this). + last_cumulative_units BIGINT NOT NULL DEFAULT 0, + -- Highest sync sequence applied; a sync at or below this is a replay and is ignored. + last_sync_seq BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category) +); + +CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team + ON stirling_pdf.payg_instance_usage (team_id); + +COMMENT ON TABLE stirling_pdf.payg_instance_usage IS + 'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted ' + 'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the ' + 'standard charge path; last_sync_seq dedups replays.'; diff --git a/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql new file mode 100644 index 0000000000..f79097e325 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V26__payg_shadow_charge_linked_instance_source.sql @@ -0,0 +1,19 @@ +-- Twin of supabase/migrations/_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS). +-- Keep byte-identical to the Supabase twin. +-- +-- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode +-- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which +-- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after +-- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync. +-- +-- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase +-- migration sets against the same schema) and additive (the new set is a superset of the JobSource +-- enum; the app only ever writes enum values, so no existing row can violate it). + +ALTER TABLE stirling_pdf.payg_shadow_charge + DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check; + +ALTER TABLE stirling_pdf.payg_shadow_charge + ADD CONSTRAINT payg_shadow_charge_job_source_check + CHECK (job_source IS NULL + OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE')); diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java index d221ac8603..4c9cc3b76a 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/InstanceControllerTest.java @@ -1,6 +1,7 @@ package stirling.software.saas.accountlink; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -8,9 +9,12 @@ import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; +import java.util.Optional; 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; @@ -19,14 +23,19 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import stirling.software.proprietary.billing.UnitCalcPolicy; import stirling.software.saas.accountlink.InstanceController.EntitlementResponse; 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.instance.InstanceUsageIngestService; +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.policy.PricingPolicy; +import stirling.software.saas.payg.policy.PricingPolicyService; /** * Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read. @@ -39,9 +48,22 @@ class InstanceControllerTest { @Mock private EntitlementService entitlementService; @Mock private TeamBillingService billingService; @Mock private AccountLinkService accountLinkService; + @Mock private PricingPolicyService pricingPolicyService; + @Mock private InstanceUsageIngestService usageIngestService; + @Mock private LinkedInstanceRepository linkedInstanceRepository; private InstanceController controller() { - return new InstanceController(entitlementService, billingService, accountLinkService); + return new InstanceController( + entitlementService, + billingService, + accountLinkService, + pricingPolicyService, + usageIngestService, + linkedInstanceRepository); + } + + private static PricingPolicy policy() { + return new PricingPolicy(1, 1_048_576L, 1, 1000); } @Test @@ -50,6 +72,7 @@ class InstanceControllerTest { when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L)); when(entitlementService.getSnapshot(42L)) .thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -62,6 +85,13 @@ class InstanceControllerTest { assertThat(body.periodCapUnits()).isEqualTo(1250L); // WARNED is still within budget for the gate's purposes → coarse OK. assertThat(body.state()).isEqualTo("OK"); + // Phase 2: the metering inputs the instance needs ride along. + assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000)); + assertThat(body.periodStart()).isNotNull(); + assertThat(body.periodEnd()).isNotNull(); + // The instance-facing read drops the cached snapshot first so a just-subscribed team's + // plan surfaces on the next poll instead of waiting out the cache TTL. + verify(entitlementService).invalidate(42L); } @Test @@ -70,6 +100,7 @@ class InstanceControllerTest { when(billingService.forTeam(7L)).thenReturn(freeBilling(500L)); when(entitlementService.getSnapshot(7L)) .thenReturn(snapshot(EntitlementState.FULL, 0L, null)); + when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy()); ResponseEntity resp = controller().entitlement(token); @@ -89,6 +120,7 @@ class InstanceControllerTest { when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L)); when(entitlementService.getSnapshot(8L)) .thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L)); + when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy()); EntitlementResponse body = controller().entitlement(token).getBody(); @@ -110,6 +142,59 @@ class InstanceControllerTest { verifyNoInteractions(entitlementService, billingService); } + @Test + void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() { + Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L); + LinkedInstance li = new LinkedInstance(); + li.setCreatedByUserId(7L); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li)); + when(billingService.forTeam(99L)).thenReturn(freeBilling(10L)); + // The reported periodStart is validated against the authoritative snapshot period. + when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null)); + when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy()); + + InstanceController.UsageSyncRequest req = + new InstanceController.UsageSyncRequest( + 3L, + period, + new InstanceController.UsageSyncRequest.CategoryUnits(12, 4, 8)); + + ResponseEntity resp = controller().sync(token, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + @SuppressWarnings("unchecked") + ArgumentCaptor> cumulative = ArgumentCaptor.forClass(Map.class); + verify(usageIngestService) + .ingest(eq(99L), eq(7L), eq(3L), eq(period), cumulative.capture()); + assertThat(cumulative.getValue()) + .containsEntry(BillingCategory.API, 12L) + .containsEntry(BillingCategory.AI, 4L) + .containsEntry(BillingCategory.AUTOMATION, 8L); + // The sync drops the team's cached snapshot so the just-charged delta (and the free-grant + // balance it moved) show on the next wallet read instead of lagging out the 30s TTL. + verify(entitlementService).invalidate(99L); + } + + @Test + void sync_rejectsImplausiblePeriodStart() { + Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L); + LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null)); + + // A fabricated far-future periodStart (would reset the dedup partition) → 400, no ingest. + InstanceController.UsageSyncRequest req = + new InstanceController.UsageSyncRequest( + 1L, + period.plusYears(5), + new InstanceController.UsageSyncRequest.CategoryUnits(99, 0, 0)); + + ResponseEntity resp = controller().sync(token, req); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + verifyNoInteractions(usageIngestService); + } + @Test void revokeSelf_callsServiceWithTokenIdentityAndReturns204() { Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L); @@ -187,4 +272,17 @@ class InstanceControllerTest { start.plusMonths(1), false); } + + /** Snapshot with an explicit period — the sync tests need a deterministic period window. */ + private static EntitlementSnapshot snapshotForPeriod(LocalDateTime start, Long cap) { + return new EntitlementSnapshot( + EntitlementState.FULL, + FeatureSet.FULL, + List.of(FeatureGate.OFFSITE_PROCESSING), + 0L, + cap, + start, + start.plusMonths(1), + false); + } } 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 index 8433f5e5a3..6cc97d916b 100644 --- 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 @@ -432,6 +432,52 @@ class PaygWalletControllerTest { verifyNoInteractions(policyRepo, entitlementService); } + // ----------------------------------------------------------------------------------------- + // POST /wallet/refresh + // ----------------------------------------------------------------------------------------- + + @Test + void refreshWallet_dropsCallerTeamCache() { + User user = userWithId(30L, UUID.randomUUID()); + Team team = teamWithId(70L); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(30L)) + .thenReturn(List.of(membership(team, user, TeamRole.MEMBER))); + + ResponseEntity resp = controller.refreshWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + // Portal pokes this after checkout so the next /wallet read reflects the subscription + // immediately rather than after the cache TTL. + verify(entitlementService).invalidate(70L); + } + + @Test + void refreshWallet_noTeam_isNoOpButOk() { + User user = userWithId(31L, UUID.randomUUID()); + when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user)); + when(memberRepo.findPrimaryMembership(31L)).thenReturn(List.of()); + + ResponseEntity resp = controller.refreshWallet(jwtAuth(user.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(entitlementService, never()).invalidate(any()); + } + + @Test + void refreshWallet_anonymousIs401() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity resp = controller.refreshWallet(anon); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(entitlementService); + } + // ----------------------------------------------------------------------------------------- // Fixtures // ----------------------------------------------------------------------------------------- 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 8b3ce50b6a..e669e1b5bd 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 @@ -913,6 +913,52 @@ class JobChargeServiceTest { .isInstanceOf(IllegalArgumentException.class); } + @Test + void chargeStandalone_floorsUnitsAtMinChargeUnits() { + // Pins the per-call minChargeUnits floor that the linked-instance sync path inherits: a + // daily delta below the floor bills the floor (max(delta, minChargeUnits)) — applied per + // sync-delta here, not per underlying op (documented divergence from the in-cloud per-op + // floor; can only under-bill vs per-op, never over). + long teamId = 100L; + PricingPolicy policy = stubPolicy(/*minCharge*/ 5, Map.of(JobSource.WEB, 10)); + when(policyService.getEffectivePolicy(teamId)).thenReturn(policy); + + UUID jobId = UUID.randomUUID(); + when(jobService.open(any(JobContext.class), eq(5))).thenReturn(openJob(jobId)); + when(jobService.close(jobId)).thenReturn(openJob(jobId)); + + 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, 5, 0, BillingCategory.API))); + + ChargeContext ctx = + new ChargeContext( + 7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API); + ArgumentCaptor ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class); + + withTransactionSynchronization(() -> service.chargeStandalone(ctx, 2)); + + // Delta of 2 floored to minChargeUnits=5: the job, ledger debit, and meter all use 5. + verify(jobService).open(any(JobContext.class), eq(5)); + verify(ledgerRepo).save(ledger.capture()); + assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-5); + verify(meterReporter) + .recordUsage( + eq(teamId), + eq("cus_x"), + eq(5), + eq(BillingCategory.API), + eq("process:" + jobId + ":close"), + eq(jobId)); + } + private static void withTransactionSynchronization(Runnable body) { TransactionSynchronizationManager.initSynchronization(); try { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java new file mode 100644 index 0000000000..57c7b42c01 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/instance/InstanceUsageIngestServiceTest.java @@ -0,0 +1,135 @@ +package stirling.software.saas.payg.instance; + +import static org.assertj.core.api.Assertions.assertThat; +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.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +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 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.JobSource; +import stirling.software.saas.payg.repository.PaygInstanceUsageRepository; + +@ExtendWith(MockitoExtension.class) +class InstanceUsageIngestServiceTest { + + @Mock private PaygInstanceUsageRepository repo; + @Mock private JobChargeService chargeService; + + private InstanceUsageIngestService service; + private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0); + + @BeforeEach + void setUp() { + service = new InstanceUsageIngestService(repo, chargeService); + } + + @Test + void firstSyncChargesFullCumulativeAndSavesRow() { + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "AI")) + .thenReturn(Optional.empty()); + + service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.AI, 10L)); + + ArgumentCaptor ctx = ArgumentCaptor.forClass(ChargeContext.class); + verify(chargeService).chargeStandalone(ctx.capture(), eq(10)); + assertThat(ctx.getValue().ownerTeamId()).isEqualTo(1L); + assertThat(ctx.getValue().ownerUserId()).isEqualTo(7L); + assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI); + assertThat(ctx.getValue().source()).isEqualTo(JobSource.LINKED_INSTANCE); + + ArgumentCaptor row = ArgumentCaptor.forClass(PaygInstanceUsage.class); + verify(repo).save(row.capture()); + assertThat(row.getValue().getLastCumulativeUnits()).isEqualTo(10L); + assertThat(row.getValue().getLastSyncSeq()).isEqualTo(1L); + } + + @Test + void secondSyncChargesOnlyDelta() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 10L, 1L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L)); + + // One charge for the aggregated delta (15), not per underlying op — pins the per-delta + // model. + verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(15)); + verify(repo).save(existing); + assertThat(existing.getLastCumulativeUnits()).isEqualTo(25L); + assertThat(existing.getLastSyncSeq()).isEqualTo(2L); + } + + @Test + void replayIsIgnored() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L)); + + verify(chargeService, never()).chargeStandalone(any(), anyInt()); + verify(repo, never()).save(any()); + } + + @Test + void regressionIsRefusedAndNotAdvanced() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 5L)); + + verify(chargeService, never()).chargeStandalone(any(), anyInt()); + verify(repo, never()).save(any()); + } + + @Test + void zeroDeltaAdvancesSeqWithoutCharging() { + PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L); + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.of(existing)); + + service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 25L)); + + verify(chargeService, never()).chargeStandalone(any(), anyInt()); + verify(repo).save(existing); + assertThat(existing.getLastSyncSeq()).isEqualTo(3L); + } + + @Test + void billsAccruedDeltaWithoutConsultingCap() { + // Intent pin: the ingest has no cap input and always bills the accrued delta — cap + // enforcement is the request-time gate's job (the instance stops accruing at the cap), not + // this aggregate charge path's. A large valid delta is billed in full. + when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API")) + .thenReturn(Optional.empty()); + + service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, 5_000_000L)); + + verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(5_000_000)); + } + + @Test + void nullActorSkipsEntirely() { + service.ingest(1L, null, 1L, period, Map.of(BillingCategory.AI, 10L)); + + verifyNoInteractions(repo, chargeService); + } +} diff --git a/devGuide/STYLELINT.md b/devGuide/STYLELINT.md index 04ddc407a1..0e279e1915 100644 --- a/devGuide/STYLELINT.md +++ b/devGuide/STYLELINT.md @@ -17,8 +17,8 @@ Apply Stylelint to your project's CSS with the following steps: ```jsonc { "scripts": { - "lint:css:check": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config ../.stylelintrc.json", - "lint:css:fix": "stylelint \"../app/core//src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json --fix" + "lint:css:check": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json", + "lint:css:fix": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json --fix" } } ``` diff --git a/docs/type3_fallback_plan.md b/docs/type3_fallback_plan.md index 3ef7e6ad2a..f49514585a 100644 --- a/docs/type3_fallback_plan.md +++ b/docs/type3_fallback_plan.md @@ -132,7 +132,7 @@ Feel free to expand this plan or add notes as the work progresses. | Stage | Tool / Command | Output | | --- | --- | --- | -| 1. Collect PDFs | `python scripts/download_pdf_collection.py --output scripts/pdf-collection` (or drop your own PDFs anywhere) | Raw PDFs ready for harvesting | +| 1. Collect PDFs | `python scripts/download_pdf_samples.py --output-dir scripts/pdf-collection` (or drop your own PDFs anywhere) | Raw PDFs ready for harvesting | | 2. Harvest signatures | `python scripts/harvest_type3_fonts.py --input scripts/pdf-collection --pretty` | Per-PDF dumps in `docs/type3/signatures/…` + global summary `docs/type3/harvest_report.json` | | 3. Summarize backlog | `python scripts/summarize_type3_signatures.py` | `docs/type3/signature_inventory.md` (human checklist of aliases/signatures) | | 4. Convert fonts | Either copy the upstream TTF/OTF for the font (DejaVu, CM, STIX, etc.) or run `scripts/type3_to_cff.py` against the harvested glyph JSON to synthesize one offline; store the result under `app/core/src/main/resources/type3/library/fonts//`. | Canonical font binaries | diff --git a/frontend/.gitignore b/frontend/.gitignore index 3a5de11c57..e07dce196a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,7 +11,6 @@ # production /build /dist -/dist-portal /storybook-static /editor/build @@ -27,8 +26,8 @@ # Root .gitignore ignores all .env* - whitelist only our committed ones, anchored # to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS # mock env is injected via .storybook/main.ts, not a file). -!/portal/.env !/editor/.env +!/editor/.env.proprietary !/editor/.env.desktop !/editor/.env.saas diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 63d7130f47..25caf7cd10 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -1,5 +1,4 @@ dist/ -dist-portal/ editor/dist/ # Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier). # Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each @@ -9,7 +8,7 @@ editor/src-tauri/gen/ node_modules/ editor/public/vendor/ # Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted. -portal/public/mockServiceWorker.js +editor/public/mockServiceWorker.js # Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim. editor/public/og-metadata.json editor/src/core/data/ogImageMap.json diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index c016a5c37d..4d4aadc8f8 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -6,16 +6,13 @@ 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 under editor/src/ (which includes + * the portal layer at editor/src/portal/). MDX docs pages live in + * editor/src/portal/docs/. */ const config: StorybookConfig = { stories: [ - "../portal/src/**/*.mdx", - "../portal/src/**/*.stories.@(ts|tsx)", - "../shared/**/*.mdx", - "../shared/**/*.stories.@(ts|tsx)", + "../editor/src/portal/**/*.mdx", "../editor/src/**/*.stories.@(ts|tsx)", ], addons: ["@storybook/addon-themes", "@storybook/addon-a11y"], @@ -26,17 +23,21 @@ const config: StorybookConfig = { typescript: { reactDocgen: "react-docgen-typescript", }, - // Serve the MSW worker file from portal/public so Storybook can intercept - // network calls the same way the dev portal does. - staticDirs: ["../portal/public"], + // Serve the MSW worker file from the portal's public dir so Storybook can + // intercept network calls the same way the dev portal does. + staticDirs: ["../editor/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. + // Wire the @portal/* alias directly on the Storybook bundler so portal + // 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"), + "@portal": resolve(__dirname, "../editor/src/portal"), + // Direct layer aliases so .storybook config files (preview.tsx), which sit + // outside src/ and so aren't covered by tsconfigPaths, can import layer + // modules (e.g. the auth supabase client that moved into proprietary). + "@proprietary": resolve(__dirname, "../editor/src/proprietary"), + "@core": resolve(__dirname, "../editor/src/core"), }; // Editor stories import via @app/* (proprietary→core fallback), @core/* and // @proprietary/*. Resolve them exactly the way the editor's own build does — diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 1f9c935fe6..d086613f44 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -19,11 +19,11 @@ import { ThemeProvider } from "@portal/contexts/ThemeContext"; import { UIProvider } from "@portal/contexts/UIContext"; import { mantineTheme } from "@portal/theme/mantineTheme"; import { handlers } from "@portal/mocks/handlers"; -import { configureSupabase } from "@shared/auth/supabase/supabaseClient"; +import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; import "@mantine/core/styles.css"; -import "@shared/tokens/tokens.css"; -import "@shared/tokens/base.css"; +import "@core/tokens/tokens.css"; +import "@core/tokens/base.css"; // Start MSW once. Storybook runs in a browser so this uses the service worker. initialize({ onUnhandledRequest: "bypass" }, handlers); diff --git a/frontend/editor/.env.proprietary b/frontend/editor/.env.proprietary new file mode 100644 index 0000000000..ed5498ba3a --- /dev/null +++ b/frontend/editor/.env.proprietary @@ -0,0 +1,32 @@ +############################################################################### +# Proprietary-build environment variables, layered on top of `.env` when Vite +# runs in `--mode proprietary` (so the core/OSS build never sees them). These +# back the admin portal mounted at /portal. Committed to Git, so no private keys; +# machine-specific overrides go in the uncommitted .env.proprietary.local / +# .env.local. (VITE_STRIPE_PUBLISHABLE_KEY lives in the base `.env` as it is +# shared across builds.) +############################################################################### + +# Where the portal's "Editor" switcher / non-admin redirect points. "/" is +# correct: the editor serves the portal at /portal on the same origin. In dev, +# override in .env.local with your running editor's URL (e.g. +# VITE_EDITOR_URL=http://localhost:5173/). +VITE_EDITOR_URL=/ + +# Force the portal's MSW mocks on ("true") or off ("false"). Empty = default +# (on in dev, off in production builds). Set "false" to run against the real +# backend. +VITE_PORTAL_MOCKS= + +# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account +# linking (both values are public). Set per deploy; absent -> the account-link +# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase +# project the local backend links against. +VITE_SAAS_SUPABASE_URL= +VITE_SAAS_SUPABASE_ANON_KEY= + +# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for +# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the +# admin's Supabase JWT. Distinct from the local backend (reached same-origin via +# the editor's vite proxy). Absent -> wallet/billing surfaces stay on the mock. +VITE_SAAS_API_URL= diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 7f81e56f3b..346ece1523 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6048,6 +6048,1863 @@ stepOf = "Step {{step}} of {{total}}" toolChainDesc = "Configure the tools this policy runs on each document." typesSelected = "{{count}} types selected" +[portal.accountLink.card] +billingNote = "Unattended processing bills against your org wallet." +eyebrow = "Account link" +linkButton = "Link your Stirling account" +linked = "Linked" +linkedAs = "Linked as {{name}}." +linkedGeneric = "This instance is linked." +notLinked = "Not linked" +title = "Link this org to its Stirling account" +unlink = "Unlink" + +[portal.accountLink.card.error] +title = "Couldn't link" + +[portal.accountLink.card.loginNotConfigured] +after = "to enable account linking against the hosted Stirling account. In dev you can simulate sign-in from the link dialog." +before = "Set" +title = "SaaS login not configured" + +[portal.accountLink.gate] +action = "Link account" +description = "Link this org's Stirling account to use billable features." +title = "Link to unlock" +titleFeature = "Link to unlock {{feature}}" + +[portal.accountLink.instances] +active = "Active" +revoke = "Revoke" +revoked = "Revoked" +unnamed = "Unnamed instance" + +[portal.accountLink.instances.columns] +instance = "Instance" +lastSeen = "Last seen" +linked = "Linked" +status = "Status" + +[portal.accountLink.instances.empty] +description = "Link this org's account, then register your self-hosted instances to see them here." +title = "No linked instances" + +[portal.accountLink.instances.time] +daysAgo_one = "{{count}}d ago" +daysAgo_other = "{{count}}d ago" +hoursAgo_one = "{{count}}h ago" +hoursAgo_other = "{{count}}h ago" +justNow = "just now" +minutesAgo_one = "{{count}}m ago" +minutesAgo_other = "{{count}}m ago" +never = "never" + +[portal.accountLink.modal] +linkSubtitle = "Sign in to the account this server should bill against." +linkTitle = "Link your Stirling account" +reauthSubtitle = "Your session expired — sign back in to your Stirling account. Your instance stays linked." +reauthTitle = "Sign in again" +simulateSignIn = "Simulate sign-in (dev)" + +[portal.accountLink.modal.loginNotConfigured] +after = "to enable in-app linking against the hosted Stirling account." +and = "and" +before = "Set" +title = "SaaS login not configured" + +[portal.accountLink.panel] +instancesSub = "Every self-hosted instance registered to this org. Revoke a credential to immediately cut off its unattended access." +instancesTitle = "Linked instances" +revokeError = "Couldn't revoke instance" +sub = "Link this self-hosted org to its Stirling account so unattended processing bills against your org wallet." + +[portal.accountLink.panel.loadError] +forbidden = "Only the team owner can view the org's linked instances." +generic = "Couldn't load the team's linked instances. Try again in a moment." +title = "Couldn't load linked instances" + +[portal.accountLink.state] +free = "Editor plan" +subscribed = "Processor plan" +unlinked = "Not linked" + +[portal.agentBuilder] +bootstrapFromDocument = "Bootstrap from document" +sectionsAriaLabel = "Agent builder sections" +selectorAriaLabel = "Agents" +subtitle = "Design, test and ship the AI agents that classify, extract from and route your documents. Define scenarios, fence tool access, run a golden set, and publish a version." +title = "Agent Builder" + +[portal.agentBuilder.bootstrap] +cancel = "Cancel" +dropzoneText = "Choose a sample document (PDF or image)" +lead = "Drop a sample document and we'll propose scenarios and an extraction schema you can refine. Nothing is published until you review it." +submit = "Bootstrap agent" +subtitle = "Seed a new agent from one representative file" +title = "Bootstrap from a document" + +[portal.agentBuilder.empty] +description = "Bootstrap an agent from a sample document to seed its scenarios and extraction schema, then refine and publish." +title = "No agents yet" + +[portal.agentBuilder.evals] +casesPassing = "Cases passing" +columnCase = "Eval case" +columnLatency = "Latency" +columnResult = "Result" +fail = "fail" +goldenSetPassRate = "Golden-set pass rate" +latencyMs = "{{ms}} ms" +notRun = "not run" +pass = "pass" +passRate = "Pass rate" +runEvals = "Run evals" + +[portal.agentBuilder.evals.empty] +description = "Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it." +title = "No golden set yet" + +[portal.agentBuilder.kpi] +acrossGoldenSets = "across golden sets" +activeAgents = "Active agents" +avgPassRate = "Avg eval pass rate" +fleetWide = "fleet-wide" +latestPublished = "Latest published" +scenarios = "Scenarios" +testCases = "test cases" +totalDescription_one = "{{count}} total" +totalDescription_other = "{{count}} total" + +[portal.agentBuilder.scenarios] +add = "Add" +addScenario = "Add scenario" +enable = "Enable" +expectationLabel = "Expected behaviour" +expectationPlaceholder = "What the agent should do" +inEval = "in eval" +mute = "Mute" +muted = "muted" +nameLabel = "Name" +namePlaceholder = "e.g. Compliance escalation" + +[portal.agentBuilder.tabs] +evals = "Evals" +scenarios = "Scenarios" +tools = "Tools" +versions = "Versions" + +[portal.agentBuilder.tools] +broadAccess = "Broad access" +deniedHint = "Selected tools are blocked. Everything else stays callable." +deniedTools = "Denied tools" +governanceGate = "Tool governance is available on the Enterprise plan." +restricted = "Restricted" +restrictedAccess = "Restricted tool access" +restrictedDescription = "Allow every tool except the ones you deny below." + +[portal.agentBuilder.versions] +current = "current" +historyGate = "Full version history and rollback are available on the Enterprise plan." +publish = "Publish" +rollBack = "Roll back" + +[portal.assistant] +close = "Close assistant" +error = "Couldn't reach the assistant." +errorWithDetail = "Couldn't reach the assistant: {{detail}}" +inputAriaLabel = "Ask the assistant" +inputPlaceholder = "Ask about Stirling…" +open = "Open assistant" +send = "Send" +title = "Assistant" +tryAsking = "Try asking" +typing = "Typing" + +[portal.auth] +loading = "Loading" +redirectingToEditor = "Redirecting to the editor..." + +[portal.billing.checkout] +noClientSecret = "Edge function returned no client_secret." +subtitle = "Add a card to keep going past your free Editor-plan grant. Stripe handles the rest." +title = "Turn on the Processor plan" + +[portal.billing.checkout.activationSlow] +body = "Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly." +close = "Close" +title = "Almost there" + +[portal.billing.checkout.error] +title = "Couldn't start checkout" + +[portal.billing.checkout.finalizing] +body = "Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds." +hint = "Please keep this window open." +title = "Activating your Processor plan..." + +[portal.billing.checkout.notConfigured] +bodyAfter = "in the portal env to enable in-app checkout." +bodyBefore = "Set" +title = "Stripe not configured" + +[portal.billing.enterpriseUpsell] +cta = "Build your Enterprise quote" +description = "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships." +eyebrow = "Volume discount · 1M+ PDFs" +title = "Stirling Enterprise" + +[portal.billing.freeEditors] +activeThisMonth = "Active this month" +cost = "Cost" +editorsDeployed = "Editors deployed" +inviteTeammates = "Invite teammates" +pdfsEdited = "PDFs edited" +previewBadge = "Preview · sample data" +subtitle = "Deploy anywhere, for your whole team." +title = "Free PDF Editors" + +[portal.billing.freePlan] +checkoutErrorTitle = "Couldn't start checkout" +currentPlan = "Current plan" +freeForever = "Free forever" +noTeamResolved = "No team is resolved on your wallet yet — refresh and try again." +ownerOnly = "Only the team owner can switch on the Processor plan." +planName = "Editor" +ssoIncluded = "SSO included" +switchOnProcessor = "Switch on the Processor →" +unlimitedUsers = "Unlimited users" + +[portal.billing.invoices] +columnAmount = "Amount" +columnDate = "Date" +columnDescription = "Description" +columnPdfsProcessed = "PDFs processed" +columnStatus = "Status" +descriptionFallback = "Invoice" +downloadAriaLabel = "Download invoice {{number}} as PDF" +emptyDescription = "Once your team subscribes and the first cycle closes, your invoices appear here." +emptyTitle = "No invoices yet" +fetchLimitNote_one = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal." +fetchLimitNote_other = "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal." +loadError = "Couldn't load invoices: {{error}}" +pdfLink = "PDF ↓" +showAll_one = "Show all {{count}}" +showAll_other = "Show all {{count}}" +showFewer_one = "Show fewer (top {{count}})" +showFewer_other = "Show fewer (top {{count}})" +showMostRecent_one = "Show {{count}} most recent" +showMostRecent_other = "Show {{count}} most recent" +title = "Invoice history" +viewAriaLabel = "View invoice {{number}} in Stripe" +viewLink = "View ↗" + +[portal.billing.linkPrompt] +cta = "Link Stirling account" +description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use." +title = "Link your Stirling account" + +[portal.billing.paymentMethod] +billedMonthly = "Billed monthly" +cardEnding = "{{brand}} ending {{last4}}" +cardFallback = "Card" +expiresBilledMonthly = "Expires {{expiry}} · billed monthly" +eyebrow = "Payment method" +managedSub = "Your card and billing details are kept securely in Stripe's customer portal." +managedTitle = "Managed in Stripe" +update = "Update" + +[portal.billing.pdfsProcessed] +emptyPeriod = "No metered processing yet this period." +eyebrow = "PDFs processed this period" +legendValue_one = "{{formatted}} PDFs" +legendValue_other = "{{formatted}} PDFs" +segbarAriaLabel = "Metered PDFs split by category" +segmentAgentsDesc = "AI agent actions" +segmentAgentsLabel = "Agents" +segmentApiDesc = "Direct API requests" +segmentApiLabel = "API" +segmentAutomationDesc = "Automations & pipelines" +segmentAutomationLabel = "Automation" +unit = "metered PDFs" + +[portal.billing.spendLimit] +adjustLimit = "Adjust limit" +cancel = "Cancel" +capControlNote = "Changes apply immediately — raise or lower the ceiling any time." +capSuffix = "/ month" +capSuffixWithDocs = "/ month · ≈ {{documents}} documents" +displaySub = "You're only billed for what you process automatically — never past the ceiling." +editTitle = "Set your monthly ceiling" +eyebrow = "Spend limit" +guardrailBody = "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost." +guardrailLabel = "Your guardrail:" +noCap = "no cap" +pctUsed = "{{pct}}% used" +remaining = "{{amount}} remaining" +save = "Save limit" +saveError = "Couldn't save limit" +thisPeriodUncapped = "{{amount}} this period · uncapped" +usedThisMonth = "{{amount}} used this month" +useSuggested = "Use suggested · {{amount}} / month" + +[portal.billing.spendLimit.projection] +body_one = "At {{rate}}/day you reach the cap in ~{{count}} day (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}." +body_other = "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}." +label = "Projected to exceed." + +[portal.billing.spendThisMonth] +eyebrow = "Spend this month" +processed_one = "{{formattedCount}} PDF processed." +processed_other = "{{formattedCount}} PDFs processed." +processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." +processedWithRate_other = "{{formattedCount}} PDFs processed, at {{rate}} each." + +[portal.billing.subscribedPlan.capWarn] +approachingBody = "Raise it now so automated processing never pauses." +approachingTitle = "You're at {{pct}}% of your monthly spend limit" +raiseLimit = "Raise limit" +reachedBody = "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working." +reachedTitle = "Monthly spend limit reached" + +[portal.billing.subscribedPlan.portalError] +title = "Couldn't open Stripe portal" + +[portal.billing.walletMeter] +capSuffix_one = "of {{allowance}} free PDFs used" +capSuffix_other = "of {{allowance}} free PDFs used" +eyebrow = "Processor trial" +statusLabel_one = "{{remaining}} left" +statusLabel_other = "{{remaining}} left" +sub = "Use the PDF Editor for free. Pay to process PDFs automatically." +title_one = "Process {{allowance}} PDFs free" +title_other = "Process {{allowance}} PDFs free" +titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF" + +[portal.catalogue.card] +lockedAriaLabel = "Locked" +openAriaLabel = "Open {{name}} component" + +[portal.catalogue.detail] +addToProject = "Add to project" +ariaLabel = "Component detail" +tabsAriaLabel = "Component detail sections" +upgradeToUnlock = "Upgrade to unlock" + +[portal.catalogue.detail.code] +install = "Install" +usage = "Usage" + +[portal.catalogue.detail.locked] +description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it." +title = "Not available on your plan" + +[portal.catalogue.detail.preview] +badge = "Live preview" +note = "Interactive sandbox renders here" + +[portal.catalogue.detail.pricing] +note = "Metered per {{unit}}. Usage beyond the monthly free quota is billed to your account and itemised under Usage & Billing." + +[portal.catalogue.detail.stats] +billedOn = "Billed on" +embeds30d = "Embeds (30d)" +freeQuota = "Free quota" +freeQuotaValue = "{{amount}} / mo" +maturity = "Maturity" +none = "None" +perAction = "Per action" +price = "Price" + +[portal.catalogue.detail.tabs] +code = "Code" +overview = "Overview" +pricing = "Pricing" +props = "Props / API" + +[portal.catalogue.props] +optional = "optional" +required = "required" + +[portal.catalogue.props.columns] +description = "Description" +name = "Prop" +required = "Required" +type = "Type" + +[portal.catalogue.summary] +componentsGa = "Components GA" +componentSpendMtd = "Component spend (MTD)" +embedsThisMonth = "Embeds this month" +inBeta = "In beta" + +[portal.common] +inviteMember = "Invite member" + +[portal.componentsView] +subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props." +title = "Components" + +[portal.componentsView.empty] +description = "The component catalogue could not be loaded. Try again shortly." +title = "No components available" + +[portal.componentsView.lockedBanner] +description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge." +title = "Some components need a paid plan" + +[portal.docs.authentication] +codeCaption = "every request" +eyebrow = "GETTING STARTED" +lead = "All requests authenticate with a bearer token. Keys are scoped per environment and never expire unless rotated." +liveKey = "Production keys — billed, rate-limited per your plan." +testKey = "Sandbox keys — free, return synthetic fixtures." +title = "Authentication" + +[portal.docs.components] +codeCaption = "embed the viewer" +eyebrow = "COMPONENTS" +lead = "Embeddable UI for review queues and document inspection. Bring your own styles or use the shipped theme." +title = "Drop-in viewers" + +[portal.docs.endpoints] +eyebrow = "API REFERENCE" +fieldCount_one = "{{count}} field" +fieldCount_other = "{{count}} fields" +filterAll = "All" +filterAriaLabel = "Filter endpoints by vertical" +lead = "Every document type is a typed endpoint. POST a file, receive schema-validated JSON. Filter by vertical below." +title = "Endpoints" + +[portal.docs.errors] +codeCaption = "422 Unprocessable Entity" +eyebrow = "API REFERENCE" +lead = "Errors return a stable machine-readable code plus a human message. The 4xx body always includes a request_id for support." +title = "Errors" + +[portal.docs.nav] +ariaLabel = "Documentation" + +[portal.docs.nav.empty] +description = "The documentation index could not be loaded." +title = "Docs unavailable" + +[portal.docs.quickstart] +eyebrow = "GETTING STARTED" +lead = "Send your first document to a typed endpoint and get structured JSON back in three steps. No model training, no prompt engineering." +title = "Quickstart" + +[portal.docs.quickstart.callout] +bodyAfterLink = "for copy-paste recipes." +bodyBeforeLink = "wire the same call into a pipeline to chain validation, redaction, and delivery — or expose it to an agent over MCP. See" +label = "Next:" +link = "Playbooks" + +[portal.docs.quickstart.step1] +body = "Create a scoped key from the Infrastructure tab. Keys carry rate limits and an optional IP allowlist. Export it into your shell:" +title = "Issue an API key" + +[portal.docs.quickstart.step2] +body = "POST a file to any typed endpoint. The endpoint determines the schema you get back — here, the invoice extractor." +snippetCaption = "extract an invoice" +title = "Send a document" + +[portal.docs.quickstart.step3] +body = "Every response is validated against the endpoint schema, with a confidence score and per-field provenance." +codeCaption = "200 OK" +title = "Read the structured result" + +[portal.docs.rateLimits] +burst = "Burst" +codeCaption = "429 Too Many Requests" +concurrency = "Concurrency" +eyebrow = "GETTING STARTED" +lead = "Limits scale with your plan. A 429 response includes a Retry-After header; the SDKs back off automatically." +requestsPerMinute = "Requests / minute" +title = "Rate limits & quotas" + +[portal.docs.recipes] +cloneButton = "Clone recipe" +eyebrow = "PLAYBOOKS" +lead = "End-to-end patterns that chain sources, operations, and destinations. Each maps to a pipeline you can clone." +title = "Recipes" + +[portal.docs.sdks] +eyebrow = "SDKS" +lead = "First-party clients with typed responses, automatic retries, and streaming uploads. All track the same endpoint catalogue." +title = "Official SDKs" + +[portal.docs.sdks.status] +beta = "Beta" +deprecated = "Deprecated" + +[portal.docs.skills] +eyebrow = "SKILLS" +lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached." +title = "Agent skills" + +[portal.docs.webhooks] +codeCaption = "document.processed" +eyebrow = "API REFERENCE" +lead = "Subscribe to document.processed, pipeline.completed, and quota.threshold events. Payloads are signed with HMAC-SHA256." +title = "Webhooks" + +[portal.docs.webhooks.callout] +afterHelper = "helper." +beforeHelper = "header against your signing secret before trusting a payload. SDKs ship a" +beforeSignature = "Verify the" + +[portal.documents] +subtitle = "Review and approve documents moving through your pipelines." +title = "Documents" + +[portal.documents.audit] +empty = "No events recorded yet." + +[portal.documents.drawer] +sectionsAriaLabel = "Document detail sections" + +[portal.documents.drawer.tabs] +audit = "Audit" +extractions = "Extractions" +overview = "Overview" + +[portal.documents.elevation] +requestAccess = "Request access" + +[portal.documents.elevation.active] +description = "Temporary grant — access is logged and time-boxed." +descriptionFourEyes = "Temporary grant — a peer reviewer was notified (four-eyes)." +title = "Access expires in {{time}}" + +[portal.documents.elevation.gated] +description = "Content is gated by zero-standing-access. Requesting starts a time-boxed grant." +descriptionFourEyes = "Content is gated by zero-standing-access. Requesting starts a time-boxed grant and notifies a peer reviewer (four-eyes)." +title = "Sensitive document" + +[portal.documents.extractions] +empty = "No fields were extracted from this document." +masked = "Extracted fields are hidden. Request timed access to view this document's content." + +[portal.documents.extractions.columns] +confidence = "Confidence" +field = "Field" +value = "Value" + +[portal.documents.filters] +all = "All" +archived = "Archived" +ariaLabel = "Filter documents by status" +needsReview = "Needs review" +processed = "Processed" + +[portal.documents.overview] +confidence = "Confidence" +fieldsExtracted = "Fields extracted" +received = "Received" +source = "Source" +status = "Status" +type = "Type" + +[portal.documents.queue.empty] +description = "As sources feed documents into your pipelines they'll appear here for review." +title = "No documents in the queue" + +[portal.documents.summary] +avgConfidence = "Avg confidence" +inQueue = "In queue" +needsReview = "Needs review" +processedToday = "Processed today" + +[portal.documents.table] +empty = "No documents match this filter." +sensitiveLabel = "Sensitive" +sensitiveTitle = "Sensitive — access required" + +[portal.documents.table.columns] +confidence = "Confidence" +fields = "Fields" +name = "Name" +source = "Source" +status = "Status" +time = "Time" +type = "Type" + +[portal.editorAdmin] +subtitle = "Deploy the Stirling PDF Editor, pair self-hosted instances to your org, and operate the running fleet — targets, health, credentials, and offline activation in one place." +title = "Editor deployment" + +[portal.editorAdmin.health.columns] +activeUsers = "Active users" +host = "Host" +lastSeen = "Last seen" +region = "Region" +status = "Status" +version = "Version" + +[portal.editorAdmin.health.empty] +description = "Deploy a target and pair it to see live instance health here." +title = "No instances reporting" + +[portal.editorAdmin.offlineActivation] +enterpriseTag = "Enterprise" +generateButton = "Generate offline bundle" +lockCopy = "Offline and on-prem activation is part of Enterprise." +subtitle = "Generate a signed activation bundle for an offline or on-prem install with no outbound network path. Transfer it to the instance and apply it during first-run setup." +talkToSales = "Talk to sales" +title = "Air-gapped activation" + +[portal.editorAdmin.offlineActivation.readyBanner] +description = "{{file}} is signed and ready to transfer. It activates one instance and expires in 14 days." +title = "Bundle ready" + +[portal.editorAdmin.pairing] +generated = "Generated ✓" +generateNewCode = "Generate new code" +lockCopy = "IaC provisioning is part of Enterprise." +rotate = "Rotate" +talkToSales = "Talk to sales" + +[portal.editorAdmin.sections.health] +sub = "Every Editor instance reporting in — version, region, status, last seen, and active users." +title = "Instance health" + +[portal.editorAdmin.sections.pairing] +sub = "Connect a self-hosted editor to this org. Generate a token, hand off a short code, or wire it through IaC." +title = "Pairing" + +[portal.editorAdmin.sections.targets] +sub = "Where the Editor runs. Copy a snippet to stand up a self-hosted instance, or use Managed Cloud with no ops." +title = "Deployment targets" + +[portal.editorAdmin.serviceToken] +currentToken = "Current token" +lastRotated = "Last rotated" +rotateButton = "Rotate service token" +subtitle = "Instances authenticate to the org with this credential. Rotate it on a schedule or immediately after a suspected leak." +title = "Service token" + +[portal.editorAdmin.serviceToken.rotatedBanner] +description = "A new token was issued. Update each self-hosted instance's STIRLING_SERVICE_TOKEN within the 24h grace window or they'll drop offline." +title = "Rotate running instances" + +[portal.editorAdmin.targets] +instanceCount_one = "{{count}} instance" +instanceCount_other = "{{count}} instances" +talkToSales = "Talk to sales" +upgradePlan = "Upgrade plan" + +[portal.editorAdmin.targets.lock] +enterprise = "On-prem and Kubernetes self-hosting are part of Enterprise." +paid = "Self-hosting with Docker and Kubernetes unlocks on a paid plan." + +[portal.editorAdmin.targets.state] +available = "Available" +locked = "Locked" +running = "Running" + +[portal.errorBoundary] +description = "This view hit an unexpected error. Try again, or pick another section from the sidebar." +retry = "Try again" +title = "Something went wrong on this page" + +[portal.forkWizard] +subtitle = "Clone a proven workflow and tune it — every template ships the same four-stage backbone." +title = "Fork a starter pipeline" + +[portal.forkWizard.action] +cancel = "Cancel" +deploy = "Deploy pipeline" +pickAnother = "Pick another" + +[portal.forkWizard.status] +building = "Building…" +ready = "Ready to deploy" + +[portal.home.chart.empty] +description = "Once documents are processed, your 30-day usage appears here." +title = "No usage yet" + +[portal.home.kpis.enterprise] +docs30d = "Docs / 30d" +evalPassRate = "Eval pass rate" +p95Latency = "P95 latency" +slaUptime = "SLA uptime (30d)" + +[portal.home.kpis.free] +agents = "Agents" +docsProcessed = "Docs processed" +docsProcessedDescription = "Free plan cap" +operations = "Operations" +pipelines = "Pipelines" + +[portal.home.kpis.pro] +agentsActive = "Agents active" +docs30d = "Docs / 30d" +evalPassRate = "Eval pass rate" +pipelines = "Pipelines" + +[portal.home.onboarding] +progress = "{{done}} / {{total}} done" +runAgain = "Run again" +start = "Start" +subtitle = "Four steps to a production-shaped Stirling project." +title = "Get to value" + +[portal.home.onboarding.empty] +description = "Onboarding tasks will appear here once your workspace is set up." +title = "No onboarding steps yet" + +[portal.home.productGrid] +ariaLabel = "Process PDFs at scale" + +[portal.home.productGrid.agents] +blurb = "Wire your agent via MCP, REST, or tool definitions. Deterministic operations, scenarios, evals." +cta = "Connect an agent" +title = "Agents" + +[portal.home.productGrid.pipelines] +badge = "Hero" +blurb = "Compose document workflows from typed operations. Upload a sample to get suggestions or start blank." +cta = "Build a pipeline" +title = "Pipelines" + +[portal.home.productGrid.sources] +blurb = "Attach pipelines where PDFs already live — S3, agents, SharePoint, webhooks, batch, email." +cta = "Connect a source" +title = "Sources" + +[portal.home.quickActions] +subtitle = "Top tasks for today" +title = "Quick actions" + +[portal.home.quickActions.buildPipeline] +blurb = "3-step composer over the typed op library" +title = "Build a pipeline" + +[portal.home.quickActions.connectSource] +blurb = "S3, agents, webhooks, watched folders" +title = "Connect a source" + +[portal.home.quickActions.issueApiKey] +blurb = "Scoped key with rate limits and IP allowlist" +title = "Issue an API key" + +[portal.home.quickActions.tryOp] +blurb = "Drop a sample, pick an op, see the JSON" +title = "Try a PDF operation" + +[portal.home.regions] +subtitle = "Real-time status for every deployed Stirling region." +title = "Region health" + +[portal.home.regions.empty] +description = "Once a region is deployed, its health appears here." +title = "No regions yet" + +[portal.infrastructure] +manageEditorDeployment = "Manage Editor deployment" +sectionsAriaLabel = "Infrastructure sections" +subtitle = "Deployments, credentials, security posture, storage, and the audit trail for your Stirling workspace." +title = "Infrastructure" + +[portal.infrastructure.apiKeys] +createKey = "Create key" +heading = "API keys" +subheading = "Scoped credentials with per-key rate limits, permissions, and IP allowlists." + +[portal.infrastructure.apiKeys.card] +allowedIps = "Allowed IPs" +anyIp = "Any IP (no allowlist)" +created = "Created" +lastUsed = "Last used" +permissions = "Permissions" +rateLimit = "Rate limit" +rateLimitValue = "{{value}} req/min" +usageMonth = "Usage this month" +usageToday = "Usage today" + +[portal.infrastructure.apiKeys.empty] +description = "Create a scoped key to start calling the Stirling API." +title = "No API keys yet" + +[portal.infrastructure.audit] +filterAriaLabel = "Filter audit events by category" +heading = "Audit logs" +latencyValue = "{{value}} ms" +noEventsInCategory = "No events in this category." +subheading = "Every authentication, configuration, and processing event across your workspace." + +[portal.infrastructure.audit.columns] +actor = "Actor" +event = "Event" +latency = "Latency" +status = "Status" +target = "Target" +timestamp = "Timestamp" + +[portal.infrastructure.audit.empty] +description = "Workspace activity will appear here as it happens." +title = "No audit events" + +[portal.infrastructure.audit.filters] +all = "All" +auth = "Auth" +config = "Config" +elevation = "Elevation" +processing = "Processing" +security = "Security" + +[portal.infrastructure.audit.metrics] +config = "Config" +elevation = "Elevation" +processing = "Processing" +totalEvents = "Total events · 24h" + +[portal.infrastructure.createKey] +cancel = "Cancel" +createKey = "Create key" +done = "Done" +ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP." +ipAllowlistLabel = "IP allowlist" +keyNameLabel = "Key name" +keyNamePlaceholder = "e.g. Production · ingest" +permissionsLabel = "Permissions" +secretKeyCaption = "Secret key" +secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later." +subtitle = "Scope the key to the minimum it needs. You can rotate or revoke at any time." +subtitleCreated = "Copy this secret now — it won't be shown again." +title = "Create API key" +titleCreated = "Key created" + +[portal.infrastructure.deployments] +msValue = "{{value}} ms" +throughputValue = "{{value}}/min" + +[portal.infrastructure.deployments.deployColumns] +deployedBy = "Deployed by" +environment = "Environment" +product = "Product" +status = "Status" +version = "Version" +when = "When" + +[portal.infrastructure.deployments.recent] +heading = "Recent deployments" +subheading = "The latest rollouts across products and environments." + +[portal.infrastructure.deployments.regionColumns] +instances = "Instances" +latency = "Latency" +load = "Load" +p99 = "P99" +region = "Region" +status = "Status" +throughput = "Throughput" +uptime = "Uptime" +version = "Version" + +[portal.infrastructure.deployments.regions] +heading = "Regions" +subheading = "Live health for every deployed Stirling region — latency, load, and rollout version." + +[portal.infrastructure.deployments.regions.empty] +description = "Deployed regions appear here once your workspace is provisioned." +title = "No regions deployed" + +[portal.infrastructure.models] +heading = "Models" +msValue = "{{value}} ms" +subheading = "The model catalogue and routing that powers document processing across your workspace." + +[portal.infrastructure.models.byom] +description = "Register an on-prem or self-hosted model and pin it to a region for data-residency-bound processing." +title = "Bring your own model" + +[portal.infrastructure.models.catalogue] +heading = "Catalogue" +sub = "Managed models available to your workspace, with live latency and cost." +subEnterprise = "Managed, bring-your-own, and on-prem models — with per-region pinning available." + +[portal.infrastructure.models.catalogue.empty] +description = "Models in your workspace's catalogue appear here." +title = "No models available" + +[portal.infrastructure.models.columns] +cost = "Cost" +latency = "Latency" +load = "Load" +model = "Model" +status = "Status" +type = "Type" +version = "Version" + +[portal.infrastructure.models.metrics] +activeModels = "Active models" +avgLatency = "Avg latency" +included = "Included" +monthlySpend = "Monthly model spend" + +[portal.infrastructure.models.routing] +empty = "No routing rules configured." +heading = "Routing rules" +sub = "Which model handles each operation. The default applies when no narrower rule matches." +subLocked = "Route operations to specific models — available on paid plans." + +[portal.infrastructure.models.routing.lockedBanner] +description = "Upgrade to Pro to control which model handles each operation and document type." +title = "Model routing is a paid feature" + +[portal.infrastructure.models.routingColumns] +default = "Default" +docType = "Document type" +modelForAria = "Model for {{operation}}" +operation = "Operation" +routedTo = "Routed to" + +[portal.infrastructure.security.access.byok] +description = "Supply a key from your own KMS. Stirling encrypts with it but can still read." +label = "Bring your own key (BYOK)" + +[portal.infrastructure.security.access.hyok] +description = "Keys never leave your KMS. Stirling holds only ciphertext." +label = "Hold your own key (HYOK)" + +[portal.infrastructure.security.access.stirling] +description = "Stirling manages encryption keys. Simplest — zero key ops on your side." +label = "Stirling-held keys" + +[portal.infrastructure.security.accessPolicy] +heading = "Document access policy" +subheading = "Controls who can decrypt processed documents at rest." + +[portal.infrastructure.security.attestations] +heading = "Compliance attestations" +noReport = "No report available" +subheading = "Framework-by-framework audit posture, with reports available on attested controls." +viewReport = "View report →" + +[portal.infrastructure.security.compliance] +heading = "Compliance" +subheading = "Attestations and certifications covering the Stirling platform." + +[portal.infrastructure.security.empty] +description = "Your workspace's security configuration will appear here." +title = "Security posture unavailable" + +[portal.infrastructure.security.hyokBanner] +description = "With HYOK, encryption keys never leave your KMS. Stirling stores and processes only ciphertext you can revoke at any time." +title = "Stirling cannot decrypt your documents" + +[portal.infrastructure.security.ipAllowlist] +empty = "No IP ranges configured — all IPs allowed." +heading = "IP allowlist" +sub = "API access is restricted to these CIDR ranges." +subLocked = "Restrict API access to known IP ranges — available on paid plans." + +[portal.infrastructure.security.ipAllowlist.lockedBanner] +description = "Upgrade to Pro to restrict API access to specific networks." +title = "IP allowlisting is a paid feature" + +[portal.infrastructure.security.ipColumns] +added = "Added" +addedBy = "Added by" +cidr = "CIDR" +label = "Label" + +[portal.infrastructure.security.keyManagement] +algorithm = "Algorithm" +heading = "Encryption key management" +keyId = "Key identifier" +lastRotated = "Last rotated" +rotateKey = "Rotate key" +rotationPolicy = "Rotation policy" +subheading = "Custody of the keys that encrypt documents at rest — who can decrypt, and how keys rotate." + +[portal.infrastructure.security.managedBanner] +description = "Bring-your-own-key (BYOK) and hold-your-own-key (HYOK) custody are available on Enterprise. Upgrade to supply keys from your own KMS." +title = "Keys are managed by Stirling on your plan" + +[portal.infrastructure.security.residency.apac] +description = "ap-southeast-1" +label = "Asia Pacific" + +[portal.infrastructure.security.residency.eu] +description = "eu-west-1 · GDPR data boundary" +label = "European Union" + +[portal.infrastructure.security.residency.us] +description = "us-east-1 · us-west-2" +label = "United States" + +[portal.infrastructure.security.residencyHeader] +heading = "Data residency" +subheading = "Where documents are stored and processed." + +[portal.infrastructure.storage] +gbValue = "{{value}} GB" +percentUsed = "{{value}} used" + +[portal.infrastructure.storage.empty] +description = "Connected storage and usage appear here." +title = "No storage configured" + +[portal.infrastructure.storage.lifecycle] +active = "Active" +activeRange = "0–{{value}}d" +archived = "Archived" +coldStorage = "cold storage" +deleted = "Deleted" +never = "never" +purged = "purged" + +[portal.infrastructure.storage.providers] +connect = "Connect" +connected = "Connected" +heading = "Connected providers" +subheading = "Where processed artifacts are written." + +[portal.infrastructure.storage.retention] +heading = "Retention" +subheading = "How long artifacts are kept before lifecycle deletion." +windowLabel = "Default retention window" + +[portal.infrastructure.storage.retentionOption] +days_one = "{{count}} day" +days_other = "{{count}} days" +never = "Never delete" + +[portal.infrastructure.storage.totalUsage] +heading = "Total usage" +progressLabel = "Storage used" +subheading = "Storage consumed across all connected providers." + +[portal.infrastructure.tabs] +apiKeys = "API Keys" +audit = "Audit Logs" +deployments = "Deployments" +models = "Models" +security = "Security" +storage = "Storage" + +[portal.mocks.label] +off = "Mocks OFF" +on = "Mocks ON" + +[portal.mocks.tooltip] +off = "Mock data OFF — fetch calls go to the real network. Click to re-enable mocks (reloads the page)." +on = "Mock data ON — fetch calls are intercepted by MSW. Click to switch to the real network (reloads the page)." + +[portal.nav] +agent-builder = "Agent Builder" +components = "Components" +docs = "Developer Docs" +documents = "Documents" +editor = "Editor" +home = "Home" +infrastructure = "Infrastructure" +pipelines = "Pipelines" +policies = "Policies" +procurement = "Procurement" +settings = "Settings" +sources = "Sources" +usage = "Usage & Billing" +users = "Users" + +[portal.notifications] +markAllRead = "Mark all read" +title = "Notifications" +viewAll = "View all" + +[portal.notifications.ariaLabel] +none = "Notifications, no unread" +unread_one = "Notifications, {{count}} unread" +unread_other = "Notifications, {{count}} unread" + +[portal.notifications.count] +allRead = "all read" +loading = "loading" +new_one = "{{count}} new" +new_other = "{{count}} new" + +[portal.notifications.empty] +description = "No new notifications." +title = "You're all caught up" + +[portal.opRunner] +durationMs = "{{ms}} ms" +featuredOps = "Featured ops" +subtitle = "Drop a sample, pick an op, see what Stirling returns." +title = "Try a PDF operation" + +[portal.opRunner.action] +close = "Close" +openBuilder = "Open the pipeline builder" +run = "Run operation" +runAgain = "Run again" +running = "Running…" + +[portal.opRunner.drop] +hint = "or use a sample document." +pickAnother = "Pick another sample" +replaceHint = "Drop again or pick another sample to replace." +title = "Drop a PDF here" +useSample = "Use a sample" + +[portal.opRunner.empty] +description = "Once operations are published, they'll show up here." +title = "No featured ops yet" + +[portal.opRunner.error] +title = "The operation didn't complete" +unknown = "Unknown error" + +[portal.opRunner.hint] +aSample = "a sample" +press = "Press" +ready = "Ready" +toInvoke = "to invoke" + +[portal.opRunner.hint.runOn] +before = "Run" +middle = "on" + +[portal.opRunner.running] +title = "Running {{label}}…" + +[portal.opRunner.status] +completed = "Completed" +failed = "Failed" + +[portal.pipelines] +subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." +title = "Pipelines" + +[portal.pipelines.actions] +newPipeline = "New pipeline" + +[portal.pipelines.composer] +cancel = "Cancel" +chainEmpty = "Add operations from the palette below." +create = "Create pipeline" +directory = "Output folder" +directoryHelp = "Absolute path on the server. Must be within the configured allowed folders." +editTitle = "Edit pipeline" +moveDown = "Move down" +moveUp = "Move up" +name = "Name" +namePlaceholder = "e.g. Redaction sweep" +noSources = "No sources connected yet. The pipeline can still run on files supplied to it directly." +operations_one = "Operation ({{count}})" +operations_other = "Operations ({{count}})" +output = "Output" +removeStep = "Remove operation" +save = "Save changes" +scheduleEvery = "Run every" +sources = "Sources" +sourcesLoading = "Loading sources..." +subtitle = "Pick the sources it runs over, chain the operations, then choose when it runs and where output goes." +title = "New pipeline" +trigger = "Trigger" +triggerManual = "Manual only" + +[portal.pipelines.composer.unit] +days = "days" +hours = "hours" +minutes = "minutes" + +[portal.pipelines.delete] +body = "Delete \"{{name}}\"? This can't be undone." +cancel = "Cancel" +confirm = "Delete" +title = "Delete pipeline?" + +[portal.pipelines.detail] +closeAriaLabel = "Close detail" +delete = "Delete pipeline" +edit = "Edit" +noSources = "No sources. Files are supplied directly to each run." +noSteps = "No operations configured." +output = "Output" +pause = "Pause" +resume = "Resume" +run = "Run now" +sources = "Sources" +steps = "Operations" +subtitle = "{{trigger}} · {{status}}" + +[portal.pipelines.empty] +action = "Create a pipeline" +description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes." +title = "No pipelines yet" + +[portal.pipelines.kpi] +active = "Active" +paused = "Paused" +total = "Pipelines" + +[portal.pipelines.output] +folder = "Write to folder" +inline = "Return files" + +[portal.pipelines.run] +completed_one = "Run completed." +completed_other = "All {{count}} runs completed." +empty = "Nothing to run: the sources had no documents to process." +failed = "Run failed: {{error}}" +running = "Run started; still in progress." + +[portal.pipelines.status] +active = "Active" +paused = "Paused" + +[portal.pipelines.table] +name = "Pipeline" +sources = "Sources" +status = "Status" +steps = "Steps" + +[portal.pipelines.trigger] +folder-watch = "Folder watch" +manual = "Manual" +schedule = "Scheduled" + +[portal.policies] +subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original." +title = "Policies" + +[portal.policies.card] +comingSoon = "Coming soon" +notSetUp = "Not set up" + +[portal.policies.detail] +enforces = "Enforces" +onEveryExport = "On every export" +onEveryUpload = "On every upload" +outputAsNewFile = "as a new file" +outputAsNewVersion = "as a new version" +recentActivity = "Recent activity" +retry = "Retry" +showLess = "Show less" +showMore = "Show more" +sources = "Sources" + +[portal.policies.detail.actions] +delete = "Delete" +editSettings = "Edit settings" +pause = "Pause" +resume = "Resume" +runNow = "Run now" + +[portal.policies.detail.emptyActivity] +description = "Documents will appear here once this policy runs." +title = "No activity yet" + +[portal.policies.offline] +description = "Your policies are saved and will appear once the connection is restored." +retry = "Retry" +title = "Backend unavailable" + +[portal.policies.stats] +activeFor = "Active" +dataProcessed = "Data processed" +docsEnforced = "Docs enforced" + +[portal.policies.status] +active = "Active" +paused = "Paused" + +[portal.policies.summary.active] +description = "Enforcing on upload/export" +label = "Active policies" + +[portal.policies.summary.categories] +description = "Available to configure" +label = "Categories" + +[portal.policies.summary.docsEnforced] +description = "Across active policies" +label = "Docs enforced" + +[portal.policies.summary.paused] +description = "Configured but not firing" +label = "Paused" + +[portal.policies.wizard.actions] +back = "Back" +cancel = "Cancel" +continue = "Continue" +enablePolicy = "Enable policy" +saveChanges = "Save changes" + +[portal.policies.wizard.docTypes] +allDescription = "Set up an Ingestion (classification) policy to narrow this to specific document types." +allTitle = "All document types" +clear = "Clear" +heading = "Document types" +narrow = "Narrow" +selected_one = "{{count}} selected" +selected_other = "{{count}} selected" + +[portal.policies.wizard.errors] +noTools = "Enable at least one tool in the workflow first." +saveFailed = "Couldn't save the policy. Please try again." + +[portal.policies.wizard.output] +heading = "Output & run" + +[portal.policies.wizard.output.filenameRule] +autoNumber = "Auto-number" +label = "Filename rule" +placeholder = "Text to add (optional)" +prefix = "Prefix" +suffix = "Suffix" + +[portal.policies.wizard.output.outputAs] +label = "Output as" +newFile = "New file" +newVersion = "New version" + +[portal.policies.wizard.output.retries] +delayLabel = "Retry delay (min)" +heading = "Retries" +maxLabel = "Max retries" + +[portal.policies.wizard.output.runOn] +export = "Export" +helper = "When the policy fires: on upload, or before export." +label = "Run on" +upload = "Upload" + +[portal.policies.wizard.settings] +heading = "Settings" + +[portal.policies.wizard.sources] +emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here." +emptyTitle = "No sources available" +heading = "Sources" +loading = "Loading sources…" + +[portal.policies.wizard.tabs] +ariaLabel = "Setup steps" +settings = "Settings" +workflow = "Workflow" + +[portal.policies.wizard.title] +edit = "Edit {{category}} policy" +setUp = "Set up {{category}} policy" + +[portal.policies.wizard.workflow] +description = "The sequence of tools this policy runs on each document. Each tool is a Stirling endpoint; toggle the ones this policy should enforce." + +[portal.policySummary] +activeSummary = "{{active}} / {{total}} active" +noRule = "No rule enforced yet" +subtitle = "Standing automations every document passes through, regardless of which pipeline handles it." +title = "What runs on your PDFs" + +[portal.policySummary.action] +comingSoon = "Coming soon" +configure = "Configure" +setUp = "Set up" + +[portal.policySummary.column] +activeRule = "Active rule" +policy = "Policy" +status = "Status" + +[portal.policySummary.empty] +description = "Once policies are configured, the categories appear here." +title = "No policies yet" + +[portal.policySummary.state] +active = "Active" +off = "Off" +soon = "Soon" + +[portal.processingStatus] +managePlan = "Manage plan" +pdfsThisMonth = "PDFs this month" +progressLabel = "{{used}} of {{cap}} PDFs used this month" +upgrade = "Upgrade" +volumeSuffix = "PDFs processed · last 30 days" + +[portal.procurement] +enterpriseBadge = "Enterprise" +subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place." +title = "Procurement" + +[portal.procurement.action] +download = "Download" +pay = "Pay now" +request = "Request" +sign = "Review & sign" +upload = "Upload" + +[portal.procurement.docs] +count_one = "{{count}} doc" +count_other = "{{count}} docs" +done = "Done" +here = "You're here" +hide = "Hide" +optional = "Optional" +paidAddon = "Paid add-on" +show = "Show" +subtitle = "Everything you need at each step of the journey, surfaced as the deal moves through it." +supportingSubtitle = "SOC 2, security reviews, tax forms and more, ready when your security or procurement team asks. Some carry a one-time fee." +supportingTitle = "Supporting your evaluation" +title = "Documents" +upcoming = "Upcoming" + +[portal.procurement.journey] +daysLeft_one = "{{count}} day left" +daysLeft_other = "{{count}} days left" +engineerLabel = "Your solutions engineer" +eyebrow = "Your rollout" +live = "You're live on Stirling Enterprise" +nextStep = "Next step: {{action}}" +subtitle = "Your solutions engineer is on every step. One next action at a time; the full checklist is below." +title = "From trial to live, one guided path" +trialTitle = "Enterprise trial" + +[portal.procurement.locked] +description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation." +eyebrow = "Enterprise only" +talkToSales = "Talk to sales" +title = "The procurement track opens with Enterprise" + +[portal.procurement.modal] +cancel = "Cancel" +chooseFile = "Choose file" +downloadBody = "Your download will begin shortly." +downloadCta = "Download" +downloadTitle = "Download" +noFile = "No file selected" +payBody = "Pay your committed contract by card or bank transfer through Stripe. Your workspace provisions as soon as payment clears." +payCta = "Continue to Stripe" +payTitle = "Confirm payment" +requestBodyFree = "We generate this on demand. Confirm and your solutions engineer will send it across shortly." +requestBodyPaid = "This is a paid add-on. Confirm and your solutions engineer will scope it and send the paperwork." +requestCta = "Request" +requestTitle = "Request this document" +signBody = "Opens the Stirling Enterprise Agreement for e-signature: one signature covers the MSA, order form, EULA and DPA. We countersign automatically and you advance to payment." +signCta = "Open for signature" +signTitle = "Review and sign your agreement" +uploadBody = "Send us your PO and we invoice against it on your terms. Drag in the PDF or pick a file below." +uploadCta = "Upload purchase order" +uploadTitle = "Upload your purchase order" + +[portal.procurement.status] +action = "Action needed" +available = "Available" +complete = "Complete" +pending = "Pending" +request = "On request" + +[portal.recentActivity] +title = "Recent activity" +viewAll = "View all" + +[portal.recentActivity.empty] +description = "Pipeline runs, deploys and agent events will appear here." +title = "Nothing here yet" + +[portal.search] +ariaLabel = "Search" +placeholder = "Search Stirling — endpoints, pipelines, docs…" + +[portal.search.empty] +noActionsDescription = "Quick actions will appear here once they're available." +noActionsTitle = "No quick actions" +noMatches = "No matches for \"{{query}}\"" +noMatchesDescription = "Try a different keyword or browse the catalogue." + +[portal.settings] +ariaLabel = "Settings" +cancel = "Cancel" +enterpriseBadge = "Enterprise" +footerNote = "Changes apply to this workspace." +saveChanges = "Save changes" + +[portal.settings.appearance] +themeSub = "Choose how the portal looks on this device." +themeTitle = "Theme" + +[portal.settings.appearance.dark] +hint = "Dim surfaces" +label = "Dark" + +[portal.settings.appearance.light] +hint = "Bright surfaces" +label = "Light" + +[portal.settings.authentication] +sessionTimeout = "Session timeout" +sessionTimeoutHelper = "Members re-authenticate after this idle period." +sub = "Organisation-wide authentication controls." +title = "Sign-in policy" + +[portal.settings.authentication.mfa] +description = "Require every member to complete MFA at sign-in." +label = "Enforce two-factor (MFA)" + +[portal.settings.authentication.scim] +description = "Sync members and roles from your directory." +label = "SCIM provisioning" + +[portal.settings.authentication.sso] +description = "Federate sign-in through your identity provider." +label = "Single sign-on (SAML)" + +[portal.settings.authentication.timeout] +1440 = "24 hours" +240 = "4 hours" +480 = "8 hours" +60 = "1 hour" +720 = "12 hours" + +[portal.settings.earlyAccess] +sub = "Opt into features still in preview." +title = "Preview features" + +[portal.settings.groups] +account = "Account" +admin = "Admin" +workspace = "Workspace" + +[portal.settings.notifications] +sub = "Pick which events reach your inbox." +title = "Email notifications" + +[portal.settings.notifications.pipeline-failures] +description = "A run errors out or a step times out." +label = "Pipeline failures" + +[portal.settings.notifications.pipeline-success] +description = "Every successful pipeline run finishes." +label = "Pipeline completions" + +[portal.settings.notifications.product-updates] +description = "New operations, sources, and release notes." +label = "Product updates" + +[portal.settings.notifications.security-alerts] +description = "New API keys, sign-ins, or permission changes." +label = "Security alerts" + +[portal.settings.notifications.usage-alerts] +description = "You approach a plan limit or rate cap." +label = "Usage & quota alerts" + +[portal.settings.notifications.weekly-digest] +description = "A Monday summary of volume and health." +label = "Weekly digest" + +[portal.settings.profile] +accountFallback = "Account" +changePhoto = "Change photo" +email = "Email" +emailHelper = "Used for sign-in and notification delivery." +emailPlaceholder = "you@company.com" +fullName = "Full name" +namePlaceholder = "Your name" + +[portal.settings.sections] +account-link = "Account link" +appearance = "Appearance" +authentication = "Authentication" +early-access = "Early access" +general = "General" +notifications = "Notifications" +profile = "Profile" +sessions = "Active sessions" + +[portal.settings.sessions] +revoke = "Revoke" +sub = "Devices currently signed in to this account." +thisDevice = "This device" +title = "Active sessions" + +[portal.settings.workspace] +manageBilling = "Manage billing" +nameLabel = "Workspace name" +namePlaceholder = "Workspace name" +plan = "Plan" +regionEnterpriseSuffix = "{{region}} · Enterprise" +regionHelper = "Where documents are processed and stored at rest." +regionLabel = "Data residency region" +seats = "Seats" +seatsUsed = "{{used}} of {{total}} used" + +[portal.shell.header] +accountFallback = "Account" +accountMenu = "Account menu" +darkMode = "Dark mode" +lightMode = "Light mode" +search = "Search" +searchPlaceholder = "Search…" +signOut = "Sign out" +switchToDark = "Switch to dark theme" +switchToLight = "Switch to light theme" + +[portal.shell.sidebar] +appEditor = "Editor" +appProcessor = "Processor" +brandSuffix = "Stirling Processor" +docsCount = "{{docs}} docs" +docsProcessed = "Docs processed" +linkAccount = "Link Stirling account" +planEnterprise = "Enterprise plan" +planProcessor = "Processor plan" +primaryNav = "Primary navigation" +switchApp = "Switch app" + +[portal.sources] +subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it." +title = "Sources" + +[portal.sources.actions] +agentBuilder = "Agent Builder" +connectSource = "Connect source" + +[portal.sources.delete] +body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated." +cancel = "Cancel" +confirm = "Delete" +title = "Delete source?" + +[portal.sources.detail] +closeAriaLabel = "Close detail" +delete = "Delete source" +docs24h = "Last 24h" +docs30d = "Last 30 days" +docsTotal = "Total seen" +docsTrend = "Documents over the last 30 days" +documents = "Documents" +edit = "Edit" +notReferenced = "Not referenced by any policy, so it's safe to delete." +pause = "Pause" +resume = "Resume" +subtitle = "{{type}} · {{status}}" +usedBy = "Used by" + +[portal.sources.empty] +description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from." +title = "No sources connected yet" + +[portal.sources.kpi] +inUse = "In use" +total = "Connections" +unused = "Unused" + +[portal.sources.status] +active = "Active" +disabled = "Disabled" +unused = "Unused" + +[portal.sources.table] +source = "Source" +status = "Status" +usedBy = "Policies" + +[portal.sources.types.editor] +label = "Editor" + +[portal.sources.types.folder] +description = "Watch a directory on the server for new documents." +label = "Folder" + +[portal.sources.types.folder.fields.directory] +helperText = "Absolute path Stirling watches for files to process." +label = "Directory path" +placeholder = "/data/incoming" + +[portal.sources.types.folder.fields.mode] +label = "Read mode" + +[portal.sources.types.folder.fields.mode.options] +consume = "Consume: process each file once" +snapshot = "Snapshot: re-read the folder every run" + +[portal.sources.types.unknown] +label = "Source" + +[portal.sources.wizard] +back = "Back" +cancel = "Cancel" +continue = "Continue" +editTitle = "Edit source" +name = "Name" +namePlaceholder = "e.g. Claims intake" +save = "Save changes" +subtitle = "Step {{current}} of {{total}} · {{label}}" +title = "Connect a source" +type = "Type" + +[portal.sources.wizard.steps] +chooseType = "Choose type" +configure = "Configure" +review = "Review & connect" + +[portal.usage] +managePayment = "Manage Payment" +subtitle = "Consumption, invoices, and plan management for every PDF Stirling has billed, in one console." +title = "Usage & billing" + +[portal.usage.error] +loadWallet = "Couldn't load wallet" +openStripePortal = "Couldn't open Stripe portal" +walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}" + +[portal.usage.sessionExpired] +action = "Sign in again" +body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked." +title = "Session expired" + +[portal.usageChart] +defaultLabel = "Docs processed · last 30 days" +delta = "{{pct}}% vs prior 30d" +docsValue = "{{value}} docs" +srAnnounce = "{{date}}: {{value}} docs" + +[portal.useCases] +title = "Popular use cases" +viewAll = "View all pipelines" + +[portal.useCases.items.authenticity] +blurb = "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code." +cta = "Try authenticity check" +eyebrow = "AUTHENTICITY" +title = "Verify signatures and detect tampering" + +[portal.useCases.items.autoRouting] +blurb = "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow." +cta = "Build a classifier pipeline" +eyebrow = "AUTO-ROUTING" +title = "Auto-classify and route incoming documents" + +[portal.useCases.items.piiRedaction] +blurb = "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline." +cta = "See redaction pipelines" +eyebrow = "PII REDACTION" +title = "Redact PII before it leaves your stack" + +[portal.useCases.items.trainingData] +blurb = "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable." +cta = "Build a training-data pipeline" +eyebrow = "TRAINING DATA" +title = "Turn PDFs into training data" + +[portal.users] +subtitle = "The people in your organization and the access they hold — roles, status and security controls." +title = "Users" + +[portal.users.access] +subtitle = "Seats, authentication and provisioning for your organization." +title = "Access & security" + +[portal.users.access.auth] +title = "Authentication" + +[portal.users.access.auth.requireMfa] +description = "Members must set up a second factor to sign in." +enforced = "Enforced org-wide on this plan." +label = "Require MFA" + +[portal.users.access.auth.shortSessions] +description = "Sign members out after inactivity (currently {{timeout}})." +label = "Short-lived sessions" + +[portal.users.access.scim] +active = "Active" +directory = "Directory" +lastSync = "Last sync" +note = "Members are created, updated and deactivated automatically from your identity provider." +off = "Off" +title = "SCIM provisioning" + +[portal.users.access.seats] +title = "Seats" +unlimited = "Your plan includes unlimited seats." +usedLabel = "{{used}} of {{limit}} seats used" + +[portal.users.access.sso] +connected = "Connected" +domains = "Domains" +manage = "Manage connection" +notConfigured = "Not configured" +provider = "Provider" +title = "SSO / SAML" + +[portal.users.access.upgrade] +action = "Upgrade plan" +title = "Unlock team access controls" + +[portal.users.empty] +description = "Invite your team to start collaborating on documents and pipelines." +title = "No members yet" + +[portal.users.invite] +cancel = "Cancel" +email = "Email" +emailError = "Enter a valid email address" +emailPlaceholder = "teammate@acme.com" +role = "Role" +roleHelper = "Determines what the member can do once they join." +send = "Send invite" +subtitle = "They'll receive an email to join your organization." + +[portal.users.roles] +subtitle = "Every role exists on every plan — what each one can do is fixed across the org." +title = "Roles" + +[portal.users.summary] +members = "Members" +pendingInvites = "Pending invites" +seatsUsed = "Seats used" + +[portal.users.table] +actionsFor = "Actions for {{name}}" +changeRole = "Change role" +lastActive = "Last active" +member = "Member" +remove = "Remove from org" +role = "Role" +status = "Status" +suspend = "Suspend" + +[portal.welcome] +ariaLabel = "Stirling product highlights" +pagination = "Carousel pagination" +slideLabel = "Slide {{number}}: {{title}}" + +[portal.welcome.ornament.editor] +critical = "Critical" +ocrClean = "OCR-clean" +schemaMatch = "schema match 0.97" +signed = "signed" + +[portal.welcome.slides.agents] +eyebrow = "AI Agents" +primary = "Try PDF Processor" +secondary = "View MCP docs" +sub = "Wire your agent via MCP, REST or tool definitions. Deterministic operations and guardrails — test with scenarios and evals before you ship." +title = "PDF Processor for AI Agents" + +[portal.welcome.slides.editor] +eyebrow = "PDF Editor" +primary = "Install PDF Editor" +secondary = "Connect an instance" +sub = "Annotate, sign, redact, and review locally or in the cloud. Brought to the platform as the credibility anchor of the Stirling control plane." +title = "The #1 PDF Editor on GitHub" + +[portal.welcome.slides.platform] +eyebrow = "Platform" +primary = "Try a PDF operation" +secondary = "Get an API key" +sub = "Ingest from agents, APIs and connectors. Run composable pipelines with evals and golden sets. Land in a vault with zero-standing-access controls." +title = "PDF Infrastructure for Developers" + [printFile] title = "Print File" diff --git a/frontend/portal/public/mockServiceWorker.js b/frontend/editor/public/mockServiceWorker.js similarity index 100% rename from frontend/portal/public/mockServiceWorker.js rename to frontend/editor/public/mockServiceWorker.js diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx index f26d40f743..654691c2fc 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/SpendCapControl.tsx @@ -1,5 +1,5 @@ /** - * Editor cloud adapter over the shared {@code @shared/billing} spend-cap control: + * Editor cloud adapter over the shared {@code @app/billing} spend-cap control: * supplies the i18n copy (the shared control is copy-agnostic) and the editor's * {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code * onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is @@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next"; import { DEFAULT_CAP_PRESETS, SpendCapControl as SharedSpendCapControl, -} from "@shared/billing"; +} from "@app/billing"; // eslint-disable-next-line no-restricted-imports import "./SpendCapControl.css"; diff --git a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx index 308fc97d32..4c8804027c 100644 --- a/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx +++ b/frontend/editor/src/cloud/components/shared/config/configSections/usageMeters.tsx @@ -7,7 +7,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useWallet, type Wallet } from "@app/hooks/useWallet"; -import { currencySymbol, MeterBar, meterState } from "@shared/billing"; +import { currencySymbol, MeterBar, meterState } from "@app/billing"; import "@app/components/shared/config/configSections/Payg.css"; import "@app/components/shared/config/configSections/PaygFree.css"; diff --git a/frontend/editor/src/cloud/hooks/useWallet.ts b/frontend/editor/src/cloud/hooks/useWallet.ts index 5a1b1d21cb..07e862fb8c 100644 --- a/frontend/editor/src/cloud/hooks/useWallet.ts +++ b/frontend/editor/src/cloud/hooks/useWallet.ts @@ -57,10 +57,10 @@ import type { WalletMember, WalletCategoryBreakdown, WalletActivityRow, -} from "@shared/billing"; +} from "@app/billing"; // ─── Public types ─────────────────────────────────────────────────────── -// The wallet contract lives in @shared/billing (shared with the admin portal). +// The wallet contract lives in @app/billing (shared with the admin portal). // Re-exported so existing `@app/hooks/useWallet` importers keep their imports. export type { Wallet, diff --git a/frontend/editor/src/cloud/tsconfig.json b/frontend/editor/src/cloud/tsconfig.json index bf3eedb2e5..2b703ea335 100644 --- a/frontend/editor/src/cloud/tsconfig.json +++ b/frontend/editor/src/cloud/tsconfig.json @@ -7,10 +7,10 @@ "../../src/proprietary/*", "../../src/core/*" ], + "@portal/*": ["../../src/portal/*"], "@cloud/*": ["../../src/cloud/*"], "@proprietary/*": ["../../src/proprietary/*"], - "@core/*": ["../../src/core/*"], - "@shared/*": ["../../../shared/*"] + "@core/*": ["../../src/core/*"] } }, "include": [ diff --git a/frontend/shared/assets/Brand.stories.tsx b/frontend/editor/src/core/assets/Brand.stories.tsx similarity index 75% rename from frontend/shared/assets/Brand.stories.tsx rename to frontend/editor/src/core/assets/Brand.stories.tsx index 87cf9f1257..886e7a023d 100644 --- a/frontend/shared/assets/Brand.stories.tsx +++ b/frontend/editor/src/core/assets/Brand.stories.tsx @@ -2,16 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; // Visual catalogue of the shared brand assets (shared/assets/brand) — the single // source of truth for the Stirling logos used across the apps. -import modernMarkDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; -import modernMarkLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; -import modernBlack from "@shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg"; -import modernWhite from "@shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg"; -import modernGrey from "@shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg"; -import classicMarkDark from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg"; -import classicMarkLight from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg"; -import classicBlack from "@shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg"; -import classicWhite from "@shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg"; -import classicGrey from "@shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg"; +import modernMarkDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; +import modernMarkLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; +import modernBlack from "@app/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg"; +import modernWhite from "@app/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg"; +import modernGrey from "@app/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg"; +import classicMarkDark from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg"; +import classicMarkLight from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg"; +import classicBlack from "@app/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg"; +import classicWhite from "@app/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg"; +import classicGrey from "@app/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg"; type Asset = { label: string; src: string; onDark?: boolean }; type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] }; diff --git a/frontend/shared/assets/brand/classic-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png similarity index 100% rename from frontend/shared/assets/brand/classic-logo/Firstpage.png rename to frontend/editor/src/core/assets/brand/classic-logo/Firstpage.png diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg similarity index 100% rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg similarity index 100% rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg similarity index 100% rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg similarity index 100% rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg diff --git a/frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg b/frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg similarity index 100% rename from frontend/shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg rename to frontend/editor/src/core/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg diff --git a/frontend/shared/assets/brand/classic-logo/favicon.ico b/frontend/editor/src/core/assets/brand/classic-logo/favicon.ico similarity index 100% rename from frontend/shared/assets/brand/classic-logo/favicon.ico rename to frontend/editor/src/core/assets/brand/classic-logo/favicon.ico diff --git a/frontend/shared/assets/brand/classic-logo/logo-tooltip.svg b/frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg similarity index 100% rename from frontend/shared/assets/brand/classic-logo/logo-tooltip.svg rename to frontend/editor/src/core/assets/brand/classic-logo/logo-tooltip.svg diff --git a/frontend/shared/assets/brand/classic-logo/logo192.png b/frontend/editor/src/core/assets/brand/classic-logo/logo192.png similarity index 100% rename from frontend/shared/assets/brand/classic-logo/logo192.png rename to frontend/editor/src/core/assets/brand/classic-logo/logo192.png diff --git a/frontend/shared/assets/brand/classic-logo/logo512.png b/frontend/editor/src/core/assets/brand/classic-logo/logo512.png similarity index 100% rename from frontend/shared/assets/brand/classic-logo/logo512.png rename to frontend/editor/src/core/assets/brand/classic-logo/logo512.png diff --git a/frontend/shared/assets/brand/modern-logo/Firstpage.png b/frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png similarity index 100% rename from frontend/shared/assets/brand/modern-logo/Firstpage.png rename to frontend/editor/src/core/assets/brand/modern-logo/Firstpage.png diff --git a/frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg b/frontend/editor/src/core/assets/brand/modern-logo/LoginDarkModeHeader.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/LoginDarkModeHeader.svg rename to frontend/editor/src/core/assets/brand/modern-logo/LoginDarkModeHeader.svg diff --git a/frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg b/frontend/editor/src/core/assets/brand/modern-logo/LoginLightModeHeader.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/LoginLightModeHeader.svg rename to frontend/editor/src/core/assets/brand/modern-logo/LoginLightModeHeader.svg diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg diff --git a/frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg b/frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg rename to frontend/editor/src/core/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg diff --git a/frontend/portal/public/favicon.ico b/frontend/editor/src/core/assets/brand/modern-logo/favicon.ico similarity index 100% rename from frontend/portal/public/favicon.ico rename to frontend/editor/src/core/assets/brand/modern-logo/favicon.ico diff --git a/frontend/shared/assets/brand/modern-logo/logo-tooltip.svg b/frontend/editor/src/core/assets/brand/modern-logo/logo-tooltip.svg similarity index 100% rename from frontend/shared/assets/brand/modern-logo/logo-tooltip.svg rename to frontend/editor/src/core/assets/brand/modern-logo/logo-tooltip.svg diff --git a/frontend/shared/assets/brand/modern-logo/logo192.png b/frontend/editor/src/core/assets/brand/modern-logo/logo192.png similarity index 100% rename from frontend/shared/assets/brand/modern-logo/logo192.png rename to frontend/editor/src/core/assets/brand/modern-logo/logo192.png diff --git a/frontend/shared/assets/brand/modern-logo/logo512.png b/frontend/editor/src/core/assets/brand/modern-logo/logo512.png similarity index 100% rename from frontend/shared/assets/brand/modern-logo/logo512.png rename to frontend/editor/src/core/assets/brand/modern-logo/logo512.png diff --git a/frontend/shared/assets/login/AddToPDF.png b/frontend/editor/src/core/assets/login/AddToPDF.png similarity index 100% rename from frontend/shared/assets/login/AddToPDF.png rename to frontend/editor/src/core/assets/login/AddToPDF.png diff --git a/frontend/shared/assets/login/Firstpage.png b/frontend/editor/src/core/assets/login/Firstpage.png similarity index 100% rename from frontend/shared/assets/login/Firstpage.png rename to frontend/editor/src/core/assets/login/Firstpage.png diff --git a/frontend/shared/assets/login/LoginBackgroundPanel.png b/frontend/editor/src/core/assets/login/LoginBackgroundPanel.png similarity index 100% rename from frontend/shared/assets/login/LoginBackgroundPanel.png rename to frontend/editor/src/core/assets/login/LoginBackgroundPanel.png diff --git a/frontend/shared/assets/login/SecurePDF.png b/frontend/editor/src/core/assets/login/SecurePDF.png similarity index 100% rename from frontend/shared/assets/login/SecurePDF.png rename to frontend/editor/src/core/assets/login/SecurePDF.png diff --git a/frontend/shared/assets/login/apple.svg b/frontend/editor/src/core/assets/login/apple.svg similarity index 100% rename from frontend/shared/assets/login/apple.svg rename to frontend/editor/src/core/assets/login/apple.svg diff --git a/frontend/shared/assets/login/authentik.svg b/frontend/editor/src/core/assets/login/authentik.svg similarity index 100% rename from frontend/shared/assets/login/authentik.svg rename to frontend/editor/src/core/assets/login/authentik.svg diff --git a/frontend/shared/assets/login/cloudron.svg b/frontend/editor/src/core/assets/login/cloudron.svg similarity index 100% rename from frontend/shared/assets/login/cloudron.svg rename to frontend/editor/src/core/assets/login/cloudron.svg diff --git a/frontend/shared/assets/login/github.svg b/frontend/editor/src/core/assets/login/github.svg similarity index 100% rename from frontend/shared/assets/login/github.svg rename to frontend/editor/src/core/assets/login/github.svg diff --git a/frontend/shared/assets/login/google.svg b/frontend/editor/src/core/assets/login/google.svg similarity index 100% rename from frontend/shared/assets/login/google.svg rename to frontend/editor/src/core/assets/login/google.svg diff --git a/frontend/shared/assets/login/keycloak.svg b/frontend/editor/src/core/assets/login/keycloak.svg similarity index 100% rename from frontend/shared/assets/login/keycloak.svg rename to frontend/editor/src/core/assets/login/keycloak.svg diff --git a/frontend/shared/assets/login/microsoft.svg b/frontend/editor/src/core/assets/login/microsoft.svg similarity index 100% rename from frontend/shared/assets/login/microsoft.svg rename to frontend/editor/src/core/assets/login/microsoft.svg diff --git a/frontend/shared/assets/login/oidc.svg b/frontend/editor/src/core/assets/login/oidc.svg similarity index 100% rename from frontend/shared/assets/login/oidc.svg rename to frontend/editor/src/core/assets/login/oidc.svg diff --git a/frontend/shared/auth/ui/oauthIcons.ts b/frontend/editor/src/core/auth/ui/oauthIcons.ts similarity index 67% rename from frontend/shared/auth/ui/oauthIcons.ts rename to frontend/editor/src/core/auth/ui/oauthIcons.ts index c588274b92..f0d879786f 100644 --- a/frontend/shared/auth/ui/oauthIcons.ts +++ b/frontend/editor/src/core/auth/ui/oauthIcons.ts @@ -6,14 +6,14 @@ * editor's saas/desktop login buttons, and the config provider list) share one * copy that works in both the editor and the portal bundles. */ -import googleIcon from "@shared/assets/login/google.svg"; -import githubIcon from "@shared/assets/login/github.svg"; -import appleIcon from "@shared/assets/login/apple.svg"; -import microsoftIcon from "@shared/assets/login/microsoft.svg"; -import keycloakIcon from "@shared/assets/login/keycloak.svg"; -import cloudronIcon from "@shared/assets/login/cloudron.svg"; -import authentikIcon from "@shared/assets/login/authentik.svg"; -import oidcIcon from "@shared/assets/login/oidc.svg"; +import googleIcon from "@app/assets/login/google.svg"; +import githubIcon from "@app/assets/login/github.svg"; +import appleIcon from "@app/assets/login/apple.svg"; +import microsoftIcon from "@app/assets/login/microsoft.svg"; +import keycloakIcon from "@app/assets/login/keycloak.svg"; +import cloudronIcon from "@app/assets/login/cloudron.svg"; +import authentikIcon from "@app/assets/login/authentik.svg"; +import oidcIcon from "@app/assets/login/oidc.svg"; /** Generic fallback icon (filename) for unknown providers. */ export const GENERIC_PROVIDER_ICON = "oidc.svg"; diff --git a/frontend/editor/src/core/components/AppProviders.tsx b/frontend/editor/src/core/components/AppProviders.tsx index ede9ed266a..f050ae7f9b 100644 --- a/frontend/editor/src/core/components/AppProviders.tsx +++ b/frontend/editor/src/core/components/AppProviders.tsx @@ -27,6 +27,7 @@ import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestra import { PageEditorProvider } from "@app/contexts/PageEditorContext"; import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; import { useLogoAssets } from "@app/hooks/useLogoAssets"; @@ -43,6 +44,11 @@ function ScarfTrackingInitializer() { return null; } +function PosthogTrackingInitializer() { + usePosthogTracking(); + return null; +} + // Component to run app-level initialization (must be inside AppProviders for context access) function AppInitializer() { useAppInitialization(); @@ -122,6 +128,7 @@ export function AppProviders({ retryOptions={appConfigRetryOptions} {...appConfigProviderProps} > + diff --git a/frontend/editor/src/core/components/shared/ThemeProvider.tsx b/frontend/editor/src/core/components/shared/ThemeProvider.tsx index 2dda9b8d39..bf15ce187f 100644 --- a/frontend/editor/src/core/components/shared/ThemeProvider.tsx +++ b/frontend/editor/src/core/components/shared/ThemeProvider.tsx @@ -18,8 +18,8 @@ import { getSystemTheme, resolveColorScheme, } from "@app/constants/theme"; -// SUI shared design-system tokens (used by @shared/components); key on `data-theme`. -import "@shared/tokens/tokens.css"; +// SUI shared design-system tokens (used by @app/ui); key on `data-theme`. +import "@app/tokens/tokens.css"; interface ThemeContextType { themeMode: ThemeMode; diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.css b/frontend/editor/src/core/components/shared/ToolPanelHeader.css new file mode 100644 index 0000000000..f1d99d1913 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.css @@ -0,0 +1,93 @@ +/* ===================== PanelHeader (core) ===================== */ +/* Header for the active-tool rail. Core-owned so the OSS build has no */ +/* design-system dependency; mirrors the shared rail header treatment so it */ +/* reads well in both light and dark mode (thin border, no heavy fill). */ + +.sui-panelhdr { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 1rem 1rem 0.75rem; + flex-shrink: 0; +} + +.sui-panelhdr__bar { + display: inline-flex; + align-items: center; + gap: 0.5rem; + flex: 1; + min-width: 0; + padding: 0.4rem 0.75rem 0.4rem 0.4rem; + border: 1px solid var(--border-subtle, var(--mantine-color-default-border)); + border-radius: 9999px; + background: var(--mantine-color-body); + text-align: left; + color: inherit; +} + +.sui-panelhdr__icon { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 9999px; + background: var(--mantine-color-blue-light); + color: var(--mantine-color-blue-filled); + flex-shrink: 0; +} + +.sui-panelhdr__icon svg { + font-size: 1rem; + width: 1rem; + height: 1rem; +} + +/* ToolIcon wraps its glyph in .tool-button-icon with its own margin/transform; + reset them so the glyph sits dead-centre in the circular badge. */ +.sui-panelhdr__icon .tool-button-icon { + margin: 0 !important; + transform: none !important; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.sui-panelhdr__label { + flex: 1; + min-width: 0; + font-size: 0.9rem; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--mantine-color-text); +} + +/* Dark mode: let the header blend into the rail — just a thin border, no fill — + so it doesn't read as a clashing lighter card on the dark toolbar. */ +[data-mantine-color-scheme="dark"] .sui-panelhdr__bar { + background: transparent; + border-color: var(--border-subtle, var(--mantine-color-default-border)); +} + +[data-mantine-color-scheme="dark"] .sui-panelhdr__icon { + background: color-mix( + in srgb, + var(--mantine-color-blue-filled) 18%, + transparent + ); + color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled)); +} + +/* Dark mode: the subtle gray close button is too dim against the dark rail — + brighten it to a clearly-visible light grey (near-white on hover). */ +[data-mantine-color-scheme="dark"] .sui-panelhdr__close { + color: var(--mantine-color-gray-4); +} + +[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover { + color: var(--mantine-color-gray-2); +} diff --git a/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx new file mode 100644 index 0000000000..e34ca4c6c2 --- /dev/null +++ b/frontend/editor/src/core/components/shared/ToolPanelHeader.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from "react"; +import { ActionIcon } from "@mantine/core"; +import CloseIcon from "@mui/icons-material/Close"; +import "@app/components/shared/ToolPanelHeader.css"; + +export interface ToolPanelHeaderProps { + /** Glyph rendered in the tinted circular badge at the header's leading edge. */ + icon: ReactNode; + /** Header title. */ + title: ReactNode; + /** Close (X) handler. The trailing close button renders only when supplied. */ + onClose?: () => void; + /** aria-label for the close button. */ + closeLabel?: string; +} + +/** + * Header for the active-tool rail surface: a tinted icon badge + title in a + * rounded bar with a trailing close button. + * + * Core-owned so the OSS build carries no design-system dependency. It mirrors + * the shared rail header's styling for the simple (non-menu) case the tool panel + * needs; the richer variant (dropdown menu, accents, loading dot) lives in the + * design system for the AI chat and Policies surfaces. + */ +export function ToolPanelHeader({ + icon, + title, + onClose, + closeLabel, +}: ToolPanelHeaderProps) { + return ( +

    +
    + {icon} + {title} +
    + {onClose && ( + + + + )} +
    + ); +} diff --git a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx index e5fdacea79..598b705347 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -112,12 +112,14 @@ const GeneralSection: React.FC = ({ // falling back to the backend version const currentVersion = appVersion ?? config?.appVersion ?? null; - // Check for updates on mount + // Check for updates on mount — skipped when the update UI is hidden (SaaS + // build, managed-disabled desktop) so no external update call ever fires. useEffect(() => { + if (hideUpdateSection) return; if (currentVersion) { checkForUpdate(); } - }, [currentVersion, config?.machineType]); + }, [currentVersion, config?.machineType, hideUpdateSection]); const checkForUpdate = async () => { if (!currentVersion) return; diff --git a/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts b/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts index 6b5983822f..72dc675f8f 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts +++ b/frontend/editor/src/core/components/shared/config/configSections/providerDefinitions.ts @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { oauthIconUrl } from "@shared/auth/ui/oauthIcons"; +import { oauthIconUrl } from "@app/auth/ui/oauthIcons"; export type ProviderType = "oauth2" | "saml2" | "telegram" | "googledrive"; diff --git a/frontend/editor/src/core/components/tools/RightSidebar.tsx b/frontend/editor/src/core/components/tools/RightSidebar.tsx index bfd98d7b32..7e3aa04629 100644 --- a/frontend/editor/src/core/components/tools/RightSidebar.tsx +++ b/frontend/editor/src/core/components/tools/RightSidebar.tsx @@ -19,7 +19,7 @@ import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems"; import { useToolSections } from "@app/hooks/useToolSections"; import type { SubcategoryGroup } from "@app/hooks/useToolSections"; import { ToolIcon } from "@app/components/shared/ToolIcon"; -import { PanelHeader } from "@shared/components/PanelHeader"; +import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader"; import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip"; import { withViewTransition } from "@app/utils/viewTransition"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; @@ -291,7 +291,7 @@ export default function RightSidebar() { <> {!showPolicies && (activeTool ? ( - 001). Set 0 to disable.", @@ -90,6 +93,7 @@ const AddPageNumbersAppearanceSettings = ({ { describe("env vars", () => { it("every VITE_ var used in source is present in an example env file", () => { const baseEnv = readFileSync(join(frontendRoot, ".env"), "utf-8"); + const proprietaryEnv = readFileSync( + join(frontendRoot, ".env.proprietary"), + "utf-8", + ); const desktopEnv = readFileSync( join(frontendRoot, ".env.desktop"), "utf-8", @@ -58,6 +62,7 @@ describe("env vars", () => { const declaredKeys = new Set([ ...parseEnvKeys(baseEnv), + ...parseEnvKeys(proprietaryEnv), ...parseEnvKeys(desktopEnv), ...parseEnvKeys(saasEnv), ]); diff --git a/frontend/editor/src/core/hooks/useLogoAssets.test.ts b/frontend/editor/src/core/hooks/useLogoAssets.test.ts index b22701dedd..3d83fb35f4 100644 --- a/frontend/editor/src/core/hooks/useLogoAssets.test.ts +++ b/frontend/editor/src/core/hooks/useLogoAssets.test.ts @@ -10,11 +10,11 @@ import type { LogoVariant } from "@app/services/preferencesService"; */ describe("useLogoAssets - Logo Asset Files", () => { const publicDir = path.resolve(__dirname, "../../../public"); - // Brand logo assets live in the shared design system; the editor's vite - // config copies shared/assets/brand//* into the served root at build - // time (see viteStaticCopy in editor/vite.config.ts), so useLogoAssets can - // keep referencing them by their public-URL path. Validate them at source. - const brandDir = path.resolve(__dirname, "../../../../shared/assets/brand"); + // Brand logo assets live in core; the editor's vite config copies + // core/assets/brand//* into the served root at build time (see + // viteStaticCopy in editor/vite.config.ts), so useLogoAssets can keep + // referencing them by their public-URL path. Validate them at source. + const brandDir = path.resolve(__dirname, "../assets/brand"); // All asset files that useLogoAssets references const requiredAssets = [ diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx new file mode 100644 index 0000000000..41f5fad7dc --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.test.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from "react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; + +const posthogState = vi.hoisted(() => ({ loaded: false })); +const posthogMock = vi.hoisted(() => ({ + get __loaded() { + return posthogState.loaded; + }, + init: vi.fn(() => { + posthogState.loaded = true; + }), + opt_out_capturing: vi.fn(), + opt_in_capturing: vi.fn(), + set_config: vi.fn(), + has_opted_in_capturing: vi.fn(() => false), +})); + +vi.mock("posthog-js", () => ({ + default: posthogMock, +})); + +import { usePosthogTracking } from "@app/hooks/usePosthogTracking"; + +describe("usePosthogTracking", () => { + beforeEach(() => { + posthogState.loaded = false; + posthogMock.init.mockClear(); + posthogMock.opt_out_capturing.mockClear(); + posthogMock.opt_in_capturing.mockClear(); + posthogMock.set_config.mockClear(); + vi.stubEnv("VITE_PUBLIC_POSTHOG_KEY", "test-key"); + vi.stubEnv("VITE_PUBLIC_POSTHOG_HOST", "https://eu.i.posthog.com"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("does not initialize PostHog when analytics is disabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).not.toHaveBeenCalled(); + }); + }); + + it("initializes PostHog when analytics is enabled", async () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + renderHook(() => usePosthogTracking(), { wrapper }); + + await waitFor(() => { + expect(posthogMock.init).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/frontend/editor/src/core/hooks/usePosthogTracking.ts b/frontend/editor/src/core/hooks/usePosthogTracking.ts new file mode 100644 index 0000000000..a8037ec4a7 --- /dev/null +++ b/frontend/editor/src/core/hooks/usePosthogTracking.ts @@ -0,0 +1,83 @@ +import { useEffect } from "react"; +import posthog from "posthog-js"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; + +function applyPosthogConsent(): void { + if (typeof window === "undefined" || !posthog.__loaded) { + return; + } + + const optedIn = + window.CookieConsent?.acceptedService?.("posthog", "analytics") || false; + + if (optedIn) { + posthog.set_config({ persistence: "localStorage+cookie" }); + posthog.opt_in_capturing(); + return; + } + + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); +} + +function ensurePosthogInitialized(): boolean { + if (typeof window === "undefined") { + return false; + } + + const posthogKey = import.meta.env.VITE_PUBLIC_POSTHOG_KEY; + const posthogHost = import.meta.env.VITE_PUBLIC_POSTHOG_HOST; + + if (!posthogKey || !posthogHost) { + return false; + } + + if (!posthog.__loaded) { + posthog.init(posthogKey, { + api_host: posthogHost, + defaults: "2025-05-24", + capture_exceptions: true, + debug: false, + opt_out_capturing_by_default: true, + persistence: "memory", + cross_subdomain_cookie: false, + }); + } + + return true; +} + +export function usePosthogTracking(): void { + const { config } = useAppConfig(); + + useEffect(() => { + const analyticsEnabled = config?.enableAnalytics === true; + const posthogEnabled = analyticsEnabled && config?.enablePosthog !== false; + + if (!posthogEnabled) { + if (posthog.__loaded) { + posthog.opt_out_capturing(); + posthog.set_config({ persistence: "memory" }); + } + return; + } + + if (!ensurePosthogInitialized()) { + return; + } + + applyPosthogConsent(); + + const handleConsentChange = () => { + applyPosthogConsent(); + }; + + window.addEventListener("cc:onConsent", handleConsentChange); + window.addEventListener("cc:onChange", handleConsentChange); + + return () => { + window.removeEventListener("cc:onConsent", handleConsentChange); + window.removeEventListener("cc:onChange", handleConsentChange); + }; + }, [config?.enableAnalytics, config?.enablePosthog]); +} diff --git a/frontend/editor/src/core/i18n.ts b/frontend/editor/src/core/i18n.ts index 82b860b467..9663a1c2e2 100644 --- a/frontend/editor/src/core/i18n.ts +++ b/frontend/editor/src/core/i18n.ts @@ -1,7 +1,7 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; -import TomlBackend from "@shared/i18n/tomlBackend"; +import TomlBackend from "@app/i18n/tomlBackend"; import { supportedLanguages, rtlLanguages, @@ -10,10 +10,10 @@ import { normalizeLanguageCode, toUnderscoreFormat, toUnderscoreLanguages, -} from "@shared/i18n/languages"; +} from "@app/i18n/languages"; // Language metadata and code helpers are shared with the portal via -// @shared/i18n. Re-export them so existing `@app/i18n` consumers are unchanged. +// @app/i18n. Re-export them so existing `@app/i18n` consumers are unchanged. export { supportedLanguages, rtlLanguages, diff --git a/frontend/editor/src/core/i18n/config.ts b/frontend/editor/src/core/i18n/config.ts index 47e028c1f4..02b79f06b1 100644 --- a/frontend/editor/src/core/i18n/config.ts +++ b/frontend/editor/src/core/i18n/config.ts @@ -1,6 +1,6 @@ import i18n from "i18next"; import { initReactI18next } from "react-i18next"; -import TomlBackend from "@shared/i18n/tomlBackend"; +import TomlBackend from "@app/i18n/tomlBackend"; i18n .use(TomlBackend) diff --git a/frontend/shared/i18n/languages.ts b/frontend/editor/src/core/i18n/languages.ts similarity index 100% rename from frontend/shared/i18n/languages.ts rename to frontend/editor/src/core/i18n/languages.ts diff --git a/frontend/shared/i18n/tomlBackend.ts b/frontend/editor/src/core/i18n/tomlBackend.ts similarity index 100% rename from frontend/shared/i18n/tomlBackend.ts rename to frontend/editor/src/core/i18n/tomlBackend.ts diff --git a/frontend/shared/i18n/translationAudit.ts b/frontend/editor/src/core/i18n/translationAudit.ts similarity index 90% rename from frontend/shared/i18n/translationAudit.ts rename to frontend/editor/src/core/i18n/translationAudit.ts index 50cd1b2c76..e2cc309c72 100644 --- a/frontend/shared/i18n/translationAudit.ts +++ b/frontend/editor/src/core/i18n/translationAudit.ts @@ -19,8 +19,8 @@ import { fileURLToPath } from "url"; import ts from "typescript"; import { parse } from "smol-toml"; -const HERE = path.dirname(fileURLToPath(import.meta.url)); // frontend/shared/i18n -const FRONTEND_ROOT = path.resolve(HERE, "../.."); // frontend +const HERE = path.dirname(fileURLToPath(import.meta.url)); // frontend/editor/src/core/i18n +const FRONTEND_ROOT = path.resolve(HERE, "../../../.."); // frontend /** Repo root, for rendering source paths relative in CI annotations. */ export const REPO_ROOT = path.resolve(FRONTEND_ROOT, ".."); @@ -29,12 +29,6 @@ export interface TranslationProject { name: string; /** Absolute path to the source tree to scan. */ srcRoot: string; - /** - * Extra source trees that also contribute used keys (e.g. the shared layer - * (frontend/shared), whose components (login UI, etc.) are rendered by both - * apps and reference each app's locale keys). - */ - extraRoots?: string[]; /** Absolute path to the en-US source locale. */ localeFile: string; /** Static keys flagged as missing that are genuinely fine (false positives). */ @@ -68,11 +62,6 @@ const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/; const front = (rel: string): string => path.join(FRONTEND_ROOT, rel); -// The shared layer is rendered by both apps, so its translation usage counts -// toward each project (or keys used only from shared look unused, and a shared -// component's keys go unvalidated against an app's locale). -const SHARED_SRC = front("shared"); - /** * The projects the i18n suites guard. Each carries its own ignore lists: the * editor exempts a few runtime-assembled key families; the portal starts clean. @@ -80,8 +69,11 @@ const SHARED_SRC = front("shared"); export const I18N_PROJECTS: TranslationProject[] = [ { name: "editor", + // One project over the whole editor tree, including the portal layer. Portal + // strings live under portal.* in the shared locale and are referenced with + // that prefix in source (t("portal.users.title")), so they validate here + // like any other key. srcRoot: front("editor/src"), - extraRoots: [SHARED_SRC], localeFile: front("editor/public/locales/en-US/translation.toml"), ignoredKeyPatterns: [ // SignSettings / SavedSignaturesSection resolve every key as @@ -93,24 +85,14 @@ export const I18N_PROJECTS: TranslationProject[] = [ /^account\./, // [language] direction is read by the i18n layer, never as a UI string. /^language\.direction$/, + // Portal source-type copy is referenced via metadata keys in + // components/sources/sourceTypes.ts (t(field.labelKey)), invisible to the + // static scan. + /^portal\.sources\.types\./, ], minUsedKeys: 100, minLocaleKeys: 100, }, - { - name: "portal", - srcRoot: front("portal/src"), - extraRoots: [SHARED_SRC], - localeFile: front("portal/public/locales/en-US/translation.toml"), - ignoredKeyPatterns: [ - // Source-type copy is referenced via metadata keys in - // components/sources/sourceTypes.ts (t(field.labelKey)), so the static - // scan can't see these as used. - /^sources\.types\./, - ], - minUsedKeys: 20, - minLocaleKeys: 20, - }, ]; // Locale tables are shallow by design; the cap only trips on a pathological @@ -189,11 +171,6 @@ const listSourceFiles = (roots: string[]): string[] => ) .filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file))); -const projectRoots = (project: TranslationProject): string[] => [ - project.srcRoot, - ...(project.extraRoots ?? []), -]; - const hasPluralCoverage = (key: string, available: Set): boolean => [...available].some( (k) => k.startsWith(`${key}_`) && PLURAL_SUFFIX_RE.test(k), @@ -347,7 +324,7 @@ export function findMissingKeys(project: TranslationProject): { return false; }; - const used = listSourceFiles(projectRoots(project)) + const used = listSourceFiles([project.srcRoot]) .flatMap(extractStaticKeys) .filter(({ key }) => !ignored.has(key)); const missing = used.filter(({ key }) => !resolves(key)); @@ -360,7 +337,7 @@ export function findUnusedKeys(project: TranslationProject): { localeCount: number; } { const localeKeys = Array.from(collectLocaleKeys(project.localeFile)); - const files = listSourceFiles(projectRoots(project)); + const files = listSourceFiles([project.srcRoot]); const source = files.map((file) => fs.readFileSync(file, "utf8")).join("\n"); const shapes = new Set(); diff --git a/frontend/editor/src/core/routes/adminRouteExtensions.tsx b/frontend/editor/src/core/routes/adminRouteExtensions.tsx new file mode 100644 index 0000000000..657d4799a3 --- /dev/null +++ b/frontend/editor/src/core/routes/adminRouteExtensions.tsx @@ -0,0 +1,10 @@ +import type { ReactElement } from "react"; + +/** + * Admin-only route-set contributed by higher layers (the portal). The OSS core + * build ships none, so this stub returns an empty list and the portal chunk is + * never referenced in the core bundle. + */ +export function getAdminRouteExtensions(): ReactElement[] { + return []; +} diff --git a/frontend/editor/src/core/services/googleDrivePickerService.ts b/frontend/editor/src/core/services/googleDrivePickerService.ts index ac4dbd116d..9d91ffdeac 100644 --- a/frontend/editor/src/core/services/googleDrivePickerService.ts +++ b/frontend/editor/src/core/services/googleDrivePickerService.ts @@ -155,7 +155,7 @@ class GoogleDrivePickerService { return; } if (response.access_token == null) { - reject(new Error("No acces token in response")); + reject(new Error("No access token in response")); } this.accessToken = response.access_token; diff --git a/frontend/editor/src/core/tests/helpers/stub-test-base.ts b/frontend/editor/src/core/tests/helpers/stub-test-base.ts index 5752108d42..e6ae98c6f7 100644 --- a/frontend/editor/src/core/tests/helpers/stub-test-base.ts +++ b/frontend/editor/src/core/tests/helpers/stub-test-base.ts @@ -6,6 +6,7 @@ import { skipOnboarding, type MockAppApiOptions, } from "@app/tests/helpers/api-stubs"; +import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers"; /** * Custom Playwright fixture for backend-free specs. @@ -57,6 +58,7 @@ export const test = base.extend({ seedJwt: [false, { option: true }], page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => { + suppressNativeFilePicker(page); await seedCookieConsent(page); if (seedJwt) { // Logged-in users hit the orchestrator path that surfaces the diff --git a/frontend/editor/src/core/tests/helpers/test-base.ts b/frontend/editor/src/core/tests/helpers/test-base.ts index 1659bc3abd..90de9432f9 100644 --- a/frontend/editor/src/core/tests/helpers/test-base.ts +++ b/frontend/editor/src/core/tests/helpers/test-base.ts @@ -1,6 +1,7 @@ import { test as base, expect } from "@playwright/test"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { suppressNativeFilePicker } from "@app/tests/helpers/ui-helpers"; /** * Custom test fixture that: @@ -40,6 +41,7 @@ const COVERAGE_DIR = path.resolve( export const test = base.extend({ page: async ({ page }, use, testInfo) => { + suppressNativeFilePicker(page); await page.context().addCookies([ { name: "cc_cookie", diff --git a/frontend/editor/src/core/tests/helpers/ui-helpers.ts b/frontend/editor/src/core/tests/helpers/ui-helpers.ts index 7c08f93f60..353037c106 100644 --- a/frontend/editor/src/core/tests/helpers/ui-helpers.ts +++ b/frontend/editor/src/core/tests/helpers/ui-helpers.ts @@ -11,6 +11,37 @@ import { expect, type Page, type Locator } from "@playwright/test"; const MANTINE_MODAL_OVERLAY = ".mantine-Modal-overlay"; +/** + * Suppress the native OS file picker for the whole page, on every browser. + * + * Several upload entry points (the FileSidebar "Open from computer" button, + * the Mantine ``, AddFileCard, etc.) open a file dialog by clicking + * a hidden ``. On firefox/webkit Playwright only intercepts + * that dialog while the page has a `filechooser` listener - it toggles + * `Page.setInterceptFileChooserDialog` off the event subscription. With no + * listener the real OS picker leaks onto the host and hangs the nightly run. + * + * Registering a (no-op) `filechooser` listener flips that interception on for + * every browser, so the dialog is suppressed at the browser level however it + * was triggered - a programmatic `.click()`, a `
  • ))} @@ -87,25 +81,29 @@ export function ScenariosPanel({ agent }: ScenariosPanelProps) {
    - {t("agentBuilder.scenarios.addScenario")} + {t("portal.agentBuilder.scenarios.addScenario")}
    - + setName(e.target.value)} - placeholder={t("agentBuilder.scenarios.namePlaceholder")} + placeholder={t("portal.agentBuilder.scenarios.namePlaceholder")} /> - + setExpectation(e.target.value)} - placeholder={t("agentBuilder.scenarios.expectationPlaceholder")} + placeholder={t( + "portal.agentBuilder.scenarios.expectationPlaceholder", + )} />
    diff --git a/frontend/portal/src/components/agent-builder/ToolsPanel.stories.tsx b/frontend/editor/src/portal/components/agent-builder/ToolsPanel.stories.tsx similarity index 100% rename from frontend/portal/src/components/agent-builder/ToolsPanel.stories.tsx rename to frontend/editor/src/portal/components/agent-builder/ToolsPanel.stories.tsx diff --git a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/ToolsPanel.tsx similarity index 83% rename from frontend/portal/src/components/agent-builder/ToolsPanel.tsx rename to frontend/editor/src/portal/components/agent-builder/ToolsPanel.tsx index 81e0f57af4..4dde8ff54d 100644 --- a/frontend/portal/src/components/agent-builder/ToolsPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/ToolsPanel.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Chip, ToggleSwitch } from "@shared/components"; +import { Chip, ToggleSwitch } from "@app/ui"; import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -40,27 +40,27 @@ export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) { checked={restricted} onChange={setRestricted} disabled={!governanceUnlocked} - label={t("agentBuilder.tools.restrictedAccess")} + label={t("portal.agentBuilder.tools.restrictedAccess")} description={ governanceUnlocked - ? t("agentBuilder.tools.restrictedDescription") - : t("agentBuilder.tools.governanceGate") + ? t("portal.agentBuilder.tools.restrictedDescription") + : t("portal.agentBuilder.tools.governanceGate") } /> {restricted - ? t("agentBuilder.tools.restricted") - : t("agentBuilder.tools.broadAccess")} + ? t("portal.agentBuilder.tools.restricted") + : t("portal.agentBuilder.tools.broadAccess")} {restricted && (
    - {t("agentBuilder.tools.deniedTools")} + {t("portal.agentBuilder.tools.deniedTools")}

    - {t("agentBuilder.tools.deniedHint")} + {t("portal.agentBuilder.tools.deniedHint")}

    {TOOL_CATALOGUE.map((tool) => { diff --git a/frontend/portal/src/components/agent-builder/VersionsPanel.stories.tsx b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.stories.tsx similarity index 100% rename from frontend/portal/src/components/agent-builder/VersionsPanel.stories.tsx rename to frontend/editor/src/portal/components/agent-builder/VersionsPanel.stories.tsx diff --git a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx similarity index 91% rename from frontend/portal/src/components/agent-builder/VersionsPanel.tsx rename to frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx index b05e2fa7fc..88edc7993c 100644 --- a/frontend/portal/src/components/agent-builder/VersionsPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Button, StatusBadge } from "@shared/components"; +import { Button, StatusBadge } from "@app/ui"; import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; @@ -56,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {isCurrent && ( - {t("agentBuilder.versions.current")} + {t("portal.agentBuilder.versions.current")} )}
    @@ -72,7 +72,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="outline" onClick={() => publish(v.version)} > - {t("agentBuilder.versions.publish")} + {t("portal.agentBuilder.versions.publish")} )} {v.status === "published" && !isCurrent && ( @@ -81,7 +81,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { variant="ghost" onClick={() => rollback(v.version)} > - {t("agentBuilder.versions.rollBack")} + {t("portal.agentBuilder.versions.rollBack")} )}
    @@ -92,7 +92,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {!historyUnlocked && publishedExists && (

    - {t("agentBuilder.versions.historyGate")} + {t("portal.agentBuilder.versions.historyGate")}

    )} diff --git a/frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/EnterpriseUpsell.stories.tsx rename to frontend/editor/src/portal/components/billing/EnterpriseUpsell.stories.tsx diff --git a/frontend/portal/src/components/billing/EnterpriseUpsell.tsx b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx similarity index 74% rename from frontend/portal/src/components/billing/EnterpriseUpsell.tsx rename to frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx index 9ad3cbeae7..b303c530ff 100644 --- a/frontend/portal/src/components/billing/EnterpriseUpsell.tsx +++ b/frontend/editor/src/portal/components/billing/EnterpriseUpsell.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Button, Card } from "@shared/components"; +import { Button, Card } from "@app/ui"; interface Props { /** Render without the Card wrapper, to embed inside another card's column. */ @@ -15,23 +15,29 @@ export function EnterpriseUpsell({ bare = false }: Props) { const body = ( <> - {t("billing.enterpriseUpsell.eyebrow", "Volume discount · 1M+ PDFs")} + {t( + "portal.billing.enterpriseUpsell.eyebrow", + "Volume discount · 1M+ PDFs", + )}

    - {t("billing.enterpriseUpsell.title", "Stirling Enterprise")} + {t("portal.billing.enterpriseUpsell.title", "Stirling Enterprise")}

    {t( - "billing.enterpriseUpsell.description", + "portal.billing.enterpriseUpsell.description", "Committed volume discounts, air-gapped deployment, custom MSA and security reviews, and 3rd-party distributor partnerships.", )}

    {/* Destination wired when the enterprise/sales URL is confirmed. */}
    diff --git a/frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/FreePdfEditorsCard.stories.tsx rename to frontend/editor/src/portal/components/billing/FreePdfEditorsCard.stories.tsx diff --git a/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx similarity index 75% rename from frontend/portal/src/components/billing/FreePdfEditorsCard.tsx rename to frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx index 2744e26d08..77cbdbad5b 100644 --- a/frontend/portal/src/components/billing/FreePdfEditorsCard.tsx +++ b/frontend/editor/src/portal/components/billing/FreePdfEditorsCard.tsx @@ -1,12 +1,6 @@ import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { - Button, - Card, - MetricCard, - MetricStrip, - StatusBadge, -} from "@shared/components"; +import { Button, Card, MetricCard, MetricStrip, StatusBadge } from "@app/ui"; import GroupsIcon from "@mui/icons-material/GroupsRounded"; import PersonAddIcon from "@mui/icons-material/PersonAddAltRounded"; @@ -36,14 +30,17 @@ export function FreePdfEditorsCard() {

    - {t("billing.freeEditors.title", "Free PDF Editors")}{" "} + {t("portal.billing.freeEditors.title", "Free PDF Editors")}{" "} - {t("billing.freeEditors.previewBadge", "Preview · sample data")} + {t( + "portal.billing.freeEditors.previewBadge", + "Preview · sample data", + )}

    {t( - "billing.freeEditors.subtitle", + "portal.billing.freeEditors.subtitle", "Deploy anywhere, for your whole team.", )}

    @@ -51,22 +48,25 @@ export function FreePdfEditorsCard() {
    @@ -77,7 +77,7 @@ export function FreePdfEditorsCard() { leadingIcon={} onClick={() => navigate("/users?invite=1")} > - {t("billing.freeEditors.inviteTeammates", "Invite teammates")} + {t("portal.billing.freeEditors.inviteTeammates", "Invite teammates")} diff --git a/frontend/portal/src/components/billing/FreePlanView.stories.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/FreePlanView.stories.tsx rename to frontend/editor/src/portal/components/billing/FreePlanView.stories.tsx diff --git a/frontend/portal/src/components/billing/FreePlanView.tsx b/frontend/editor/src/portal/components/billing/FreePlanView.tsx similarity index 69% rename from frontend/portal/src/components/billing/FreePlanView.tsx rename to frontend/editor/src/portal/components/billing/FreePlanView.tsx index 02a5352a43..7132e6dfa1 100644 --- a/frontend/portal/src/components/billing/FreePlanView.tsx +++ b/frontend/editor/src/portal/components/billing/FreePlanView.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, StatusBadge } from "@shared/components"; +import { Banner, Button, StatusBadge } from "@app/ui"; import type { Wallet } from "@portal/api/billing"; +import type { LocalUsage } from "@portal/api/link"; import type { SaasCurrency } from "@portal/billing/stripe"; import { WalletMeter } from "@portal/components/billing/WalletMeter"; import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; @@ -10,8 +11,14 @@ import { StripeCheckoutModal } from "@portal/components/billing/StripeCheckoutMo interface Props { wallet: Wallet; - /** Called after checkout completes so the parent refetches the wallet. */ - onSubscribed?: () => void; + /** Instance-local usage not yet synced to SaaS; folded into the trial meter. */ + unsynced?: LocalUsage | null; + /** + * Runs the post-checkout activation poll and resolves true once the wallet + * reads subscribed (false if it's lagging past the poll window). The checkout + * modal awaits this to stay open through activation. + */ + onSubscribed?: () => Promise; } function isSaasCurrency(c: string | null): c is SaasCurrency { @@ -23,7 +30,7 @@ function isSaasCurrency(c: string | null): c is SaasCurrency { * editor fleet, the Processor trial meter (with the inline "Switch on the * Processor" CTA → embedded Stripe Checkout), and the Enterprise upsell. */ -export function FreePlanView({ wallet, onSubscribed }: Props) { +export function FreePlanView({ wallet, unsynced, onSubscribed }: Props) { const { t } = useTranslation(); const [modalOpen, setModalOpen] = useState(false); const [missingTeam, setMissingTeam] = useState(null); @@ -37,7 +44,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { if (wallet.teamId == null) { setMissingTeam( t( - "billing.freePlan.noTeamResolved", + "portal.billing.freePlan.noTeamResolved", "No team is resolved on your wallet yet — refresh and try again.", ), ); @@ -53,7 +60,10 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { onClick={openCheckout} disabled={wallet.teamId == null} > - {t("billing.freePlan.switchOnProcessor", "Switch on the Processor →")} + {t( + "portal.billing.freePlan.switchOnProcessor", + "Switch on the Processor →", + )} ) : null; @@ -62,20 +72,20 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { {/* Current plan */}
    - {t("billing.freePlan.currentPlan", "Current plan")} + {t("portal.billing.freePlan.currentPlan", "Current plan")}

    - {t("billing.freePlan.planName", "Editor")} + {t("portal.billing.freePlan.planName", "Editor")}

    - {t("billing.freePlan.freeForever", "Free forever")} + {t("portal.billing.freePlan.freeForever", "Free forever")} - {t("billing.freePlan.ssoIncluded", "SSO included")} + {t("portal.billing.freePlan.ssoIncluded", "SSO included")} - {t("billing.freePlan.unlimitedUsers", "Unlimited users")} + {t("portal.billing.freePlan.unlimitedUsers", "Unlimited users")}
    @@ -83,13 +93,17 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { {/* Processor trial — meter with the inline upgrade CTA */} - + {missingTeam && ( @@ -99,7 +113,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { {!isLeader && (

    {t( - "billing.freePlan.ownerOnly", + "portal.billing.freePlan.ownerOnly", "Only the team owner can switch on the Processor plan.", )}

    @@ -114,10 +128,7 @@ export function FreePlanView({ wallet, onSubscribed }: Props) { onClose={() => setModalOpen(false)} teamId={wallet.teamId} currency={currency} - onComplete={() => { - setModalOpen(false); - onSubscribed?.(); - }} + onComplete={() => onSubscribed?.() ?? Promise.resolve(false)} /> )} diff --git a/frontend/portal/src/components/billing/InvoicesList.tsx b/frontend/editor/src/portal/components/billing/InvoicesList.tsx similarity index 82% rename from frontend/portal/src/components/billing/InvoicesList.tsx rename to frontend/editor/src/portal/components/billing/InvoicesList.tsx index 8b13670148..1a9f015f5e 100644 --- a/frontend/portal/src/components/billing/InvoicesList.tsx +++ b/frontend/editor/src/portal/components/billing/InvoicesList.tsx @@ -8,8 +8,8 @@ import { StatusBadge, Table, type TableColumn, -} from "@shared/components"; -import { formatMinor, formatPeriodDate } from "@shared/billing"; +} from "@app/ui"; +import { formatMinor, formatPeriodDate } from "@app/billing"; import { fetchInvoices, type Invoice } from "@portal/api/billing"; const DEFAULT_VISIBLE = 5; @@ -83,13 +83,16 @@ export function InvoicesList() { const columns: TableColumn[] = [ { key: "date", - header: t("billing.invoices.columnDate", "Date"), + header: t("portal.billing.invoices.columnDate", "Date"), render: (inv) => inv.createdAt ? formatPeriodDate(inv.createdAt, { year: true }) : "—", }, { key: "pdfs", - header: t("billing.invoices.columnPdfsProcessed", "PDFs processed"), + header: t( + "portal.billing.invoices.columnPdfsProcessed", + "PDFs processed", + ), align: "right", // Billed units on the invoice's metered line item; "—" when the // line-item table isn't synced into the Stripe mirror. @@ -98,7 +101,7 @@ export function InvoicesList() { }, { key: "amount", - header: t("billing.invoices.columnAmount", "Amount"), + header: t("portal.billing.invoices.columnAmount", "Amount"), align: "right", render: (inv) => inv.totalMinor == null @@ -107,7 +110,7 @@ export function InvoicesList() { }, { key: "status", - header: t("billing.invoices.columnStatus", "Status"), + header: t("portal.billing.invoices.columnStatus", "Status"), render: (inv) => ( {inv.status} @@ -116,11 +119,11 @@ export function InvoicesList() { }, { key: "description", - header: t("billing.invoices.columnDescription", "Description"), + header: t("portal.billing.invoices.columnDescription", "Description"), render: (inv) => ( {inv.description ?? - t("billing.invoices.descriptionFallback", "Invoice")} + t("portal.billing.invoices.descriptionFallback", "Invoice")} ), }, @@ -137,14 +140,14 @@ export function InvoicesList() { target="_blank" rel="noopener noreferrer" aria-label={t( - "billing.invoices.viewAriaLabel", + "portal.billing.invoices.viewAriaLabel", "View invoice {{number}} in Stripe", { number: inv.number ?? inv.id, }, )} > - {t("billing.invoices.viewLink", "View ↗")} + {t("portal.billing.invoices.viewLink", "View ↗")} )} {inv.invoicePdf && ( @@ -154,14 +157,14 @@ export function InvoicesList() { target="_blank" rel="noopener noreferrer" aria-label={t( - "billing.invoices.downloadAriaLabel", + "portal.billing.invoices.downloadAriaLabel", "Download invoice {{number}} as PDF", { number: inv.number ?? inv.id, }, )} > - {t("billing.invoices.pdfLink", "PDF ↓")} + {t("portal.billing.invoices.pdfLink", "PDF ↓")} )} @@ -172,7 +175,7 @@ export function InvoicesList() { return (

    - {t("billing.invoices.title", "Invoice history")} + {t("portal.billing.invoices.title", "Invoice history")}

    {invoices === null && !error && ( @@ -186,7 +189,7 @@ export function InvoicesList() { {error && (

    {t( - "billing.invoices.loadError", + "portal.billing.invoices.loadError", "Couldn't load invoices: {{error}}", { error }, )} @@ -196,9 +199,9 @@ export function InvoicesList() { {invoices !== null && invoices.length === 0 && !error && ( @@ -221,24 +224,28 @@ export function InvoicesList() { > {showAll ? t( - "billing.invoices.showFewer", + "portal.billing.invoices.showFewer", "Show fewer (top {{count}})", { count: DEFAULT_VISIBLE }, ) : atFetchLimit ? t( - "billing.invoices.showMostRecent", + "portal.billing.invoices.showMostRecent", "Show {{count}} most recent", { count: total }, ) - : t("billing.invoices.showAll", "Show all {{count}}", { - count: total, - })} + : t( + "portal.billing.invoices.showAll", + "Show all {{count}}", + { + count: total, + }, + )} {showAll && atFetchLimit && ( {t( - "billing.invoices.fetchLimitNote", + "portal.billing.invoices.fetchLimitNote", "Showing your {{count}} most recent invoices. Older invoices are in the Stripe portal.", { count: FETCH_LIMIT }, )} diff --git a/frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/LinkAccountPrompt.stories.tsx rename to frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx diff --git a/frontend/portal/src/components/billing/LinkAccountPrompt.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx similarity index 76% rename from frontend/portal/src/components/billing/LinkAccountPrompt.tsx rename to frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx index df7429784e..522fcc17c3 100644 --- a/frontend/portal/src/components/billing/LinkAccountPrompt.tsx +++ b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@shared/components"; +import { Button, Card, EmptyState } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; /** @@ -14,14 +14,17 @@ export function LinkAccountPrompt() { openLinkModal()}> - {t("billing.linkPrompt.cta", "Link Stirling account")} + {t("portal.billing.linkPrompt.cta", "Link Stirling account")} } /> diff --git a/frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx b/frontend/editor/src/portal/components/billing/PaymentMethodCard.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/PaymentMethodCard.stories.tsx rename to frontend/editor/src/portal/components/billing/PaymentMethodCard.stories.tsx diff --git a/frontend/portal/src/components/billing/PaymentMethodCard.tsx b/frontend/editor/src/portal/components/billing/PaymentMethodCard.tsx similarity index 79% rename from frontend/portal/src/components/billing/PaymentMethodCard.tsx rename to frontend/editor/src/portal/components/billing/PaymentMethodCard.tsx index ee2a1a4f01..cd3b094a5e 100644 --- a/frontend/portal/src/components/billing/PaymentMethodCard.tsx +++ b/frontend/editor/src/portal/components/billing/PaymentMethodCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, Card } from "@shared/components"; +import { Button, Card } from "@app/ui"; import { fetchPaymentMethod, type PaymentMethod } from "@portal/api/billing"; interface Props { @@ -46,18 +46,18 @@ export function PaymentMethodCard({ onManage, managing }: Props) {

    - {t("billing.paymentMethod.eyebrow", "Payment method")} + {t("portal.billing.paymentMethod.eyebrow", "Payment method")} {hasCard ? ( <>

    {t( - "billing.paymentMethod.cardEnding", + "portal.billing.paymentMethod.cardEnding", "{{brand}} ending {{last4}}", { brand: titleCase( pm.brand ?? - t("billing.paymentMethod.cardFallback", "Card"), + t("portal.billing.paymentMethod.cardFallback", "Card"), ), last4: pm.last4, }, @@ -66,23 +66,29 @@ export function PaymentMethodCard({ onManage, managing }: Props) {

    {pm.expMonth != null && pm.expYear != null ? t( - "billing.paymentMethod.expiresBilledMonthly", + "portal.billing.paymentMethod.expiresBilledMonthly", "Expires {{expiry}} · billed monthly", { expiry: `${String(pm.expMonth).padStart(2, "0")}/${pm.expYear}`, }, ) - : t("billing.paymentMethod.billedMonthly", "Billed monthly")} + : t( + "portal.billing.paymentMethod.billedMonthly", + "Billed monthly", + )}

    ) : ( <>

    - {t("billing.paymentMethod.managedTitle", "Managed in Stripe")} + {t( + "portal.billing.paymentMethod.managedTitle", + "Managed in Stripe", + )}

    {t( - "billing.paymentMethod.managedSub", + "portal.billing.paymentMethod.managedSub", "Your card and billing details are kept securely in Stripe's customer portal.", )}

    @@ -95,7 +101,7 @@ export function PaymentMethodCard({ onManage, managing }: Props) { loading={managing} onClick={onManage} > - {t("billing.paymentMethod.update", "Update")} + {t("portal.billing.paymentMethod.update", "Update")}
    diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx similarity index 70% rename from frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx rename to frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx index eaf3172bec..6f4c46641c 100644 --- a/frontend/portal/src/components/billing/PdfsProcessedCard.stories.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.stories.tsx @@ -14,6 +14,20 @@ type Story = StoryObj; /** Metered PDFs split across API / Agents / Automation (real categoryBreakdown). */ export const WithBreakdown: Story = { args: { wallet: subscribedWallet } }; +/** Synced usage plus instance-local work not yet billed — headline + split combine, with a pending note. */ +export const WithUnsynced: Story = { + args: { + wallet: subscribedWallet, + unsynced: { + periodStart: subscribedWallet.billingPeriodStart, + apiUnsyncedUnits: 12, + aiUnsyncedUnits: 3, + automationUnsyncedUnits: 0, + totalUnsyncedUnits: 15, + }, + }, +}; + /** Nothing metered yet this period — the split hides. */ export const Empty: Story = { args: { diff --git a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx similarity index 62% rename from frontend/portal/src/components/billing/PdfsProcessedCard.tsx rename to frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx index ba5ac17ea9..00d9806e6a 100644 --- a/frontend/portal/src/components/billing/PdfsProcessedCard.tsx +++ b/frontend/editor/src/portal/components/billing/PdfsProcessedCard.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; -import { Card } from "@shared/components"; +import { Card } from "@app/ui"; import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing"; +import type { LocalUsage } from "@portal/api/link"; /** * "PDFs processed this period" headline + a stacked split of where the metered @@ -8,6 +9,11 @@ import type { Wallet, WalletCategoryBreakdown } from "@portal/api/billing"; * (API / Agents / Automation — the same buckets the entitlement service tracks; * the "AI" bucket surfaces as "Agents" here). Real data only: the bar hides when * nothing metered has run yet. + * + *

    When a linked instance has accrued usage SaaS hasn't billed yet ({@code + * unsynced}), it's folded into the headline + split so "current usage" reflects + * work done since the last daily sync. The synced-vs-pending split is an internal + * detail the customer doesn't need, so it's not surfaced — just the combined total. */ const SEGMENTS: ReadonlyArray<{ key: keyof WalletCategoryBreakdown; @@ -19,46 +25,64 @@ const SEGMENTS: ReadonlyArray<{ }> = [ { key: "api", - labelKey: "billing.pdfsProcessed.segmentApiLabel", + labelKey: "portal.billing.pdfsProcessed.segmentApiLabel", labelDefault: "API", - descKey: "billing.pdfsProcessed.segmentApiDesc", + descKey: "portal.billing.pdfsProcessed.segmentApiDesc", descDefault: "Direct API requests", cls: "blue", }, { key: "ai", - labelKey: "billing.pdfsProcessed.segmentAgentsLabel", + labelKey: "portal.billing.pdfsProcessed.segmentAgentsLabel", labelDefault: "Agents", - descKey: "billing.pdfsProcessed.segmentAgentsDesc", + descKey: "portal.billing.pdfsProcessed.segmentAgentsDesc", descDefault: "AI agent actions", cls: "purple", }, { key: "automation", - labelKey: "billing.pdfsProcessed.segmentAutomationLabel", + labelKey: "portal.billing.pdfsProcessed.segmentAutomationLabel", labelDefault: "Automation", - descKey: "billing.pdfsProcessed.segmentAutomationDesc", + descKey: "portal.billing.pdfsProcessed.segmentAutomationDesc", descDefault: "Automations & pipelines", cls: "teal", }, ]; -export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { +export function PdfsProcessedCard({ + wallet, + unsynced, +}: { + wallet: Wallet; + unsynced?: LocalUsage | null; +}) { const { t } = useTranslation(); - const b = wallet.categoryBreakdown; + // Fold instance-local unsynced usage into both the headline and the split, so + // the card shows synced + not-yet-billed work as a single current-usage figure. + const pending = unsynced?.totalUnsyncedUnits ?? 0; + const base = wallet.categoryBreakdown; + const b: WalletCategoryBreakdown = { + api: base.api + (unsynced?.apiUnsyncedUnits ?? 0), + ai: base.ai + (unsynced?.aiUnsyncedUnits ?? 0), + automation: base.automation + (unsynced?.automationUnsyncedUnits ?? 0), + }; const total = b.api + b.ai + b.automation; + const headline = wallet.billableUsed + pending; return ( - {t("billing.pdfsProcessed.eyebrow", "PDFs processed this period")} + {t( + "portal.billing.pdfsProcessed.eyebrow", + "PDFs processed this period", + )}

    - {wallet.billableUsed.toLocaleString()} + {headline.toLocaleString()} - {t("billing.pdfsProcessed.unit", "metered PDFs")} + {t("portal.billing.pdfsProcessed.unit", "metered PDFs")}
    @@ -68,7 +92,7 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { className="portal-billing__segbar" role="img" aria-label={t( - "billing.pdfsProcessed.segbarAriaLabel", + "portal.billing.pdfsProcessed.segbarAriaLabel", "Metered PDFs split by category", )} > @@ -94,7 +118,7 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { {t( - "billing.pdfsProcessed.legendValue", + "portal.billing.pdfsProcessed.legendValue", "{{formatted}} PDFs", { count: b[s.key], @@ -112,7 +136,7 @@ export function PdfsProcessedCard({ wallet }: { wallet: Wallet }) { ) : (

    {t( - "billing.pdfsProcessed.emptyPeriod", + "portal.billing.pdfsProcessed.emptyPeriod", "No metered processing yet this period.", )}

    diff --git a/frontend/portal/src/components/billing/SpendLimitCard.stories.tsx b/frontend/editor/src/portal/components/billing/SpendLimitCard.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/SpendLimitCard.stories.tsx rename to frontend/editor/src/portal/components/billing/SpendLimitCard.stories.tsx diff --git a/frontend/portal/src/components/billing/SpendLimitCard.tsx b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx similarity index 84% rename from frontend/portal/src/components/billing/SpendLimitCard.tsx rename to frontend/editor/src/portal/components/billing/SpendLimitCard.tsx index 2593810017..d117f3d9bc 100644 --- a/frontend/portal/src/components/billing/SpendLimitCard.tsx +++ b/frontend/editor/src/portal/components/billing/SpendLimitCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, Card } from "@shared/components"; +import { Banner, Button, Card } from "@app/ui"; import { currencySymbol, docCapForMoney, @@ -9,7 +9,7 @@ import { MeterBar, meterState, SpendCapControl as SharedSpendCapControl, -} from "@shared/billing"; +} from "@app/billing"; import type { Wallet } from "@portal/api/billing"; import { updateCap } from "@portal/api/billing"; @@ -127,10 +127,10 @@ export function SpendLimitCard({ return ( - {t("billing.spendLimit.eyebrow", "Spend limit")} + {t("portal.billing.spendLimit.eyebrow", "Spend limit")}

    - {t("billing.spendLimit.editTitle", "Set your monthly ceiling")} + {t("portal.billing.spendLimit.editTitle", "Set your monthly ceiling")}

    @@ -151,7 +151,7 @@ export function SpendLimitCard({ onClick={() => setDraftCap(proj.suggestedMajor)} > {t( - "billing.spendLimit.useSuggested", + "portal.billing.spendLimit.useSuggested", "Use suggested · {{amount}} / month", { amount: formatMoneyMajor(proj.suggestedMajor, wallet.currency), @@ -162,10 +162,10 @@ export function SpendLimitCard({
    - {t("billing.spendLimit.guardrailLabel", "Your guardrail:")} + {t("portal.billing.spendLimit.guardrailLabel", "Your guardrail:")} {" "} {t( - "billing.spendLimit.guardrailBody", + "portal.billing.spendLimit.guardrailBody", "a hard ceiling — you're never billed past it. At the cap, metered processing pauses (unlimited PDF editing keeps working) until you raise it or the cycle resets. Nothing is lost.", )}
    @@ -173,7 +173,10 @@ export function SpendLimitCard({ {error && ( {error} @@ -185,10 +188,10 @@ export function SpendLimitCard({ size="sm" onClick={() => onAdjustingChange(false)} > - {t("billing.spendLimit.cancel", "Cancel")} + {t("portal.billing.spendLimit.cancel", "Cancel")}
    @@ -209,11 +212,11 @@ export function SpendLimitCard({
    - {t("billing.spendLimit.eyebrow", "Spend limit")} + {t("portal.billing.spendLimit.eyebrow", "Spend limit")}

    {t( - "billing.spendLimit.displaySub", + "portal.billing.spendLimit.displaySub", "You're only billed for what you process automatically — never past the ceiling.", )}

    @@ -224,7 +227,7 @@ export function SpendLimitCard({ size="sm" onClick={() => onAdjustingChange(true)} > - {t("billing.spendLimit.adjustLimit", "Adjust limit")} + {t("portal.billing.spendLimit.adjustLimit", "Adjust limit")} )}
    @@ -240,18 +243,18 @@ export function SpendLimitCard({ capActive ? docEstimate != null ? t( - "billing.spendLimit.capSuffixWithDocs", + "portal.billing.spendLimit.capSuffixWithDocs", "/ month · ≈ {{documents}} documents", { documents: docEstimate.toLocaleString(), }, ) - : t("billing.spendLimit.capSuffix", "/ month") - : t("billing.spendLimit.noCap", "no cap") + : t("portal.billing.spendLimit.capSuffix", "/ month") + : t("portal.billing.spendLimit.noCap", "no cap") } statusLabel={ capActive - ? t("billing.spendLimit.pctUsed", "{{pct}}% used", { + ? t("portal.billing.spendLimit.pctUsed", "{{pct}}% used", { pct: Math.round(pct), }) : null @@ -262,7 +265,7 @@ export function SpendLimitCard({ <> {t( - "billing.spendLimit.usedThisMonth", + "portal.billing.spendLimit.usedThisMonth", "{{amount}} used this month", { amount: spentLabel, @@ -270,15 +273,19 @@ export function SpendLimitCard({ )} - {t("billing.spendLimit.remaining", "{{amount}} remaining", { - amount: formatMinor(remainingMinor, wallet.currency), - })} + {t( + "portal.billing.spendLimit.remaining", + "{{amount}} remaining", + { + amount: formatMinor(remainingMinor, wallet.currency), + }, + )} ) : ( {t( - "billing.spendLimit.thisPeriodUncapped", + "portal.billing.spendLimit.thisPeriodUncapped", "{{amount}} this period · uncapped", { amount: spentLabel, @@ -293,10 +300,13 @@ export function SpendLimitCard({ {proj && (

    - {t("billing.spendLimit.projection.label", "Projected to exceed.")} + {t( + "portal.billing.spendLimit.projection.label", + "Projected to exceed.", + )} {" "} {t( - "billing.spendLimit.projection.body", + "portal.billing.spendLimit.projection.body", "At {{rate}}/day you reach the cap in ~{{count}} days (~{{monthEnd}} month-end). Suggested limit ~{{suggested}}.", { count: proj.daysToCap, diff --git a/frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/SpendThisMonthCard.stories.tsx rename to frontend/editor/src/portal/components/billing/SpendThisMonthCard.stories.tsx diff --git a/frontend/portal/src/components/billing/SpendThisMonthCard.tsx b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx similarity index 86% rename from frontend/portal/src/components/billing/SpendThisMonthCard.tsx rename to frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx index 2a8baf2986..d4a969ae0c 100644 --- a/frontend/portal/src/components/billing/SpendThisMonthCard.tsx +++ b/frontend/editor/src/portal/components/billing/SpendThisMonthCard.tsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { Card } from "@shared/components"; -import { formatMinor } from "@shared/billing"; +import { Card } from "@app/ui"; +import { formatMinor } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; import { EnterpriseUpsell } from "@portal/components/billing/EnterpriseUpsell"; @@ -19,7 +19,7 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) { return ( - {t("billing.spendThisMonth.eyebrow", "Spend this month")} + {t("portal.billing.spendThisMonth.eyebrow", "Spend this month")}

    @@ -29,7 +29,7 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) {

    {rateLabel ? t( - "billing.spendThisMonth.processedWithRate", + "portal.billing.spendThisMonth.processedWithRate", "{{formattedCount}} PDFs processed, at {{rate}} each.", { count: wallet.billableUsed, @@ -38,7 +38,7 @@ export function SpendThisMonthCard({ wallet }: { wallet: Wallet }) { }, ) : t( - "billing.spendThisMonth.processed", + "portal.billing.spendThisMonth.processed", "{{formattedCount}} PDFs processed.", { count: wallet.billableUsed, diff --git a/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx new file mode 100644 index 0000000000..2b8048ebcc --- /dev/null +++ b/frontend/editor/src/portal/components/billing/StripeCheckoutModal.tsx @@ -0,0 +1,272 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Modal, Skeleton, Spinner } from "@app/ui"; +import { + EmbeddedCheckout, + EmbeddedCheckoutProvider, +} from "@stripe/react-stripe-js"; +import type { Stripe } from "@stripe/stripe-js"; +import { + createCheckoutSession, + getStripePublishableKey, + type SaasCurrency, +} from "@portal/billing/stripe"; + +interface Props { + open: boolean; + onClose: () => void; + /** Caller's resolved team id. The edge function needs it to scope checkout. */ + teamId: number; + /** "usd" | "eur" | "gbp" — the SaaS PAYG offering's supported set. */ + currency: SaasCurrency; + /** Optional billing email prefill (Stripe locks the field when set). */ + billingOwnerEmail?: string; + /** + * Fired when Stripe (or the mock continue button) signals payment success. + * Runs the caller's activation flow (poll the wallet until the subscription + * webhook lands) and resolves {@code true} once subscribed, {@code false} if + * it's taking longer than the poll window. The modal stays open and + * non-dismissable while this runs, so the admin watches activation through + * instead of the modal vanishing and needing a manual refresh. + */ + onComplete: () => Promise; +} + +/** + * Embedded Stripe Checkout, matching the SaaS web app's PAYG sign-up UX. We + * fetch a {@code client_secret} from the SaaS Supabase edge function then + * mount <EmbeddedCheckoutProvider> inline — no full-page redirect, the + * admin stays in the portal. + * + * If the team is already subscribed the edge function short-circuits to a + * Stripe Customer Portal URL; we open it in a new tab and close the modal. + */ +let stripePromise: Promise | null = null; +function loadStripeOnce(pk: string): Promise { + if (stripePromise === null) { + stripePromise = import("@stripe/stripe-js").then((m) => m.loadStripe(pk)); + } + return stripePromise; +} + +/** Payment done, waiting for the subscription webhook to activate the plan. */ +function CheckoutFinalizing() { + const { t } = useTranslation(); + return ( +

    + +

    + {t( + "portal.billing.checkout.finalizing.title", + "Activating your Processor plan...", + )} +

    +

    + {t( + "portal.billing.checkout.finalizing.body", + "Your payment went through. We're switching on metered processing across your linked instances - this usually takes a few seconds.", + )} +

    +

    + {t( + "portal.billing.checkout.finalizing.hint", + "Please keep this window open.", + )} +

    +
    + ); +} + +/** Webhook lagging past the poll window — dismissable "it'll appear shortly" notice. */ +function CheckoutActivationSlow({ onClose }: { onClose: () => void }) { + const { t } = useTranslation(); + return ( +
    +

    + {t("portal.billing.checkout.activationSlow.title", "Almost there")} +

    +

    + {t( + "portal.billing.checkout.activationSlow.body", + "Your payment succeeded, but activation is taking a little longer than usual. It'll switch on automatically - close this and it'll appear here shortly.", + )} +

    + +
    + ); +} + +export function StripeCheckoutModal({ + open, + onClose, + teamId, + currency, + billingOwnerEmail, + onComplete, +}: Props) { + const { t } = useTranslation(); + const [clientSecret, setClientSecret] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // "checkout" = Stripe form; "finalizing" = payment done, waiting for the plan to activate + // (non-dismissable); "activationSlow" = webhook lagging past the poll window (dismissable). + const [phase, setPhase] = useState< + "checkout" | "finalizing" | "activationSlow" + >("checkout"); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + const publishableKey = getStripePublishableKey(); + + // Mint the checkout session whenever the modal opens for a fresh team/currency. + useEffect(() => { + if (!open) { + // Reset on close so re-opening fetches a fresh session + starts at checkout. + setClientSecret(null); + setError(null); + setLoading(true); + setPhase("checkout"); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + createCheckoutSession({ + teamId, + currency, + successUrl: window.location.href, + cancelUrl: window.location.href, + billingOwnerEmail, + }) + .then((session) => { + if (cancelled) return; + if (session.alreadySubscribed && session.redirectUrl) { + // Team's already on PAYG — bounce them to the management portal + // instead of mounting a checkout iframe with no secret. + window.open(session.redirectUrl, "_blank", "noopener,noreferrer"); + onClose(); + return; + } + if (!session.clientSecret) { + setError( + t( + "portal.billing.checkout.noClientSecret", + "Edge function returned no client_secret.", + ), + ); + return; + } + setClientSecret(session.clientSecret); + }) + .catch((e) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, teamId, currency, billingOwnerEmail, onClose, t]); + + const stripe = publishableKey ? loadStripeOnce(publishableKey) : null; + const canRender = Boolean(stripe && clientSecret); + + // Payment succeeded — hold the modal open and run the caller's activation poll instead of + // closing. The parent swaps to the subscribed view on success (unmounting us); a lagging + // webhook drops us into the dismissable "almost there" state. + function handleStripeComplete() { + setPhase("finalizing"); + onComplete() + .then((activated) => { + if (!activated && mounted.current) setPhase("activationSlow"); + }) + .catch(() => { + if (mounted.current) setPhase("activationSlow"); + }); + } + + // Block dismissal while activation is in flight so a half-finished flow can't be abandoned; + // the X, backdrop, and Escape all route through this. + const dismissable = phase !== "finalizing"; + const handleClose = () => { + if (dismissable) onClose(); + }; + + return ( + + {phase === "finalizing" && } + + {phase === "activationSlow" && ( + + )} + + {phase === "checkout" && ( + <> + {!publishableKey && ( + + {t("portal.billing.checkout.notConfigured.bodyBefore", "Set")}{" "} + VITE_STRIPE_PUBLISHABLE_KEY{" "} + {t( + "portal.billing.checkout.notConfigured.bodyAfter", + "in the portal env to enable in-app checkout.", + )} + + )} + {publishableKey && error && ( + + {error} + + )} + {publishableKey && loading && !error && ( +
    + + +
    + )} + {publishableKey && canRender && stripe && clientSecret && ( + + + + )} + + )} +
    + ); +} diff --git a/frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx b/frontend/editor/src/portal/components/billing/SubscribedPlanView.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/SubscribedPlanView.stories.tsx rename to frontend/editor/src/portal/components/billing/SubscribedPlanView.stories.tsx diff --git a/frontend/portal/src/components/billing/SubscribedPlanView.tsx b/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx similarity index 80% rename from frontend/portal/src/components/billing/SubscribedPlanView.tsx rename to frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx index 6ccdbe7e8d..4d92eecf77 100644 --- a/frontend/portal/src/components/billing/SubscribedPlanView.tsx +++ b/frontend/editor/src/portal/components/billing/SubscribedPlanView.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button } from "@shared/components"; -import { meterState } from "@shared/billing"; +import { Banner, Button } from "@app/ui"; +import { meterState } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; +import type { LocalUsage } from "@portal/api/link"; import { useStripePortal } from "@portal/hooks/useStripePortal"; import { FreePdfEditorsCard } from "@portal/components/billing/FreePdfEditorsCard"; import { PdfsProcessedCard } from "@portal/components/billing/PdfsProcessedCard"; @@ -13,6 +14,8 @@ import { InvoicesList } from "@portal/components/billing/InvoicesList"; interface Props { wallet: Wallet; + /** Instance-local usage not yet synced to SaaS; folded into the PDFs-processed card. */ + unsynced?: LocalUsage | null; onWalletChange?: () => void; } @@ -30,7 +33,11 @@ interface Props { * page-header "Manage Payment" action and the payment card's "Update" button * deep-link there via {@link useStripePortal}. */ -export function SubscribedPlanView({ wallet, onWalletChange }: Props) { +export function SubscribedPlanView({ + wallet, + unsynced, + onWalletChange, +}: Props) { const { t } = useTranslation(); const [adjusting, setAdjusting] = useState(false); const portal = useStripePortal(wallet); @@ -57,11 +64,11 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) { title={ state === "DEGRADED" ? t( - "billing.subscribedPlan.capWarn.reachedTitle", + "portal.billing.subscribedPlan.capWarn.reachedTitle", "Monthly spend limit reached", ) : t( - "billing.subscribedPlan.capWarn.approachingTitle", + "portal.billing.subscribedPlan.capWarn.approachingTitle", "You're at {{pct}}% of your monthly spend limit", { pct: Math.round(pct), @@ -71,18 +78,21 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) { action={ isLeader ? ( ) : undefined } > {state === "DEGRADED" ? t( - "billing.subscribedPlan.capWarn.reachedBody", + "portal.billing.subscribedPlan.capWarn.reachedBody", "Metered processing is paused until you raise the limit or the cycle resets. Unlimited PDF editing keeps working.", ) : t( - "billing.subscribedPlan.capWarn.approachingBody", + "portal.billing.subscribedPlan.capWarn.approachingBody", "Raise it now so automated processing never pauses.", )} @@ -90,7 +100,7 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) { - +
    @@ -110,7 +120,7 @@ export function SubscribedPlanView({ wallet, onWalletChange }: Props) { diff --git a/frontend/portal/src/components/billing/WalletMeter.stories.tsx b/frontend/editor/src/portal/components/billing/WalletMeter.stories.tsx similarity index 100% rename from frontend/portal/src/components/billing/WalletMeter.stories.tsx rename to frontend/editor/src/portal/components/billing/WalletMeter.stories.tsx diff --git a/frontend/portal/src/components/billing/WalletMeter.tsx b/frontend/editor/src/portal/components/billing/WalletMeter.tsx similarity index 56% rename from frontend/portal/src/components/billing/WalletMeter.tsx rename to frontend/editor/src/portal/components/billing/WalletMeter.tsx index c8a2ed81de..081f6b2a32 100644 --- a/frontend/portal/src/components/billing/WalletMeter.tsx +++ b/frontend/editor/src/portal/components/billing/WalletMeter.tsx @@ -1,12 +1,15 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Card } from "@shared/components"; -import { formatMinor, MeterBar, meterState } from "@shared/billing"; +import { Card } from "@app/ui"; +import { formatMinor, MeterBar, meterState } from "@app/billing"; import type { Wallet } from "@portal/api/billing"; +import type { LocalUsage } from "@portal/api/link"; interface Props { /** A linked-free wallet. */ wallet: Wallet; + /** Instance-local usage not yet synced to SaaS; folded into "used" so the trial meter reflects work since the last sync. */ + unsynced?: LocalUsage | null; /** Optional top-right action (e.g. "Switch on the Processor"). */ action?: ReactNode; } @@ -16,10 +19,18 @@ interface Props { * grant. Uses the shared {@link MeterBar} (same `paygf-meter` structure as the * cloud plan page). The subscribed spend-vs-cap meter is a separate surface * ({@code SpendLimitCard}); this card is only the free face. + * + *

    Locally-accrued usage SaaS hasn't billed yet ({@code unsynced}) is folded + * into the used figure + remaining count so the trial depletes in step with the + * gate — which now also blocks against the pending local delta — instead of only + * moving after a daily sync. */ -export function WalletMeter({ wallet, action }: Props) { +export function WalletMeter({ wallet, unsynced, action }: Props) { const { t } = useTranslation(); - const { state, pct } = meterState(wallet.billableUsed, wallet.freeAllowance); + const pending = unsynced?.totalUnsyncedUnits ?? 0; + const used = wallet.billableUsed + pending; + const remaining = Math.max(0, wallet.freeRemaining - pending); + const { state, pct } = meterState(used, wallet.freeAllowance); const rate = wallet.pricePerDocMinor != null && wallet.pricePerDocMinor > 0 ? wallet.pricePerDocMinor @@ -27,7 +38,7 @@ export function WalletMeter({ wallet, action }: Props) { const title = rate != null ? t( - "billing.walletMeter.titleWithRate", + "portal.billing.walletMeter.titleWithRate", "Process {{allowance}} PDFs free, then {{rate}}/PDF", { count: wallet.freeAllowance, @@ -35,22 +46,26 @@ export function WalletMeter({ wallet, action }: Props) { rate: formatMinor(rate, wallet.currency), }, ) - : t("billing.walletMeter.title", "Process {{allowance}} PDFs free", { - count: wallet.freeAllowance, - allowance: wallet.freeAllowance.toLocaleString(), - }); + : t( + "portal.billing.walletMeter.title", + "Process {{allowance}} PDFs free", + { + count: wallet.freeAllowance, + allowance: wallet.freeAllowance.toLocaleString(), + }, + ); return (

    - {t("billing.walletMeter.eyebrow", "Processor trial")} + {t("portal.billing.walletMeter.eyebrow", "Processor trial")}

    {title}

    {t( - "billing.walletMeter.sub", + "portal.billing.walletMeter.sub", "Use the PDF Editor for free. Pay to process PDFs automatically.", )}

    @@ -61,9 +76,9 @@ export function WalletMeter({ wallet, action }: Props) { diff --git a/frontend/portal/src/components/billing/billing.css b/frontend/editor/src/portal/components/billing/billing.css similarity index 95% rename from frontend/portal/src/components/billing/billing.css rename to frontend/editor/src/portal/components/billing/billing.css index ee1f593833..63ea82d748 100644 --- a/frontend/portal/src/components/billing/billing.css +++ b/frontend/editor/src/portal/components/billing/billing.css @@ -122,6 +122,47 @@ gap: 0.75rem; } +/* Widen the checkout modal past the ~1000px iframe threshold where Stripe + Embedded Checkout flips from its single-column ("mobile") layout to the + two-column desktop one — matching the SaaS Plan page's UpgradeModal (1100px + cap → ~1056px iframe). Two-class selector so it beats .sui-modal--xl's + max-width regardless of stylesheet order. width:100% still shrinks it on + narrow viewports, where Stripe falls back to single column on its own. */ +.sui-modal.portal-billing__checkout-modal { + max-width: 1100px; +} + +/* Post-checkout activation state, shown inside the checkout modal while the + subscription webhook lands (and the "almost there" fallback if it lags). */ +.portal-billing__checkout-finalizing { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 0.75rem; + padding: 2.5rem 1.5rem; +} + +.portal-billing__checkout-status-title { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-1); + margin: 0.25rem 0 0; +} + +.portal-billing__checkout-status-body { + font-size: 0.9375rem; + color: var(--color-text-2); + margin: 0; + max-width: 32rem; +} + +.portal-billing__checkout-status-hint { + font-size: 0.8125rem; + color: var(--color-text-3); + margin: 0; +} + .portal-billing__error { color: var(--color-red, #b91c1c); font-size: 0.875rem; diff --git a/frontend/portal/src/components/billing/walletFixtures.ts b/frontend/editor/src/portal/components/billing/walletFixtures.ts similarity index 100% rename from frontend/portal/src/components/billing/walletFixtures.ts rename to frontend/editor/src/portal/components/billing/walletFixtures.ts diff --git a/frontend/portal/src/components/catalogue/ComponentCard.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentCard.stories.tsx similarity index 100% rename from frontend/portal/src/components/catalogue/ComponentCard.stories.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentCard.stories.tsx diff --git a/frontend/portal/src/components/catalogue/ComponentCard.tsx b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx similarity index 90% rename from frontend/portal/src/components/catalogue/ComponentCard.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentCard.tsx index 9c662d5b9b..07d3af75c1 100644 --- a/frontend/portal/src/components/catalogue/ComponentCard.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Card, Chip, StatusBadge } from "@shared/components"; +import { Card, Chip, StatusBadge } from "@app/ui"; import { type SdkComponent, MATURITY_META, @@ -30,7 +30,9 @@ export function ComponentCard({ className={"portal-components__card" + (unlocked ? "" : " is-locked")} role="button" tabIndex={0} - aria-label={t("catalogue.card.openAriaLabel", { name: component.name })} + aria-label={t("portal.catalogue.card.openAriaLabel", { + name: component.name, + })} onClick={() => onOpen(component)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -47,7 +49,7 @@ export function ComponentCard({ {!unlocked && ( 🔒 diff --git a/frontend/portal/src/components/catalogue/ComponentDetailModal.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.stories.tsx similarity index 100% rename from frontend/portal/src/components/catalogue/ComponentDetailModal.stories.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentDetailModal.stories.tsx diff --git a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx similarity index 79% rename from frontend/portal/src/components/catalogue/ComponentDetailModal.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx index 970d2fe392..1959e10b1d 100644 --- a/frontend/portal/src/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx @@ -9,7 +9,7 @@ import { StatTile, StatusBadge, Tabs, -} from "@shared/components"; +} from "@app/ui"; import { type SdkComponent, MATURITY_META, @@ -49,14 +49,14 @@ export function ComponentDetailModal({ ); } const tabs = TAB_KEYS.map((key) => ({ key, - label: t(`catalogue.detail.tabs.${key}`), + label: t(`portal.catalogue.detail.tabs.${key}`), })); const maturity = MATURITY_META[component.maturity]; @@ -92,7 +92,7 @@ export function ComponentDetailModal({ // publishable key scoped to this component. onClick={() => onClose()} > - {t("catalogue.detail.addToProject")} + {t("portal.catalogue.detail.addToProject")}
    ) : ( @@ -102,7 +102,7 @@ export function ComponentDetailModal({ // TODO(backend): route to the upgrade / contact-sales flow. onClick={() => onClose()} > - {t("catalogue.detail.upgradeToUnlock")} + {t("portal.catalogue.detail.upgradeToUnlock")} ) } @@ -110,8 +110,8 @@ export function ComponentDetailModal({ {!unlocked && ( here, booting the component against a demo document and the dev's publishable key. */} - {t("catalogue.detail.preview.badge")} + {t("portal.catalogue.detail.preview.badge")} - {t("catalogue.detail.preview.note")} + {t("portal.catalogue.detail.preview.note")}
    @@ -136,7 +136,7 @@ export function ComponentDetailModal({ activeKey={tab} onChange={setTab} variant="underline" - ariaLabel={t("catalogue.detail.tabsAriaLabel")} + ariaLabel={t("portal.catalogue.detail.tabsAriaLabel")} />
    @@ -154,25 +154,25 @@ export function ComponentDetailModal({
    0 - ? t("catalogue.detail.stats.freeQuotaValue", { + ? t("portal.catalogue.detail.stats.freeQuotaValue", { amount: component.pricing.freeQuota.toLocaleString(), }) - : t("catalogue.detail.stats.none") + : t("portal.catalogue.detail.stats.none") } />
    @@ -184,12 +184,12 @@ export function ComponentDetailModal({
    )} @@ -200,26 +200,26 @@ export function ComponentDetailModal({
    0 - ? t("catalogue.detail.stats.freeQuotaValue", { + ? t("portal.catalogue.detail.stats.freeQuotaValue", { amount: component.pricing.freeQuota.toLocaleString(), }) - : t("catalogue.detail.stats.none") + : t("portal.catalogue.detail.stats.none") } />

    - {t("catalogue.detail.pricing.note", { + {t("portal.catalogue.detail.pricing.note", { unit: component.pricing.unit, })}

    diff --git a/frontend/portal/src/components/catalogue/ComponentGrid.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentGrid.stories.tsx similarity index 100% rename from frontend/portal/src/components/catalogue/ComponentGrid.stories.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentGrid.stories.tsx diff --git a/frontend/portal/src/components/catalogue/ComponentGrid.tsx b/frontend/editor/src/portal/components/catalogue/ComponentGrid.tsx similarity index 100% rename from frontend/portal/src/components/catalogue/ComponentGrid.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentGrid.tsx diff --git a/frontend/portal/src/components/catalogue/ComponentPropsTable.stories.tsx b/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.stories.tsx similarity index 100% rename from frontend/portal/src/components/catalogue/ComponentPropsTable.stories.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentPropsTable.stories.tsx diff --git a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx b/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx similarity index 77% rename from frontend/portal/src/components/catalogue/ComponentPropsTable.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx index 0bb77cb47a..8ff5155b63 100644 --- a/frontend/portal/src/components/catalogue/ComponentPropsTable.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentPropsTable.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { Chip, Table, type TableColumn } from "@shared/components"; +import { Chip, Table, type TableColumn } from "@app/ui"; import type { ComponentProp } from "@portal/api/sdkComponents"; import "@portal/views/Components.css"; @@ -15,35 +15,35 @@ export function ComponentPropsTable({ props: rows }: ComponentPropsTableProps) { () => [ { key: "name", - header: t("catalogue.props.columns.name"), + header: t("portal.catalogue.props.columns.name"), render: (p) => ( {p.name} ), }, { key: "type", - header: t("catalogue.props.columns.type"), + header: t("portal.catalogue.props.columns.type"), render: (p) => ( {p.type} ), }, { key: "required", - header: t("catalogue.props.columns.required"), + header: t("portal.catalogue.props.columns.required"), render: (p) => p.required ? ( - {t("catalogue.props.required")} + {t("portal.catalogue.props.required")} ) : ( - {t("catalogue.props.optional")} + {t("portal.catalogue.props.optional")} ), }, { key: "description", - header: t("catalogue.props.columns.description"), + header: t("portal.catalogue.props.columns.description"), render: (p) => ( {p.description} ), diff --git a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx b/frontend/editor/src/portal/components/catalogue/ComponentsSummaryStrip.tsx similarity index 82% rename from frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx rename to frontend/editor/src/portal/components/catalogue/ComponentsSummaryStrip.tsx index ec46ad855f..ccb3965a5c 100644 --- a/frontend/portal/src/components/catalogue/ComponentsSummaryStrip.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentsSummaryStrip.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { MetricCard, MetricStrip } from "@shared/components"; +import { MetricCard, MetricStrip } from "@app/ui"; import type { ComponentsResponse } from "@portal/api/sdkComponents"; /** @@ -8,10 +8,10 @@ import type { ComponentsResponse } from "@portal/api/sdkComponents"; * Only values flow from the API. */ const KPI_LABEL_KEYS = [ - "catalogue.summary.componentsGa", - "catalogue.summary.inBeta", - "catalogue.summary.embedsThisMonth", - "catalogue.summary.componentSpendMtd", + "portal.catalogue.summary.componentsGa", + "portal.catalogue.summary.inBeta", + "portal.catalogue.summary.embedsThisMonth", + "portal.catalogue.summary.componentSpendMtd", ] as const; interface ComponentsSummaryStripProps { diff --git a/frontend/portal/src/components/docs/AuthenticationSection.stories.tsx b/frontend/editor/src/portal/components/docs/AuthenticationSection.stories.tsx similarity index 100% rename from frontend/portal/src/components/docs/AuthenticationSection.stories.tsx rename to frontend/editor/src/portal/components/docs/AuthenticationSection.stories.tsx diff --git a/frontend/portal/src/components/docs/AuthenticationSection.tsx b/frontend/editor/src/portal/components/docs/AuthenticationSection.tsx similarity index 65% rename from frontend/portal/src/components/docs/AuthenticationSection.tsx rename to frontend/editor/src/portal/components/docs/AuthenticationSection.tsx index 65b1d89599..e15e04592c 100644 --- a/frontend/portal/src/components/docs/AuthenticationSection.tsx +++ b/frontend/editor/src/portal/components/docs/AuthenticationSection.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Chip, CodeBlock } from "@shared/components"; +import { Chip, CodeBlock } from "@app/ui"; import { DocsSection } from "@portal/components/docs/DocsSection"; export function AuthenticationSection() { @@ -7,13 +7,13 @@ export function AuthenticationSection() { return (
    @@ -21,13 +21,13 @@ export function AuthenticationSection() { sk_live_ - {t("docs.authentication.liveKey")} + {t("portal.docs.authentication.liveKey")}
    sk_test_ - {t("docs.authentication.testKey")} + {t("portal.docs.authentication.testKey")}
    diff --git a/frontend/portal/src/components/docs/ComponentsSection.stories.tsx b/frontend/editor/src/portal/components/docs/ComponentsSection.stories.tsx similarity index 100% rename from frontend/portal/src/components/docs/ComponentsSection.stories.tsx rename to frontend/editor/src/portal/components/docs/ComponentsSection.stories.tsx diff --git a/frontend/portal/src/components/docs/ComponentsSection.tsx b/frontend/editor/src/portal/components/docs/ComponentsSection.tsx similarity index 81% rename from frontend/portal/src/components/docs/ComponentsSection.tsx rename to frontend/editor/src/portal/components/docs/ComponentsSection.tsx index 10a2349154..5ad8c4b502 100644 --- a/frontend/portal/src/components/docs/ComponentsSection.tsx +++ b/frontend/editor/src/portal/components/docs/ComponentsSection.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Card, Chip, CodeBlock } from "@shared/components"; +import { Card, Chip, CodeBlock } from "@app/ui"; import type { EmbedComponent } from "@portal/api/docs"; import { DocsSection } from "@portal/components/docs/DocsSection"; @@ -12,9 +12,9 @@ export function ComponentsSection({ return (
    {components.map((c) => ( @@ -31,7 +31,7 @@ export function ComponentsSection({
    +