mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd999144f0 | ||
|
|
f4ab39600d | ||
|
|
f703a67817 | ||
|
|
105af51100 | ||
|
|
57bf17d348 | ||
|
|
11df30b914 | ||
|
|
43162c40ad | ||
|
|
be57f11747 | ||
|
|
8ba8f69252 | ||
|
|
be97268a7c | ||
|
|
7bd3826178 | ||
|
|
17aa71850c | ||
|
|
20204f0ddc | ||
|
|
1df6a1759c | ||
|
|
b4f7b1d8a9 | ||
|
|
f881828cd8 | ||
|
|
16cfbc170e | ||
|
|
355d736487 | ||
|
|
f201aa5915 | ||
|
|
b69b787d63 | ||
|
|
e630d6697b | ||
|
|
a15e8227b4 | ||
|
|
12563050a6 | ||
|
|
1abd23cf94 | ||
|
|
11ba3814e5 | ||
|
|
675afe9b71 |
@@ -27,7 +27,6 @@ node_modules/
|
||||
**/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/editor/dist/
|
||||
frontend/dist-portal/
|
||||
frontend/editor/playwright-report/
|
||||
.npm/
|
||||
.yarn/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-desktop
|
||||
pkgver=2.14.0
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
|
||||
arch=('x86_64')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
|
||||
pkgname=stirling-pdf-server-bin
|
||||
pkgver=2.14.0
|
||||
pkgver=2.14.2
|
||||
pkgrel=1
|
||||
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
|
||||
arch=('any')
|
||||
|
||||
@@ -87,6 +87,21 @@ engine: &engine
|
||||
- Taskfile.yml
|
||||
- .taskfiles/engine.yml
|
||||
|
||||
# Files that can make the committed generated API models (frontend tool API
|
||||
# types + engine tool models) go stale: the Java tool surfaces they derive from,
|
||||
# the generators, the generated files themselves (to catch a hand-edit), and the
|
||||
# tasks that drive generation. Deliberately excludes the broad frontend/docker/
|
||||
# testing globs, so a CSS-only PR does not boot the backend to rebuild the spec.
|
||||
generated-models: &generated-models
|
||||
- *openapi
|
||||
- frontend/editor/scripts/generate-tool-api-types.mts
|
||||
- frontend/editor/src/core/types/toolApiTypes.ts
|
||||
- engine/scripts/generate_tool_models.py
|
||||
- engine/src/stirling/models/tool_models.py
|
||||
- .taskfiles/frontend.yml
|
||||
- .taskfiles/engine.yml
|
||||
- .github/workflows/check-generated-models.yml
|
||||
|
||||
licenses-frontend: &licenses-frontend
|
||||
- ".github/workflows/frontend-backend-licenses-update.yml"
|
||||
- "frontend/package.json"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# Validates the Python AI engine: regenerates tool models and runs the
|
||||
# engine quality gate (lint, type-check, format-check, tests). Called from
|
||||
# build.yml on PRs and merge_group; also runs directly on push to main as
|
||||
# a post-merge safety net.
|
||||
# Runs the engine quality gate (lint, type-check, format-check, tests). Called
|
||||
# from build.yml on PRs and merge_group; also runs directly on push to main as
|
||||
# a post-merge safety net. Freshness of the generated tool_models.py is checked
|
||||
# by the shared check-generated-models workflow.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -34,104 +34,9 @@ jobs:
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
- name: Regenerate tool models
|
||||
run: task engine:tool-models
|
||||
|
||||
- name: Verify tool models are up to date
|
||||
id: tool-models-check
|
||||
continue-on-error: true
|
||||
run: git diff --exit-code engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on tool models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Tool Models Check Failed',
|
||||
'',
|
||||
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
|
||||
'',
|
||||
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if tool models check failed
|
||||
if: steps.tool-models-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Tool Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated engine/src/stirling/models/tool_models.py"
|
||||
echo "is out of date with the Java OpenAPI spec and will"
|
||||
echo "need to be regenerated before it can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task engine:tool-models' to regenerate, then"
|
||||
echo "commit the updated file."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove tool models check comment on success
|
||||
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
run: task engine:check
|
||||
|
||||
@@ -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:
|
||||
@@ -165,6 +167,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise OAuth Playwright tests
|
||||
id: oauth-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-oauth.json
|
||||
run: task e2e:enterprise -- --grep "OAuth"
|
||||
- name: Stop backend + tear down OAuth Keycloak
|
||||
if: always()
|
||||
@@ -238,6 +242,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise SAML Playwright tests
|
||||
id: saml-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-saml.json
|
||||
run: task e2e:enterprise -- --grep "SAML"
|
||||
- name: Stop backend + tear down SAML Keycloak
|
||||
if: always()
|
||||
@@ -268,6 +274,8 @@ jobs:
|
||||
wait_for_backend
|
||||
- name: Run enterprise feature Playwright tests
|
||||
id: feature-tests
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results-feature.json
|
||||
run: task e2e:enterprise -- --grep "Enterprise license"
|
||||
- name: Print backend log on failure
|
||||
if: failure()
|
||||
@@ -280,10 +288,23 @@ jobs:
|
||||
run: |
|
||||
source /tmp/helpers.sh
|
||||
stop_backend
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcomes: a flaky test (passed on retry)
|
||||
# leaves its step green, so this is the only place it surfaces. Merges
|
||||
# all three phase reports (some may be absent if an earlier phase hard-
|
||||
# failed and skipped the rest). Emits ::warning:: annotations + a job
|
||||
# summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: >
|
||||
npx tsx editor/scripts/report-flaky-tests.mts
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-oauth.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-saml.json"
|
||||
"${{ github.workspace }}/frontend/playwright-report/results-feature.json"
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
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
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
docker-base: ${{ steps.changes.outputs.docker-base }}
|
||||
tauri: ${{ steps.changes.outputs.tauri }}
|
||||
engine: ${{ steps.changes.outputs.engine }}
|
||||
generated-models: ${{ steps.changes.outputs.generated-models }}
|
||||
proprietary: ${{ steps.changes.outputs.proprietary }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
@@ -171,6 +172,20 @@ jobs:
|
||||
uses: ./.github/workflows/ai-engine.yml
|
||||
secrets: inherit
|
||||
|
||||
# The generated frontend types and engine tool models are both derived from
|
||||
# the Java OpenAPI spec. This job regenerates and diffs them; it boots the
|
||||
# backend, so it is gated on the narrow generated-models filter (spec source,
|
||||
# generators, generated files, generation tasks) rather than the broad
|
||||
# frontend filter, so a CSS-only PR does not pay for a backend build.
|
||||
generated-models:
|
||||
if: needs.files-changed.outputs.generated-models == 'true'
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/check-generated-models.yml
|
||||
secrets: inherit
|
||||
|
||||
pre-commit:
|
||||
needs: [files-changed]
|
||||
permissions:
|
||||
@@ -202,6 +217,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-
|
||||
@@ -225,6 +243,7 @@ jobs:
|
||||
- test-build-docker-images
|
||||
- tauri-build
|
||||
- ai-engine
|
||||
- generated-models
|
||||
- pre-commit
|
||||
- dependency-review
|
||||
runs-on: ubuntu-latest
|
||||
@@ -250,6 +269,7 @@ jobs:
|
||||
test-build-docker-images=${{ needs.test-build-docker-images.result }}
|
||||
tauri-build=${{ needs.tauri-build.result }}
|
||||
ai-engine=${{ needs.ai-engine.result }}
|
||||
generated-models=${{ needs.generated-models.result }}
|
||||
pre-commit=${{ needs.pre-commit.result }}
|
||||
dependency-review=${{ needs.dependency-review.result }}
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
name: Check generated models
|
||||
|
||||
# Verifies the committed generated API models are still in sync with the Java
|
||||
# OpenAPI spec: the frontend tool API types
|
||||
# (frontend/editor/src/core/types/toolApiTypes.ts) and the engine tool
|
||||
# models (engine/src/stirling/models/tool_models.py). Regenerates both with the
|
||||
# single top-level `task tool-models` and fails if either committed file is
|
||||
# out of date. Called from build.yml when the backend Java, frontend, or engine
|
||||
# changes; also runs on push to main as a post-merge safety net.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generated-models:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up JDK 25
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "25"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@50e97c2cd7a37755bbfafc9c5b7cafaece252f6e # v6.1.0
|
||||
with:
|
||||
gradle-version: 9.6.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install Task
|
||||
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
|
||||
|
||||
# Rebuilds the OpenAPI spec from the current Java and regenerates both the
|
||||
# frontend types and the engine tool models from it.
|
||||
- name: Regenerate generated models
|
||||
run: task tool-models
|
||||
|
||||
- name: Verify generated models are up to date
|
||||
id: models-check
|
||||
continue-on-error: true
|
||||
run: |
|
||||
git diff --exit-code \
|
||||
frontend/editor/src/core/types/toolApiTypes.ts \
|
||||
engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on generated models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- generated-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Generated Models Check Failed',
|
||||
'',
|
||||
'The generated `frontend/editor/src/core/types/toolApiTypes.ts` and/or `engine/src/stirling/models/tool_models.py` are out of date with the Java OpenAPI spec and will need to be regenerated before they can be merged in.',
|
||||
'',
|
||||
'Run `task tool-models` to regenerate both, then commit the updated files.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if generated models check failed
|
||||
if: steps.models-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Generated Models Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "The generated frontend API types and/or engine tool"
|
||||
echo "models are out of date with the Java OpenAPI spec and"
|
||||
echo "will need to be regenerated before they can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task tool-models' to regenerate both, then"
|
||||
echo "commit the updated files."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove generated models check comment on success
|
||||
if: steps.models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- generated-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
@@ -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-<run_id>`.
|
||||
# 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
|
||||
|
||||
|
||||
@@ -62,7 +62,17 @@ jobs:
|
||||
# .test-state/playwright/coverage-pw/ for the post-process step
|
||||
# to aggregate. Chromium-only - other engines silently skip.
|
||||
PW_COVERAGE: "1"
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:live
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Generate JaCoCo report from e2e:live .exec
|
||||
if: always()
|
||||
id: live-coverage
|
||||
@@ -169,7 +179,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/
|
||||
|
||||
@@ -44,11 +44,22 @@ jobs:
|
||||
VITE_BUILD_FOR_PREVIEW: "1"
|
||||
run: task frontend:build
|
||||
- name: Run stubbed E2E tests (chromium)
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
run: task e2e:stubbed -- --workers=3
|
||||
- name: Flag flaky tests
|
||||
# Runs regardless of the test outcome: a flaky test (passed on retry)
|
||||
# leaves the step green, so this is the only place it surfaces. Emits
|
||||
# ::warning:: annotations + a job summary; never fails the job.
|
||||
if: always()
|
||||
working-directory: frontend
|
||||
run: npx tsx editor/scripts/report-flaky-tests.mts "$PLAYWRIGHT_JSON_OUTPUT_FILE"
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+22
-98
@@ -55,15 +55,6 @@ tasks:
|
||||
- editor/src/core/data/ogImageMap.json
|
||||
- editor/public/og-metadata.json
|
||||
|
||||
prepare:classifier-categories:
|
||||
internal: true
|
||||
run: when_changed
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source of truth"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
sources:
|
||||
- editor/src/proprietary/data/classificationTaxonomy.ts
|
||||
|
||||
prepare:
|
||||
desc: "Set up dev environment"
|
||||
run: when_changed
|
||||
@@ -74,7 +65,6 @@ tasks:
|
||||
vars: { MODE: '{{.MODE}}' }
|
||||
- prepare:icons
|
||||
- prepare:og
|
||||
- prepare:classifier-categories
|
||||
|
||||
# ============================================================
|
||||
# Development
|
||||
@@ -138,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
|
||||
# ============================================================
|
||||
@@ -215,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"
|
||||
@@ -273,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"
|
||||
@@ -355,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 }
|
||||
|
||||
@@ -372,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"
|
||||
@@ -392,7 +319,6 @@ tasks:
|
||||
- task: typecheck:scripts
|
||||
- task: typecheck:prototypes
|
||||
- task: typecheck:portal
|
||||
- task: typecheck:shared
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
@@ -411,28 +337,16 @@ tasks:
|
||||
cmds:
|
||||
- node editor/scripts/generate-og-metadata.mjs --check
|
||||
|
||||
classifier-categories:
|
||||
desc: "Regenerate the engine classifier categories JSON from the TS source"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts
|
||||
|
||||
classifier-categories:check:
|
||||
desc: "Fail if the committed classifier categories JSON is out of date"
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-classification-taxonomy.mts --check
|
||||
|
||||
check:all:
|
||||
desc: "Full CI quality gate"
|
||||
cmds:
|
||||
# Runs first, before prepare regenerates: guards the committed og-metadata.json /
|
||||
# ogImageMap.json that the Cloudflare Pages (plain `vite build`) deploy relies on.
|
||||
- task: og:check
|
||||
- task: classifier-categories:check
|
||||
- task: typecheck:all
|
||||
- task: lint
|
||||
- task: format:check
|
||||
- task: build
|
||||
- task: build:portal
|
||||
- task: test
|
||||
- task: storybook:build
|
||||
|
||||
@@ -444,7 +358,6 @@ tasks:
|
||||
desc: "Run tests"
|
||||
cmds:
|
||||
- task: test:editor
|
||||
- task: test:portal
|
||||
|
||||
test:editor:
|
||||
desc: "Run editor tests"
|
||||
@@ -452,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]
|
||||
@@ -489,6 +396,23 @@ tasks:
|
||||
# Code Generation
|
||||
# ============================================================
|
||||
|
||||
tool-models:
|
||||
desc: "Generate tool API types from the Java OpenAPI spec"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts
|
||||
sources:
|
||||
- editor/scripts/generate-tool-api-types.mts
|
||||
- ../SwaggerDoc.json
|
||||
generates:
|
||||
- editor/src/core/types/toolApiTypes.ts
|
||||
|
||||
tool-models:check:
|
||||
desc: "Fail if committed tool API types are out of date"
|
||||
deps: [install, ":backend:swagger"]
|
||||
cmds:
|
||||
- npx tsx editor/scripts/generate-tool-api-types.mts --spec ../SwaggerDoc.json --output editor/src/core/types/toolApiTypes.ts --check
|
||||
|
||||
licenses:generate:
|
||||
desc: "Generate frontend license report"
|
||||
deps: [install]
|
||||
@@ -502,7 +426,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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+13
-106
@@ -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
|
||||
# ============================================================
|
||||
@@ -288,6 +185,16 @@ tasks:
|
||||
- task: frontend:format:check
|
||||
- task: engine:format:check
|
||||
|
||||
# ============================================================
|
||||
# Code generation
|
||||
# ============================================================
|
||||
|
||||
tool-models:
|
||||
desc: "Generate all API models from the Java OpenAPI spec"
|
||||
cmds:
|
||||
- task: frontend:tool-models
|
||||
- task: engine:tool-models
|
||||
|
||||
# ============================================================
|
||||
# Quality Gate
|
||||
# ============================================================
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -18,11 +17,6 @@ import stirling.software.common.model.PdfMetadata;
|
||||
@Service
|
||||
public class PdfMetadataService {
|
||||
|
||||
/**
|
||||
* ({@code {category, docType, typeConfidence, tags}}). Written by the classify-and-tag tool.
|
||||
*/
|
||||
public static final String CLASSIFICATION_KEY = "StirlingPDFClassification";
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final String stirlingPDFLabel;
|
||||
private final UserServiceInterface userService;
|
||||
@@ -183,14 +177,4 @@ public class PdfMetadataService {
|
||||
}
|
||||
pdf.getDocumentInformation().setAuthor(author);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the document classifier's JSON result into the custom Info-dictionary field {@link
|
||||
* #CLASSIFICATION_KEY}, leaving all other metadata untouched.
|
||||
*/
|
||||
public void setClassificationMetadata(PDDocument pdf, String classificationJson) {
|
||||
PDDocumentInformation info = pdf.getDocumentInformation();
|
||||
info.setCustomMetadataValue(CLASSIFICATION_KEY, classificationJson);
|
||||
pdf.setDocumentInformation(info);
|
||||
}
|
||||
}
|
||||
|
||||
-29
@@ -305,23 +305,6 @@ public class GetInfoOnPDF {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Info-dictionary keys exposed above via typed getters; any other key in the dictionary is
|
||||
* surfaced as custom metadata (e.g. the classification policy's StirlingPDFClassification
|
||||
* entry).
|
||||
*/
|
||||
private static final java.util.Set<String> STANDARD_INFO_KEYS =
|
||||
java.util.Set.of(
|
||||
"Title",
|
||||
"Author",
|
||||
"Subject",
|
||||
"Keywords",
|
||||
"Producer",
|
||||
"Creator",
|
||||
"CreationDate",
|
||||
"ModDate",
|
||||
"Trapped");
|
||||
|
||||
private static ObjectNode extractMetadata(PDDocument document) {
|
||||
ObjectNode metadata = objectMapper.createObjectNode();
|
||||
|
||||
@@ -352,18 +335,6 @@ public class GetInfoOnPDF {
|
||||
if (modificationDate != null) {
|
||||
metadata.put("ModificationDate", modificationDate);
|
||||
}
|
||||
|
||||
// Surface custom Info-dictionary entries (anything beyond the
|
||||
// standard fields above) — e.g. StirlingPDFClassification
|
||||
for (String key : info.getMetadataKeys()) {
|
||||
if (STANDARD_INFO_KEYS.contains(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = info.getCustomMetadataValue(key);
|
||||
if (value != null && !value.isBlank()) {
|
||||
metadata.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error extracting metadata: {}", e.getMessage());
|
||||
|
||||
+34
-12
@@ -1,5 +1,7 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
@@ -17,20 +19,8 @@ public class PosterPdfRequest extends PDFFile {
|
||||
allowableValues = {"A4", "Letter", "A3", "A5", "Legal", "Tabloid"})
|
||||
private String pageSize = "A4";
|
||||
|
||||
@Schema(
|
||||
description = "Horizontal decimation factor (how many columns to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
private int xFactor = 2;
|
||||
|
||||
@Schema(
|
||||
description = "Vertical decimation factor (how many rows to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
private int yFactor = 2;
|
||||
|
||||
@Schema(
|
||||
@@ -38,4 +28,36 @@ public class PosterPdfRequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "false")
|
||||
private boolean rightToLeft = false;
|
||||
|
||||
@JsonProperty("xFactor")
|
||||
@Schema(
|
||||
description = "Horizontal decimation factor (how many columns to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
public int getXFactor() {
|
||||
return xFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("xFactor")
|
||||
public void setXFactor(int xFactor) {
|
||||
this.xFactor = xFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("yFactor")
|
||||
@Schema(
|
||||
description = "Vertical decimation factor (how many rows to split into)",
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "2",
|
||||
minimum = "1",
|
||||
maximum = "10")
|
||||
public int getYFactor() {
|
||||
return yFactor;
|
||||
}
|
||||
|
||||
@JsonProperty("yFactor")
|
||||
public void setYFactor(int yFactor) {
|
||||
this.yFactor = yFactor;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -29,7 +29,8 @@ public class AddPasswordRequest extends PDFFile {
|
||||
description = "The length of the encryption key",
|
||||
type = "integer",
|
||||
allowableValues = {"40", "128", "256"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
|
||||
defaultValue = "256")
|
||||
private int keyLength = 256;
|
||||
|
||||
@Schema(description = "Whether document assembly is prevented", defaultValue = "false")
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+113
-15
@@ -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").
|
||||
*
|
||||
* <p>Two calls:
|
||||
* <p>Calls:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
|
||||
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
|
||||
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
|
||||
* GET /api/v1/instance/entitlement}; what the local gate consults.
|
||||
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
|
||||
* cumulative units and returns the refreshed entitlement.
|
||||
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
|
||||
* /api/v1/instance/revoke-self}).
|
||||
* </ul>
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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<String> 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. */
|
||||
|
||||
+38
-2
@@ -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;
|
||||
* <p>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).
|
||||
*
|
||||
* <p>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<UsageSyncService> syncServiceProvider;
|
||||
|
||||
public AccountLinkController(AccountLinkService service) {
|
||||
public AccountLinkController(
|
||||
AccountLinkService service,
|
||||
LocalUsageService localUsageService,
|
||||
ObjectProvider<UsageSyncService> 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<LocalUsageService.LocalUsage> 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<Void> syncNow() {
|
||||
UsageSyncService sync = syncServiceProvider.getIfAvailable();
|
||||
if (sync == null) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
sync.syncNow();
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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, <b>separate</b> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+46
@@ -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").
|
||||
*
|
||||
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> 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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
+6
@@ -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<AccountLinkSyncState, Long> {}
|
||||
+28
-10
@@ -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 <b>billable</b> (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):
|
||||
*
|
||||
* <p>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.
|
||||
* <ul>
|
||||
* <li><b>AUTOMATION</b> — the automation marker header ({@link
|
||||
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
|
||||
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
|
||||
* <li><b>API</b> — an API-key authenticated tool call;
|
||||
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
|
||||
* </ul>
|
||||
*
|
||||
* <p>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 /<ctx>/api/v1/ai/** still classifies as billable.
|
||||
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
|
||||
String ctx = request.getContextPath();
|
||||
String path =
|
||||
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
|
||||
|
||||
+27
-24
@@ -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.
|
||||
*
|
||||
* <p>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 <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
|
||||
* allow").
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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<InstanceEntitlement> 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<DeviceCredential> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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. */
|
||||
|
||||
+38
-4
@@ -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.
|
||||
*
|
||||
* <p>The first five fields are what the <b>gate</b> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-16
@@ -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;
|
||||
* <li>Flag off → always allow (feature inert).
|
||||
* <li>Manual tool → always allow (manual tools are free, never metered).
|
||||
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
|
||||
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
|
||||
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, 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.
|
||||
* <li>Billable + linked + entitled → allow.
|
||||
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
|
||||
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
|
||||
* </ol>
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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<InstanceEntitlement> 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<InstanceEntitlement> entitlement) {
|
||||
Optional<InstanceEntitlement> 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;
|
||||
}
|
||||
}
|
||||
|
||||
+190
-12
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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.
|
||||
* <p>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<UsageMeterService> meterProvider;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public InstanceEntitlementInterceptor(
|
||||
InstanceEntitlementGate gate,
|
||||
EntitlementCache entitlementCache,
|
||||
ObjectProvider<UsageMeterService> 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<TempFile> temps = new ArrayList<>();
|
||||
try {
|
||||
List<FileSize> sizes = new ArrayList<>();
|
||||
List<String> hashes = new ArrayList<>();
|
||||
int fileCount = 0;
|
||||
for (List<MultipartFile> 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<String> hashes) {
|
||||
List<String> 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");
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -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.
|
||||
*
|
||||
* <p>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<BillingCategory, Long> 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);
|
||||
}
|
||||
}
|
||||
+73
@@ -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 <b>workflow
|
||||
* window</b>: 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.
|
||||
*
|
||||
* <p>{@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.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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<MeteredInputSignature, Long> {
|
||||
|
||||
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
|
||||
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
|
||||
LocalDateTime periodStart, String signature);
|
||||
}
|
||||
+105
@@ -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);
|
||||
}
|
||||
}
|
||||
+57
@@ -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<UsageCounter, Long> {
|
||||
|
||||
/**
|
||||
* 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<UsageCounter> 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<LocalDateTime> 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);
|
||||
}
|
||||
+115
@@ -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.
|
||||
*
|
||||
* <p>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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+189
@@ -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.
|
||||
*
|
||||
* <p>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<DeviceCredential> cred = credentialStore.get();
|
||||
if (cred.isEmpty()) {
|
||||
return; // not linked
|
||||
}
|
||||
List<LocalDateTime> 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<BillingCategory, Long> 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<BillingCategory, Long> 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
@@ -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.
|
||||
*
|
||||
* <p>Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
|
||||
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
|
||||
* billed.
|
||||
*
|
||||
* <p>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
|
||||
}
|
||||
+29
@@ -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}.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
+68
@@ -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 <em>identical</em> 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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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}.
|
||||
*
|
||||
* <p>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
|
||||
* <em>raw</em> per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not
|
||||
* per-file-clamped, units so the group cap can actually bind).
|
||||
*
|
||||
* <p>{@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<FileSize> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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.
|
||||
*
|
||||
* <p>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");
|
||||
}
|
||||
}
|
||||
}
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
import stirling.software.proprietary.classification.model.TaxonomyValidator;
|
||||
import stirling.software.proprietary.classification.store.TaxonomyStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
/**
|
||||
* Read/write the caller's team classification taxonomy — the vocabulary the document classifier
|
||||
* runs against. Team-scoped exactly like policies: every user reads their own team's taxonomy, and
|
||||
* only a user who may edit policies (a team leader on SaaS, the global admin self-hosted; see
|
||||
* {@link PolicyManagementAuthority}) may change it. Editing is gated only when login is enabled;
|
||||
* single-user deployments trust the local operator. A team with no stored taxonomy reads as {@code
|
||||
* 204} and the classifier falls back to the engine's built-in default.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/classification/taxonomy")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "Classification", description = "Team-scoped document-classification taxonomy")
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class TaxonomyController {
|
||||
|
||||
private final TaxonomyStore taxonomyStore;
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "Get the team's classification taxonomy",
|
||||
description =
|
||||
"Returns the caller's team taxonomy, or 204 when the team has none (the"
|
||||
+ " classifier then uses the built-in default).")
|
||||
public ResponseEntity<ClassificationTaxonomy> getTaxonomy() {
|
||||
return taxonomyStore
|
||||
.findByTeam(currentTeamId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Save the team's classification taxonomy",
|
||||
description =
|
||||
"Validates and stores the taxonomy for the caller's team, shared by everyone on"
|
||||
+ " the team. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<ClassificationTaxonomy> saveTaxonomy(
|
||||
@RequestBody ClassificationTaxonomy taxonomy) {
|
||||
requireEditingAllowed();
|
||||
try {
|
||||
TaxonomyValidator.validate(taxonomy);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage());
|
||||
}
|
||||
ClassificationTaxonomy saved =
|
||||
taxonomyStore.save(currentTeamId(), taxonomy, currentUsername());
|
||||
return ResponseEntity.ok(saved);
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Reset the team's classification taxonomy",
|
||||
description =
|
||||
"Removes the team's stored taxonomy so the classifier falls back to the built-in"
|
||||
+ " default. Requires the policy-editor role for the team.")
|
||||
public ResponseEntity<Void> resetTaxonomy() {
|
||||
requireEditingAllowed();
|
||||
taxonomyStore.deleteByTeam(currentTeamId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Editing the taxonomy requires the editor role for the caller's team — the same gate policies
|
||||
* use (team leader on SaaS, global admin self-hosted). Single-user deployments (login disabled)
|
||||
* have no such role, so they trust the local operator.
|
||||
*/
|
||||
private void requireEditingAllowed() {
|
||||
if (!applicationProperties.getSecurity().isEnableLogin()) {
|
||||
return;
|
||||
}
|
||||
if (!policyManagementAuthority.canEditPolicies()) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.FORBIDDEN,
|
||||
"The classification taxonomy may only be changed by a team leader");
|
||||
}
|
||||
}
|
||||
|
||||
private Long currentTeamId() {
|
||||
return policyManagementAuthority.currentUserTeamId();
|
||||
}
|
||||
|
||||
private String currentUsername() {
|
||||
return userService == null ? null : userService.getCurrentUsername();
|
||||
}
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The vocabulary a document is classified against — team-scoped and admin-editable. Its shape
|
||||
* mirrors the engine's {@code ClassificationTaxonomy} contract (categories owning doc_types, plus
|
||||
* free-standing cross-cutting tags), so a stored taxonomy is passed to the engine verbatim as the
|
||||
* per-request override. When a team has no stored taxonomy the engine falls back to its built-in
|
||||
* default.
|
||||
*/
|
||||
public record ClassificationTaxonomy(List<TaxonomyCategory> categories, List<String> tags) {
|
||||
|
||||
public ClassificationTaxonomy {
|
||||
categories = categories == null ? List.of() : List.copyOf(categories);
|
||||
tags = tags == null ? List.of() : List.copyOf(tags);
|
||||
}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A structural family of documents, owning the doc_types shaped like it. {@code docTypes} is
|
||||
* serialized in the engine's camelCase shape (the engine's {@code ClassificationTaxonomy} model
|
||||
* aliases {@code doc_types} onto it), so a stored taxonomy passes straight through to the engine.
|
||||
*/
|
||||
public record TaxonomyCategory(String id, String label, List<TaxonomyDocumentType> docTypes) {
|
||||
|
||||
public TaxonomyCategory {
|
||||
docTypes = docTypes == null ? List.of() : List.copyOf(docTypes);
|
||||
}
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
/**
|
||||
* A specific instrument within a category (e.g. {@code nda} under {@code contract}).
|
||||
* Category-scoped: the engine enforces that a doc_type can only apply to its owning category.
|
||||
*/
|
||||
public record TaxonomyDocumentType(String id, String label) {}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Structural validation for an admin-supplied (or imported) taxonomy, run before it is stored so a
|
||||
* malformed vocabulary can never reach the classifier. Mirrors the invariants the engine relies on:
|
||||
* at least one category, non-blank ids/labels everywhere, ids unique among categories and among the
|
||||
* doc_types within a category, and non-blank unique tags.
|
||||
*/
|
||||
public final class TaxonomyValidator {
|
||||
|
||||
private TaxonomyValidator() {}
|
||||
|
||||
// Generous upper bounds so a legitimate taxonomy is never blocked, but a single team can't
|
||||
// store an unbounded blob that would bloat the row, balloon the classifier prompt, or exhaust
|
||||
// memory on deserialize.
|
||||
static final int MAX_CATEGORIES = 200;
|
||||
static final int MAX_DOC_TYPES_PER_CATEGORY = 200;
|
||||
static final int MAX_TAGS = 500;
|
||||
static final int MAX_TEXT_LENGTH = 128;
|
||||
|
||||
/**
|
||||
* @throws IllegalArgumentException with a human-readable message when the taxonomy is invalid.
|
||||
*/
|
||||
public static void validate(ClassificationTaxonomy taxonomy) {
|
||||
if (taxonomy == null) {
|
||||
throw new IllegalArgumentException("Taxonomy is required");
|
||||
}
|
||||
if (taxonomy.categories().isEmpty()) {
|
||||
throw new IllegalArgumentException("Taxonomy must have at least one category");
|
||||
}
|
||||
if (taxonomy.categories().size() > MAX_CATEGORIES) {
|
||||
throw new IllegalArgumentException("Too many categories (max " + MAX_CATEGORIES + ")");
|
||||
}
|
||||
if (taxonomy.tags().size() > MAX_TAGS) {
|
||||
throw new IllegalArgumentException("Too many tags (max " + MAX_TAGS + ")");
|
||||
}
|
||||
Set<String> categoryIds = new HashSet<>();
|
||||
for (TaxonomyCategory category : taxonomy.categories()) {
|
||||
requireText(category.id(), "Category id");
|
||||
requireText(category.label(), "Category label");
|
||||
if (category.docTypes().size() > MAX_DOC_TYPES_PER_CATEGORY) {
|
||||
throw new IllegalArgumentException(
|
||||
"Too many sub-categories in '"
|
||||
+ category.id()
|
||||
+ "' (max "
|
||||
+ MAX_DOC_TYPES_PER_CATEGORY
|
||||
+ ")");
|
||||
}
|
||||
if (!categoryIds.add(category.id())) {
|
||||
throw new IllegalArgumentException("Duplicate category id: " + category.id());
|
||||
}
|
||||
Set<String> docTypeIds = new HashSet<>();
|
||||
for (TaxonomyDocumentType docType : category.docTypes()) {
|
||||
requireText(docType.id(), "Doc type id");
|
||||
requireText(docType.label(), "Doc type label");
|
||||
if (!docTypeIds.add(docType.id())) {
|
||||
throw new IllegalArgumentException(
|
||||
"Duplicate doc type id '"
|
||||
+ docType.id()
|
||||
+ "' in category '"
|
||||
+ category.id()
|
||||
+ "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
Set<String> tags = new HashSet<>();
|
||||
for (String tag : taxonomy.tags()) {
|
||||
requireText(tag, "Tag");
|
||||
if (!tags.add(tag)) {
|
||||
throw new IllegalArgumentException("Duplicate tag: " + tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
if (value.length() > MAX_TEXT_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
field + " is too long (max " + MAX_TEXT_LENGTH + " characters)");
|
||||
}
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
/**
|
||||
* In-memory {@link TaxonomyStore} for tests and any future no-database mode. {@link
|
||||
* JpaTaxonomyStore} is the runtime bean.
|
||||
*/
|
||||
public class InProcessTaxonomyStore implements TaxonomyStore {
|
||||
|
||||
private final Map<Long, ClassificationTaxonomy> byTeam = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
|
||||
return Optional.ofNullable(byTeam.get(key(teamId)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationTaxonomy save(
|
||||
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
|
||||
byTeam.put(key(teamId), taxonomy);
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
return byTeam.remove(key(teamId)) != null;
|
||||
}
|
||||
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Durable {@link TaxonomyStore} backed by JPA; the runtime store. Gated on {@code policies.enabled}
|
||||
* — a team taxonomy only matters when the Classification policy can run — so it shares the policy
|
||||
* subsystem's on/off switch. The taxonomy is persisted as JSON via {@link TaxonomyEntity}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaTaxonomyStore implements TaxonomyStore {
|
||||
|
||||
private final TaxonomyRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Override
|
||||
public Optional<ClassificationTaxonomy> findByTeam(Long teamId) {
|
||||
Optional<TaxonomyEntity> entity = repository.findById(key(teamId));
|
||||
if (entity.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(
|
||||
objectMapper.readValue(
|
||||
entity.get().getTaxonomyJson(), ClassificationTaxonomy.class));
|
||||
} catch (JacksonException e) {
|
||||
// A stored taxonomy that no longer parses (corruption / manual DB edit) must not break
|
||||
// classification: drop it so the caller falls back to the built-in default rather than
|
||||
// surfacing a 500 on every upload for the team.
|
||||
log.warn(
|
||||
"Discarding unparseable stored taxonomy for team {}: {}",
|
||||
teamId,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClassificationTaxonomy save(
|
||||
Long teamId, ClassificationTaxonomy taxonomy, String updatedBy) {
|
||||
TaxonomyEntity entity = new TaxonomyEntity();
|
||||
entity.setTeamId(key(teamId));
|
||||
entity.setTaxonomyJson(objectMapper.writeValueAsString(taxonomy));
|
||||
entity.setUpdatedAt(Instant.now());
|
||||
entity.setUpdatedBy(updatedBy);
|
||||
repository.save(entity);
|
||||
return taxonomy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean deleteByTeam(Long teamId) {
|
||||
long id = key(teamId);
|
||||
if (!repository.existsById(id)) {
|
||||
return false;
|
||||
}
|
||||
repository.deleteById(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Map the nullable team id onto the entity's non-null key (sentinel for the unteamed case). */
|
||||
private static long key(Long teamId) {
|
||||
return teamId == null ? TaxonomyEntity.NO_TEAM : teamId;
|
||||
}
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* JPA row for a team's classification taxonomy — one row per team. The taxonomy lives as JSON in
|
||||
* {@code taxonomyJson} (authoritative on read). {@code teamId} is the natural key; the sentinel
|
||||
* {@link #NO_TEAM} stands in for the unteamed (login-disabled / self-hosted single-team) case,
|
||||
* since a primary key can't be null (policies store a nullable {@code team_id}, but this table is
|
||||
* keyed one-per-team). Kept decoupled from the security entities — {@code teamId} is a plain value,
|
||||
* not a foreign key — so classification can be enabled or disabled without touching them.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "classification_taxonomies")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class TaxonomyEntity implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Sentinel key for the unteamed taxonomy (login disabled / no resolvable team). */
|
||||
public static final long NO_TEAM = 0L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private long teamId;
|
||||
|
||||
@Column(name = "taxonomy_json", columnDefinition = "text")
|
||||
private String taxonomyJson;
|
||||
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@Column(name = "updated_by")
|
||||
private String updatedBy;
|
||||
}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface TaxonomyRepository extends JpaRepository<TaxonomyEntity, Long> {}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package stirling.software.proprietary.classification.store;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
|
||||
/**
|
||||
* Stores one {@link ClassificationTaxonomy} per team. A {@code null} teamId addresses the unteamed
|
||||
* taxonomy (login disabled / no resolvable team), mirroring how the policy store treats a null
|
||||
* team.
|
||||
*/
|
||||
public interface TaxonomyStore {
|
||||
|
||||
/** The team's stored taxonomy, or empty when it has none (callers fall back to the default). */
|
||||
Optional<ClassificationTaxonomy> findByTeam(Long teamId);
|
||||
|
||||
/** Create or replace the team's taxonomy. Returns the stored value. */
|
||||
ClassificationTaxonomy save(Long teamId, ClassificationTaxonomy taxonomy, String updatedBy);
|
||||
|
||||
/** Remove the team's taxonomy (reset to default). Returns whether one existed. */
|
||||
boolean deleteByTeam(Long teamId);
|
||||
}
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
import stirling.software.proprietary.classification.store.TaxonomyStore;
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Dispatchable tool that classifies a PDF and writes the result into its metadata.
|
||||
*
|
||||
* <p>Runs as a Classification-policy pipeline step: it reads a bounded page window, asks the AI
|
||||
* engine to classify the document, and stores the engine's JSON answer — minus the transport-only
|
||||
* {@code outcome} field — in the custom Info-dictionary key {@link
|
||||
* PdfMetadataService#CLASSIFICATION_KEY}. Returns the tagged PDF. Not intended for direct client
|
||||
* use.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/ai/tools")
|
||||
@Tag(name = "AI Tools", description = "Dispatchable AI-backed tools.")
|
||||
public class ClassifyTagController {
|
||||
|
||||
/** Pages read from each end of the document — mirrors the engine's window. */
|
||||
private static final int WINDOW_PAGES = 2;
|
||||
|
||||
private static final String CLASSIFY_ENDPOINT = "/api/v1/documents/classify";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PdfContentExtractor pdfContentExtractor;
|
||||
private final PdfMetadataService pdfMetadataService;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* Present only when the policy subsystem is enabled ({@code policies.enabled}); the store and
|
||||
* team authority are gated on it. Null otherwise, in which case classification falls back to
|
||||
* the engine's built-in default taxonomy.
|
||||
*/
|
||||
private final TaxonomyStore taxonomyStore;
|
||||
|
||||
private final PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
public ClassifyTagController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
ObjectMapper objectMapper,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) TaxonomyStore taxonomyStore,
|
||||
@Autowired(required = false) PolicyManagementAuthority policyManagementAuthority) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.tempFileManager = tempFileManager;
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.objectMapper = objectMapper;
|
||||
this.userService = userService;
|
||||
this.taxonomyStore = taxonomyStore;
|
||||
this.policyManagementAuthority = policyManagementAuthority;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/classify-and-tag", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Classify a PDF and tag its metadata",
|
||||
description =
|
||||
"Reads the first two and last two pages, classifies the document via the AI"
|
||||
+ " engine, and stores the result in the StirlingPDFClassification"
|
||||
+ " metadata field. Dispatched by the Classification policy; not"
|
||||
+ " intended for direct client use.")
|
||||
public ResponseEntity<Resource> classifyAndTag(
|
||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||
|
||||
List<AiPageText> pages = extractWindow(document);
|
||||
String requestBody =
|
||||
objectMapper.writeValueAsString(
|
||||
new ClassifyEngineRequest(fileName, pages, resolveTaxonomyOverride()));
|
||||
|
||||
String userId = userService != null ? userService.getCurrentUsername() : null;
|
||||
String responseJson = aiEngineClient.post(CLASSIFY_ENDPOINT, requestBody, userId);
|
||||
|
||||
pdfMetadataService.setClassificationMetadata(document, toMetadataValue(responseJson));
|
||||
log.debug("[classify-and-tag] tagged {} ({} window pages)", fileName, pages.size());
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(document, fileName, tempFileManager);
|
||||
}
|
||||
}
|
||||
|
||||
private List<AiPageText> extractWindow(PDDocument document) throws IOException {
|
||||
List<AiPageText> pages = new ArrayList<>();
|
||||
for (int pageNumber : windowPageNumbers(document.getNumberOfPages(), WINDOW_PAGES)) {
|
||||
String text = pdfContentExtractor.extractPageTextRaw(document, pageNumber);
|
||||
if (text != null && !text.isBlank()) {
|
||||
pages.add(new AiPageText(pageNumber, text));
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
/** First and last {@code window} page numbers (1-based), de-duplicated and in order. */
|
||||
static List<Integer> windowPageNumbers(int pageCount, int window) {
|
||||
Set<Integer> numbers = new LinkedHashSet<>();
|
||||
for (int page = 1; page <= Math.min(window, pageCount); page++) {
|
||||
numbers.add(page);
|
||||
}
|
||||
for (int page = Math.max(1, pageCount - window + 1); page <= pageCount; page++) {
|
||||
numbers.add(page);
|
||||
}
|
||||
return new ArrayList<>(numbers);
|
||||
}
|
||||
|
||||
/** Drop the transport-only {@code outcome} discriminator; keep the rest verbatim. */
|
||||
private String toMetadataValue(String engineResponseJson) {
|
||||
JsonNode node = objectMapper.readTree(engineResponseJson);
|
||||
if (node instanceof ObjectNode object) {
|
||||
object.remove("outcome");
|
||||
}
|
||||
return objectMapper.writeValueAsString(node);
|
||||
}
|
||||
|
||||
private static String safeFileName(String originalFilename) {
|
||||
String name = Filenames.toSimpleFileName(originalFilename);
|
||||
return (name == null || name.isBlank()) ? "classified.pdf" : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the caller's team taxonomy and return it in the engine's shape to classify against;
|
||||
* {@code null} falls back to the engine's generated default. The stored taxonomy is already in
|
||||
* the engine's camelCase shape ({@code categories}/{@code docTypes}/{@code tags}), so it is
|
||||
* passed through verbatim. Returns null when the policy subsystem is disabled (no store), when
|
||||
* the team has no stored taxonomy, or when the team can't be resolved.
|
||||
*/
|
||||
private JsonNode resolveTaxonomyOverride() {
|
||||
if (taxonomyStore == null) {
|
||||
return null;
|
||||
}
|
||||
Long teamId =
|
||||
policyManagementAuthority == null
|
||||
? null
|
||||
: policyManagementAuthority.currentUserTeamId();
|
||||
return taxonomyStore
|
||||
.findByTeam(teamId)
|
||||
.map(taxonomy -> (JsonNode) objectMapper.valueToTree(taxonomy))
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/** Request body for the engine's {@code /api/v1/documents/classify} endpoint. */
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
private record ClassifyEngineRequest(
|
||||
String fileName, List<AiPageText> pages, JsonNode taxonomy) {}
|
||||
}
|
||||
+1
-2
@@ -21,8 +21,7 @@ public enum AiWorkflowOutcome {
|
||||
COMPLETED("completed"),
|
||||
UNSUPPORTED_CAPABILITY("unsupported_capability"),
|
||||
CANNOT_CONTINUE("cannot_continue"),
|
||||
GENERATE_FILE("generate_file"),
|
||||
CONVERT_MARKDOWN("convert_markdown");
|
||||
GENERATE_FILE("generate_file");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
||||
-7
@@ -21,11 +21,4 @@ public class AiWorkflowResultFile {
|
||||
|
||||
@Schema(description = "MIME type of the file", example = "application/pdf")
|
||||
private String contentType;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Index into the request's fileInputs that this output was derived from, or null"
|
||||
+ " when it has no single source (e.g. a merge, or a generated file)."
|
||||
+ " Lets the client replace that input in place as a new version.")
|
||||
private Integer sourceIndex;
|
||||
}
|
||||
|
||||
+3
-7
@@ -8,11 +8,7 @@ import tools.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Result of a {@link PolicyExecutor} run. {@code files} are final temp files (not yet stored).
|
||||
* {@code origins} is parallel to {@code files}: each entry is the index into the original pipeline
|
||||
* inputs that the output traces back to, or {@code null} when it has no single source (e.g. a merge
|
||||
* combining several inputs, or a generated file). Callers use it to map an output back onto the
|
||||
* file it came from. {@code report}/{@code reportTool} carry the last step's structured report and
|
||||
* its operation, or null if no step produced one.
|
||||
* {@code report}/{@code reportTool} carry the last step's structured report and its operation, or
|
||||
* null if no step produced one.
|
||||
*/
|
||||
public record PolicyExecutionResult(
|
||||
List<Resource> files, List<Integer> origins, JsonNode report, String reportTool) {}
|
||||
public record PolicyExecutionResult(List<Resource> files, JsonNode report, String reportTool) {}
|
||||
|
||||
+9
-40
@@ -58,11 +58,6 @@ public class PolicyExecutor {
|
||||
// payload the tool surfaced alongside or instead of a file.
|
||||
private record ToolResult(List<Resource> files, JsonNode report) {}
|
||||
|
||||
// A step's output files paired with each file's origin (the index into the original pipeline
|
||||
// inputs it traces back to, or null when it has no single source). Origins compose across steps
|
||||
// so the final result can be mapped back onto the files that entered the pipeline.
|
||||
private record StepOutput(List<Resource> files, List<Integer> origins, JsonNode report) {}
|
||||
|
||||
/**
|
||||
* Run every step in order, feeding each step's output into the next. Supporting files in {@code
|
||||
* inputs} bind to named file fields and never enter the document stream.
|
||||
@@ -80,12 +75,6 @@ public class PolicyExecutor {
|
||||
|
||||
List<Resource> currentFiles = inputs.primary();
|
||||
Map<String, List<Resource>> supportingFiles = inputs.supportingFiles();
|
||||
// Seed each input with its own index as origin; steps carry these through so the final
|
||||
// outputs can be traced back to the files that entered the pipeline.
|
||||
List<Integer> currentOrigins = new ArrayList<>();
|
||||
for (int k = 0; k < currentFiles.size(); k++) {
|
||||
currentOrigins.add(k);
|
||||
}
|
||||
// Last non-null report wins: the terminal step defines the output.
|
||||
JsonNode lastReport = null;
|
||||
String lastReportTool = null;
|
||||
@@ -98,10 +87,8 @@ public class PolicyExecutor {
|
||||
"Pipeline step " + (i + 1) + " has no operation");
|
||||
}
|
||||
listener.onStepStart(i + 1, steps.size(), operation);
|
||||
StepOutput stepResult =
|
||||
executeStep(step, currentFiles, currentOrigins, supportingFiles);
|
||||
ToolResult stepResult = executeStep(step, currentFiles, supportingFiles);
|
||||
currentFiles = stepResult.files();
|
||||
currentOrigins = stepResult.origins();
|
||||
if (stepResult.report() != null) {
|
||||
lastReport = stepResult.report();
|
||||
lastReportTool = operation;
|
||||
@@ -109,7 +96,7 @@ public class PolicyExecutor {
|
||||
listener.onStepComplete(i + 1, steps.size(), operation);
|
||||
}
|
||||
|
||||
return new PolicyExecutionResult(currentFiles, currentOrigins, lastReport, lastReportTool);
|
||||
return new PolicyExecutionResult(currentFiles, lastReport, lastReportTool);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,50 +104,32 @@ public class PolicyExecutor {
|
||||
* responses are unpacked so each inner file is its own result (e.g. split). For per-file
|
||||
* dispatch the first non-null report wins.
|
||||
*/
|
||||
private StepOutput executeStep(
|
||||
private ToolResult executeStep(
|
||||
PipelineStep step,
|
||||
List<Resource> inputFiles,
|
||||
List<Integer> inputOrigins,
|
||||
Map<String, List<Resource>> supportingFiles)
|
||||
throws IOException {
|
||||
requireAcceptedTypes(step.operation(), inputFiles);
|
||||
List<Resource> files = new ArrayList<>();
|
||||
List<Integer> origins = new ArrayList<>();
|
||||
JsonNode report = null;
|
||||
if (toolMetadataService.isMultiInput(step.operation())) {
|
||||
// One call over all inputs. The outputs derive from a single input only when exactly
|
||||
// one entered; otherwise (a genuine merge) there is no single source.
|
||||
ToolResult r = callEndpoint(step, inputFiles, supportingFiles);
|
||||
Integer origin = inputOrigins.size() == 1 ? inputOrigins.get(0) : null;
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else if (inputFiles.isEmpty()) {
|
||||
ToolResult r = callEndpoint(step, List.of(), supportingFiles);
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(null);
|
||||
}
|
||||
files.addAll(r.files());
|
||||
report = r.report();
|
||||
} else {
|
||||
// One call per file: every output of this call inherits that input's origin, so a 1:1
|
||||
// op keeps its chain and a split (one input, many outputs) tags each output with the
|
||||
// same source.
|
||||
for (int k = 0; k < inputFiles.size(); k++) {
|
||||
Integer origin = inputOrigins.get(k);
|
||||
ToolResult r = callEndpoint(step, List.of(inputFiles.get(k)), supportingFiles);
|
||||
for (Resource file : r.files()) {
|
||||
files.add(file);
|
||||
origins.add(origin);
|
||||
}
|
||||
for (Resource file : inputFiles) {
|
||||
ToolResult r = callEndpoint(step, List.of(file), supportingFiles);
|
||||
files.addAll(r.files());
|
||||
if (report == null) {
|
||||
report = r.report();
|
||||
}
|
||||
}
|
||||
}
|
||||
return new StepOutput(files, origins, report);
|
||||
return new ToolResult(files, report);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-4
@@ -36,8 +36,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.repository",
|
||||
"stirling.software.proprietary.integration.repository",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
"stirling.software.proprietary.integration.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -48,8 +47,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.access.model",
|
||||
"stirling.software.proprietary.integration.model",
|
||||
"stirling.software.proprietary.classification.store"
|
||||
"stirling.software.proprietary.integration.model"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable {
|
||||
|
||||
@ElementCollection
|
||||
@MapKeyColumn(name = "setting_key")
|
||||
@Lob
|
||||
@Column(name = "setting_value", columnDefinition = "text")
|
||||
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
|
||||
@JsonIgnore
|
||||
|
||||
+7
-106
@@ -68,7 +68,6 @@ import tools.jackson.databind.ObjectMapper;
|
||||
public class AiWorkflowService {
|
||||
|
||||
private static final String DOCUMENTS_ENDPOINT = "/api/v1/documents";
|
||||
private static final String PDF_TO_MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final AiEngineClient aiEngineClient;
|
||||
@@ -196,7 +195,6 @@ public class AiWorkflowService {
|
||||
return switch (response.getOutcome()) {
|
||||
case NEED_CONTENT -> onNeedContent(response, filesById, request, listener);
|
||||
case NEED_INGEST -> onNeedIngest(response, filesById, request, listener);
|
||||
case CONVERT_MARKDOWN -> onConvertMarkdown(response, filesById, listener);
|
||||
case TOOL_CALL -> onToolCall(response, filesById, listener);
|
||||
case PLAN -> onPlan(response, filesById, request, listener);
|
||||
case ANSWER -> onAnswer(response, filesById, request, listener);
|
||||
@@ -333,77 +331,6 @@ public class AiWorkflowService {
|
||||
return new WorkflowState.Pending(nextRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically convert each requested PDF to Markdown via the {@code
|
||||
* /convert/pdf/markdown} endpoint (backed by {@code PdfMarkdownConverter}) and return the
|
||||
* {@code .md} file(s) as a completed result. No AI resume — the conversion output is the final
|
||||
* answer.
|
||||
*/
|
||||
private WorkflowState onConvertMarkdown(
|
||||
AiWorkflowResponse response,
|
||||
Map<String, MultipartFile> filesById,
|
||||
ProgressListener listener) {
|
||||
List<AiFile> filesToConvert = response.getFilesToIngest();
|
||||
if (filesToConvert == null || filesToConvert.isEmpty()) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested markdown conversion without listing any files."));
|
||||
}
|
||||
|
||||
try {
|
||||
List<Resource> resultFiles = new ArrayList<>();
|
||||
List<Integer> origins = new ArrayList<>();
|
||||
List<String> inputNames = new ArrayList<>();
|
||||
for (int i = 0; i < filesToConvert.size(); i++) {
|
||||
AiFile file = filesToConvert.get(i);
|
||||
MultipartFile multipartFile = filesById.get(file.getId());
|
||||
if (multipartFile == null) {
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(
|
||||
"AI engine requested markdown conversion for unknown file: "
|
||||
+ file.getName()));
|
||||
}
|
||||
listener.onProgress(
|
||||
AiWorkflowProgressEvent.executingTool(
|
||||
PDF_TO_MARKDOWN_ENDPOINT, i + 1, filesToConvert.size()));
|
||||
Resource input = toResource(multipartFile);
|
||||
PipelineDefinition definition =
|
||||
new PipelineDefinition(
|
||||
"convert-markdown",
|
||||
List.of(new PipelineStep(PDF_TO_MARKDOWN_ENDPOINT, Map.of())),
|
||||
null);
|
||||
PolicyExecutionResult result =
|
||||
policyExecutor.execute(
|
||||
definition,
|
||||
PolicyInputs.of(List.of(input)),
|
||||
PolicyProgressListener.NOOP);
|
||||
// Each conversion runs on one input, so every output traces back to file i.
|
||||
for (Resource output : result.files()) {
|
||||
resultFiles.add(output);
|
||||
origins.add(i);
|
||||
}
|
||||
inputNames.add(multipartFile.getOriginalFilename());
|
||||
}
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(null, resultFiles, origins, inputNames, null));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("PDF to Markdown conversion timed out: {}", e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolTimeoutMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||
} catch (Exception e) {
|
||||
AiWorkflowResponse limit = paygLimitResponseOrNull(e);
|
||||
if (limit != null) {
|
||||
log.info(
|
||||
"AI markdown conversion blocked by downstream entitlement gate ({})",
|
||||
limit.getErrorCode());
|
||||
return new WorkflowState.Terminal(limit);
|
||||
}
|
||||
log.error("Failed to convert PDF to Markdown: {}", e.getMessage(), e);
|
||||
return new WorkflowState.Terminal(
|
||||
cannotContinue(toolFailureMessage(PDF_TO_MARKDOWN_ENDPOINT, e)));
|
||||
}
|
||||
}
|
||||
|
||||
private Resource toResource(MultipartFile file) throws IOException {
|
||||
TempFile tempFile = tempFileManager.createManagedTempFile("ai-workflow");
|
||||
file.transferTo(tempFile.getPath());
|
||||
@@ -477,7 +404,6 @@ public class AiWorkflowService {
|
||||
buildCompletedResponse(
|
||||
response.getRationale(),
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
@@ -539,8 +465,7 @@ public class AiWorkflowService {
|
||||
}
|
||||
};
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
response.getSummary(), List.of(resource), null, List.of(), null));
|
||||
buildCompletedResponse(response.getSummary(), List.of(resource), List.of(), null));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -598,11 +523,7 @@ public class AiWorkflowService {
|
||||
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse(
|
||||
summary,
|
||||
result.files(),
|
||||
result.origins(),
|
||||
inputFileNames(filesById),
|
||||
result.report()));
|
||||
summary, result.files(), inputFileNames(filesById), result.report()));
|
||||
} catch (InternalApiTimeoutException e) {
|
||||
log.error("Plan step on tool {} timed out: {}", e.getEndpointPath(), e.getMessage());
|
||||
return new WorkflowState.Terminal(
|
||||
@@ -691,35 +612,19 @@ public class AiWorkflowService {
|
||||
private AiWorkflowResponse buildCompletedResponse(
|
||||
String summary,
|
||||
List<Resource> resultFiles,
|
||||
List<Integer> origins,
|
||||
List<String> inputFileNames,
|
||||
JsonNode report)
|
||||
throws IOException {
|
||||
// Store every output file individually so each gets its own Stirling file ID and the
|
||||
// frontend can add them as independent variants without going through a zip.
|
||||
// Count outputs per source so only a clean 1:1 transform (one output for a source) reuses
|
||||
// the input's name; a split (one input → many outputs) keeps each entry's own name.
|
||||
Map<Integer, Long> outputsPerOrigin =
|
||||
origins == null
|
||||
? Map.of()
|
||||
: origins.stream()
|
||||
.filter(o -> o != null)
|
||||
.collect(Collectors.groupingBy(o -> o, Collectors.counting()));
|
||||
boolean preserveInputNames = inputFileNames.size() == resultFiles.size();
|
||||
List<AiWorkflowResultFile> descriptors = new ArrayList<>();
|
||||
for (int i = 0; i < resultFiles.size(); i++) {
|
||||
Resource resource = resultFiles.get(i);
|
||||
String responseName = resource.getFilename();
|
||||
// The output's source input (from the executor), used both to name it and to tell the
|
||||
// client which file to version in place.
|
||||
Integer origin = origins != null && i < origins.size() ? origins.get(i) : null;
|
||||
boolean uniqueOrigin =
|
||||
origin != null && outputsPerOrigin.getOrDefault(origin, 0L) == 1L;
|
||||
String inputName =
|
||||
uniqueOrigin && origin >= 0 && origin < inputFileNames.size()
|
||||
? inputFileNames.get(origin)
|
||||
: null;
|
||||
// Prefer the source input's name only for 1:1 operations where the output keeps the
|
||||
// same extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
String inputName = preserveInputNames ? inputFileNames.get(i) : null;
|
||||
// Prefer the input name only for 1:1 operations where the output keeps the same
|
||||
// extension (rotate, compress, etc.). For converters and other extension-changing
|
||||
// tools, the response filename from Content-Disposition is authoritative.
|
||||
String name;
|
||||
if (inputName != null
|
||||
@@ -739,11 +644,7 @@ public class AiWorkflowService {
|
||||
try (java.io.InputStream is = resource.getInputStream()) {
|
||||
fileId = fileStorage.storeInputStream(is, name).fileId();
|
||||
}
|
||||
// Only expose the source when this is a clean 1:1 transform, so the client can treat a
|
||||
// present sourceIndex as "replace that input in place" without further disambiguation.
|
||||
descriptors.add(
|
||||
new AiWorkflowResultFile(
|
||||
fileId, name, contentType, uniqueOrigin ? origin : null));
|
||||
descriptors.add(new AiWorkflowResultFile(fileId, name, contentType));
|
||||
}
|
||||
|
||||
AiWorkflowResponse completed = new AiWorkflowResponse();
|
||||
|
||||
@@ -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
|
||||
|
||||
+70
@@ -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<String> resp =
|
||||
response(
|
||||
200,
|
||||
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}");
|
||||
ArgumentCaptor<HttpRequest> 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<String> 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<String> 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));
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -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<UsageSyncService> 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<Void> 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<Void> resp = controller.syncNow();
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
||||
verify(syncService, never()).syncNow();
|
||||
}
|
||||
}
|
||||
|
||||
+52
-23
@@ -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: /<ctx>/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: /<ctx>/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));
|
||||
}
|
||||
}
|
||||
|
||||
+198
-14
@@ -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());
|
||||
}
|
||||
|
||||
+13
-1
@@ -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());
|
||||
|
||||
+113
-1
@@ -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<UsageMeterService> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+133
@@ -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
|
||||
}
|
||||
}
|
||||
+171
@@ -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());
|
||||
}
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
package stirling.software.proprietary.classification;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.proprietary.classification.model.ClassificationTaxonomy;
|
||||
import stirling.software.proprietary.classification.model.TaxonomyCategory;
|
||||
import stirling.software.proprietary.classification.model.TaxonomyDocumentType;
|
||||
import stirling.software.proprietary.classification.store.InProcessTaxonomyStore;
|
||||
import stirling.software.proprietary.classification.store.TaxonomyStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("TaxonomyController")
|
||||
class TaxonomyControllerTest {
|
||||
|
||||
private static final Long TEAM = 7L;
|
||||
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
@Mock private UserServiceInterface userService;
|
||||
|
||||
private TaxonomyStore store;
|
||||
private ApplicationProperties applicationProperties;
|
||||
private TaxonomyController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
store = new InProcessTaxonomyStore();
|
||||
applicationProperties = new ApplicationProperties();
|
||||
controller =
|
||||
new TaxonomyController(
|
||||
store, policyManagementAuthority, applicationProperties, userService);
|
||||
}
|
||||
|
||||
private static ClassificationTaxonomy sample() {
|
||||
return new ClassificationTaxonomy(
|
||||
List.of(
|
||||
new TaxonomyCategory(
|
||||
"invoice",
|
||||
"Invoice",
|
||||
List.of(new TaxonomyDocumentType("receipt", "Receipt")))),
|
||||
List.of("finance"));
|
||||
}
|
||||
|
||||
private void loginEnabled(boolean enabled) {
|
||||
applicationProperties.getSecurity().setEnableLogin(enabled);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("GET returns 204 when the team has no taxonomy")
|
||||
void getEmpty() {
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
ResponseEntity<ClassificationTaxonomy> response = controller.getTaxonomy();
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT then GET round-trips the team's taxonomy (login disabled)")
|
||||
void saveThenGet() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
|
||||
controller.saveTaxonomy(sample());
|
||||
ResponseEntity<ClassificationTaxonomy> got = controller.getTaxonomy();
|
||||
|
||||
assertThat(got.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(got.getBody()).isNotNull();
|
||||
assertThat(got.getBody().categories()).hasSize(1);
|
||||
assertThat(got.getBody().categories().getFirst().id()).isEqualTo("invoice");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is scoped per team")
|
||||
void perTeam() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTaxonomy(sample());
|
||||
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(99L);
|
||||
assertThat(controller.getTaxonomy().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT is rejected for a non-editor when login is enabled")
|
||||
void putForbiddenForNonEditor() {
|
||||
loginEnabled(true);
|
||||
when(policyManagementAuthority.canEditPolicies()).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> controller.saveTaxonomy(sample()))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT rejects an invalid taxonomy with 400")
|
||||
void putInvalid() {
|
||||
loginEnabled(false);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.saveTaxonomy(
|
||||
new ClassificationTaxonomy(List.of(), List.of())))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasFieldOrPropertyWithValue("statusCode", HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("DELETE resets the team back to no stored taxonomy")
|
||||
void deleteResets() {
|
||||
loginEnabled(false);
|
||||
when(policyManagementAuthority.currentUserTeamId()).thenReturn(TEAM);
|
||||
controller.saveTaxonomy(sample());
|
||||
|
||||
ResponseEntity<Void> response = controller.resetTaxonomy();
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
assertThat(controller.getTaxonomy().getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
}
|
||||
}
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
package stirling.software.proprietary.classification.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("TaxonomyValidator")
|
||||
class TaxonomyValidatorTest {
|
||||
|
||||
private static TaxonomyCategory category(String id, TaxonomyDocumentType... docTypes) {
|
||||
return new TaxonomyCategory(id, id + " label", List.of(docTypes));
|
||||
}
|
||||
|
||||
private static TaxonomyDocumentType docType(String id) {
|
||||
return new TaxonomyDocumentType(id, id + " label");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("accepts a well-formed taxonomy")
|
||||
void acceptsValid() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice", docType("receipt")), category("contract")),
|
||||
List.of("finance", "legal"));
|
||||
assertThatCode(() -> TaxonomyValidator.validate(taxonomy)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a taxonomy with no categories")
|
||||
void rejectsEmpty() {
|
||||
ClassificationTaxonomy taxonomy = new ClassificationTaxonomy(List.of(), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("at least one category");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate category ids")
|
||||
void rejectsDuplicateCategory() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice"), category("invoice")), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate category id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate doc type ids within a category")
|
||||
void rejectsDuplicateDocType() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice", docType("receipt"), docType("receipt"))),
|
||||
List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate doc type id");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects blank ids and labels")
|
||||
void rejectsBlank() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(new TaxonomyCategory(" ", "label", List.of())), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("must not be blank");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects duplicate tags")
|
||||
void rejectsDuplicateTags() {
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(category("invoice")), List.of("finance", "finance"));
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Duplicate tag");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects more categories than the cap")
|
||||
void rejectsTooManyCategories() {
|
||||
List<TaxonomyCategory> categories =
|
||||
java.util.stream.IntStream.rangeClosed(0, TaxonomyValidator.MAX_CATEGORIES)
|
||||
.mapToObj(i -> category("cat" + i))
|
||||
.toList();
|
||||
ClassificationTaxonomy taxonomy = new ClassificationTaxonomy(categories, List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Too many categories");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long label")
|
||||
void rejectsOverLongLabel() {
|
||||
String longLabel = "x".repeat(TaxonomyValidator.MAX_TEXT_LENGTH + 1);
|
||||
ClassificationTaxonomy taxonomy =
|
||||
new ClassificationTaxonomy(
|
||||
List.of(new TaxonomyCategory("invoice", longLabel, List.of())), List.of());
|
||||
assertThatThrownBy(() -> TaxonomyValidator.validate(taxonomy))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("too long");
|
||||
}
|
||||
}
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class ClassifyTagControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
@Mock private PdfContentExtractor pdfContentExtractor;
|
||||
@Mock private PdfMetadataService pdfMetadataService;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
private ClassifyTagController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller =
|
||||
new ClassifyTagController(
|
||||
pdfDocumentFactory,
|
||||
tempFileManager,
|
||||
pdfContentExtractor,
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
objectMapper,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifyAndTag_writesClassificationWithoutOutcome() throws Exception {
|
||||
PDDocument document = mock(PDDocument.class);
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.getOriginalFilename()).thenReturn("invoice.pdf");
|
||||
when(pdfDocumentFactory.load(any(MultipartFile.class), eq(true))).thenReturn(document);
|
||||
when(document.getNumberOfPages()).thenReturn(1);
|
||||
when(pdfContentExtractor.extractPageTextRaw(document, 1))
|
||||
.thenReturn("Invoice total due 100.00");
|
||||
when(aiEngineClient.post(eq("/api/v1/documents/classify"), anyString(), isNull()))
|
||||
.thenReturn(
|
||||
"{\"outcome\":\"classification\",\"category\":\"invoice\","
|
||||
+ "\"docType\":\"invoice\",\"typeConfidence\":0.98,"
|
||||
+ "\"tags\":[\"finance\"]}");
|
||||
|
||||
try {
|
||||
controller.classifyAndTag(file);
|
||||
} catch (Exception ignored) {
|
||||
// WebResponseUtils.pdfDocToWebResponse needs a real temp file; the metadata write we
|
||||
// assert on has already happened by the time it runs.
|
||||
}
|
||||
|
||||
ArgumentCaptor<String> value = ArgumentCaptor.forClass(String.class);
|
||||
verify(pdfMetadataService).setClassificationMetadata(eq(document), value.capture());
|
||||
|
||||
JsonNode written = objectMapper.readTree(value.getValue());
|
||||
assertThat(written.has("outcome")).isFalse();
|
||||
assertThat(written.get("category").asText()).isEqualTo("invoice");
|
||||
assertThat(written.get("docType").asText()).isEqualTo("invoice");
|
||||
assertThat(written.get("tags").get(0).asText()).isEqualTo("finance");
|
||||
}
|
||||
|
||||
@Test
|
||||
void windowPageNumbers_takesFirstAndLastWithoutOverlap() {
|
||||
assertEquals(List.of(1, 2, 4, 5), ClassifyTagController.windowPageNumbers(5, 2));
|
||||
assertEquals(List.of(1, 2, 3), ClassifyTagController.windowPageNumbers(3, 2));
|
||||
// Short docs clamp + dedupe rather than throwing or going out of range.
|
||||
assertEquals(List.of(1, 2), ClassifyTagController.windowPageNumbers(2, 2));
|
||||
assertEquals(List.of(1), ClassifyTagController.windowPageNumbers(1, 2));
|
||||
assertEquals(List.of(), ClassifyTagController.windowPageNumbers(0, 2));
|
||||
}
|
||||
}
|
||||
-28
@@ -221,34 +221,6 @@ class AiWorkflowServiceMoreTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("convert_markdown guards")
|
||||
class ConvertMarkdownGuards {
|
||||
|
||||
@Test
|
||||
@DisplayName("no files listed yields CANNOT_CONTINUE")
|
||||
void noFiles() throws IOException {
|
||||
stubOrchestrator("{\"outcome\":\"convert_markdown\",\"filesToIngest\":[]}");
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(pdf("a.pdf", "x"), "to md"));
|
||||
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown file id yields CANNOT_CONTINUE")
|
||||
void unknownFile() throws IOException {
|
||||
when(fileIdStrategy.idFor(any())).thenReturn("real-id");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"convert_markdown",
|
||||
"filesToIngest":[{"id":"other-id","name":"other.pdf"}]}
|
||||
""");
|
||||
AiWorkflowResponse result =
|
||||
service.orchestrate(requestFor(pdf("real.pdf", "x"), "to md"));
|
||||
assertThat(result.getOutcome()).isEqualTo(AiWorkflowOutcome.CANNOT_CONTINUE);
|
||||
assertThat(result.getReason()).contains("other.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("plan guards and errors")
|
||||
class PlanGuardsAndErrors {
|
||||
|
||||
+14
-66
@@ -2,7 +2,6 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
@@ -79,6 +78,7 @@ class AiWorkflowServiceTest {
|
||||
private static final String SPLIT_ENDPOINT = "/api/v1/general/split-pages";
|
||||
private static final String MERGE_ENDPOINT = "/api/v1/general/merge-pdfs";
|
||||
private static final String COMPRESS_ENDPOINT = "/api/v1/misc/compress-pdf";
|
||||
private static final String MARKDOWN_ENDPOINT = "/api/v1/convert/pdf/markdown";
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@@ -230,57 +230,6 @@ class AiWorkflowServiceTest {
|
||||
// 1:1 mapping preserves each input's filename.
|
||||
assertEquals("a.pdf", result.getResultFiles().get(0).getFileName());
|
||||
assertEquals("b.pdf", result.getResultFiles().get(1).getFileName());
|
||||
// Each output points back at the input it came from so the client versions it in place.
|
||||
assertEquals(0, result.getResultFiles().get(0).getSourceIndex());
|
||||
assertEquals(1, result.getResultFiles().get(1).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeOutputHasNoSourceIndex() throws IOException {
|
||||
MockMultipartFile a = pdf("a.pdf", "a-bytes");
|
||||
MockMultipartFile b = pdf("b.pdf", "b-bytes");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Merging"}
|
||||
"""
|
||||
.formatted(MERGE_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(MERGE_ENDPOINT)).thenReturn(true);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(MERGE_ENDPOINT)).thenReturn(false);
|
||||
stubEndpoint(MERGE_ENDPOINT, pdfResource("merged-bytes", "merged.pdf"));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result =
|
||||
service.orchestrate(requestFor(new MockMultipartFile[] {a, b}, "merge these"));
|
||||
|
||||
// A merge draws on several inputs, so there is no single source to version in place.
|
||||
assertNull(result.getResultFiles().get(0).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
void splitOutputsHaveNoSourceIndex() throws IOException {
|
||||
MockMultipartFile input = pdf("doc.pdf", "original");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{"outcome":"tool_call","tool":"%s","parameters":{},"rationale":"Splitting"}
|
||||
"""
|
||||
.formatted(SPLIT_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(SPLIT_ENDPOINT)).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(SPLIT_ENDPOINT)).thenReturn(true);
|
||||
stubEndpoint(
|
||||
SPLIT_ENDPOINT,
|
||||
zipResource(
|
||||
"doc.zip",
|
||||
List.of(
|
||||
new ZipEntryBytes("page-1.pdf", "page-one"),
|
||||
new ZipEntryBytes("page-2.pdf", "page-two"))));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "split"));
|
||||
|
||||
// One input fanned out to many outputs, so none is a clean 1:1 version — the client adds
|
||||
// them as fresh files and leaves the original in place.
|
||||
assertNull(result.getResultFiles().get(0).getSourceIndex());
|
||||
assertNull(result.getResultFiles().get(1).getSourceIndex());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -492,23 +441,23 @@ class AiWorkflowServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertMarkdownRunsDeterministicConversionAndReturnsMdFile() throws IOException {
|
||||
void planWithMarkdownStepReturnsMdFile() throws IOException {
|
||||
// PDF→Markdown is a normal tool the edit agent emits as a plan step (no bespoke
|
||||
// outcome); the plan executor runs the converter and returns the .md file.
|
||||
MockMultipartFile input = pdf("multi-column-test_lorem.pdf", "pdf-bytes");
|
||||
when(fileIdStrategy.idFor(any())).thenReturn("doc-1");
|
||||
stubOrchestrator(
|
||||
"""
|
||||
{
|
||||
"outcome":"convert_markdown",
|
||||
"reason":"PDF to Markdown requested.",
|
||||
"filesToIngest":[{"id":"doc-1","name":"multi-column-test_lorem.pdf"}]
|
||||
"outcome":"plan",
|
||||
"summary":"Convert to Markdown",
|
||||
"steps":[{"tool":"%s","parameters":{}}]
|
||||
}
|
||||
""");
|
||||
when(toolMetadataService.shouldUnpackZipResponse("/api/v1/convert/pdf/markdown"))
|
||||
.thenReturn(false);
|
||||
stubEndpoint(
|
||||
"/api/v1/convert/pdf/markdown",
|
||||
pdfResource("# Title", "multi-column-test_lorem.md"));
|
||||
AtomicInteger ids = stubFileStorage();
|
||||
"""
|
||||
.formatted(MARKDOWN_ENDPOINT));
|
||||
when(toolMetadataService.isMultiInput(anyString())).thenReturn(false);
|
||||
when(toolMetadataService.shouldUnpackZipResponse(anyString())).thenReturn(false);
|
||||
stubEndpoint(MARKDOWN_ENDPOINT, pdfResource("# Title", "multi-column-test_lorem.md"));
|
||||
stubFileStorage();
|
||||
|
||||
AiWorkflowResponse result = service.orchestrate(requestFor(input, "convert to markdown"));
|
||||
|
||||
@@ -516,8 +465,7 @@ class AiWorkflowServiceTest {
|
||||
assertEquals(1, result.getResultFiles().size());
|
||||
// Extension changes (pdf -> md), so the converter's response filename wins.
|
||||
assertEquals("multi-column-test_lorem.md", result.getResultFiles().get(0).getFileName());
|
||||
assertEquals(1, ids.get());
|
||||
verify(internalApiClient, times(1)).post(eq("/api/v1/convert/pdf/markdown"), any());
|
||||
verify(internalApiClient, times(1)).post(eq(MARKDOWN_ENDPOINT), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+112
-12
@@ -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 <b>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<EntitlementResponse> 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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,13 +18,15 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
"stirling.software.saas.repository",
|
||||
"stirling.software.saas.billing.repository",
|
||||
"stirling.software.saas.ai.repository",
|
||||
"stirling.software.saas.payg.repository"
|
||||
"stirling.software.saas.payg.repository",
|
||||
"stirling.software.saas.procurement.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.saas.accountlink",
|
||||
"stirling.software.saas.model",
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model",
|
||||
"stirling.software.saas.payg"
|
||||
"stirling.software.saas.payg",
|
||||
"stirling.software.saas.procurement.model"
|
||||
})
|
||||
public class SaasJpaConfig {}
|
||||
|
||||
@@ -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<Void> 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
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
+18
-35
@@ -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<FileSize> 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;
|
||||
|
||||
+130
@@ -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<BillingCategory, Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+5
-30
@@ -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.
|
||||
*
|
||||
* <p>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<LineageSignature> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+35
@@ -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<PaygInstanceUsage, Long> {
|
||||
|
||||
Optional<PaygInstanceUsage> 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<PaygInstanceUsage> findByTeamIdAndPeriodStartAndCategoryForUpdate(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("category") String category);
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package stirling.software.saas.procurement.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.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;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem;
|
||||
import stirling.software.saas.procurement.service.ProcurementService;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
* The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
|
||||
* (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
|
||||
* Supabase edge function the portal calls with the accepted quote — this controller never touches
|
||||
* Stripe. The caller's team is resolved from the authenticated principal; a team id is never
|
||||
* trusted from the request. Mutations require the team leader.
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/procurement")
|
||||
@Profile("saas")
|
||||
public class ProcurementController {
|
||||
|
||||
// Local mapper to parse the stored line-items JSON; the saas context exposes no injectable
|
||||
// ObjectMapper bean.
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ProcurementService procurement;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
|
||||
public ProcurementController(
|
||||
ProcurementService procurement,
|
||||
TeamMembershipRepository memberRepo,
|
||||
UserRepository userRepository,
|
||||
ProcurementConfigurationProperties config) {
|
||||
this.procurement = Objects.requireNonNull(procurement);
|
||||
this.memberRepo = Objects.requireNonNull(memberRepo);
|
||||
this.userRepository = Objects.requireNonNull(userRepository);
|
||||
this.config = Objects.requireNonNull(config);
|
||||
}
|
||||
|
||||
// ---- request / response DTOs -------------------------------------------
|
||||
|
||||
public record QuoteRequest(
|
||||
long volume,
|
||||
int users,
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
String currency,
|
||||
String businessName) {
|
||||
QuoteConfig toConfig() {
|
||||
return new QuoteConfig(
|
||||
volume,
|
||||
users,
|
||||
deployment,
|
||||
termYears,
|
||||
serviceLevel,
|
||||
indemnification,
|
||||
training,
|
||||
qbr,
|
||||
currency);
|
||||
}
|
||||
}
|
||||
|
||||
public record QuoteResponse(
|
||||
Long quoteId,
|
||||
String quoteNumber,
|
||||
String status,
|
||||
String currency,
|
||||
long annualNetMinor,
|
||||
long tcvMinor,
|
||||
List<QuoteLineItem> lineItems,
|
||||
String validUntil,
|
||||
String stripeQuoteId,
|
||||
String invoiceUrl,
|
||||
QuoteConfigEcho config) {}
|
||||
|
||||
/**
|
||||
* The inputs the quote was priced from, echoed back so the builder can seed itself when the
|
||||
* buyer re-edits an existing quote. {@code users} is not persisted (only the resulting volume
|
||||
* is), so it is always 0 here; the builder treats the seeded volume as manually set.
|
||||
*/
|
||||
public record QuoteConfigEcho(
|
||||
long volume,
|
||||
int users,
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
String currency,
|
||||
String businessName) {}
|
||||
|
||||
public record SnapshotResponse(
|
||||
Long dealId,
|
||||
String stage,
|
||||
String trialStartedAt,
|
||||
String trialEndsAt,
|
||||
int trialExtensionsUsed,
|
||||
boolean licensed,
|
||||
QuoteResponse latestQuote) {}
|
||||
|
||||
// ---- endpoints ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The team's deal snapshot. Always 200 with a single shape; an unstarted procurement returns an
|
||||
* empty snapshot ({@code dealId == null}) so the portal can render the "start" state without
|
||||
* special-casing an empty body.
|
||||
*/
|
||||
@GetMapping
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> snapshot(Authentication auth) {
|
||||
Long teamId = resolveTeam(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
return ResponseEntity.ok(
|
||||
procurement.getDeal(teamId).map(this::toSnapshot).orElse(EMPTY_SNAPSHOT));
|
||||
}
|
||||
|
||||
private static final SnapshotResponse EMPTY_SNAPSHOT =
|
||||
new SnapshotResponse(null, null, null, null, 0, false, null);
|
||||
|
||||
@PostMapping("/trial/start")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId)));
|
||||
}
|
||||
|
||||
@PostMapping("/trial/extend")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> extendTrial(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.extendTrial(teamId)));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/quote")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<QuoteResponse> buildQuote(
|
||||
@RequestBody QuoteRequest request, Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(
|
||||
toQuote(
|
||||
procurement.buildQuote(
|
||||
teamId, request.toConfig(), request.businessName())));
|
||||
}
|
||||
|
||||
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
|
||||
// draft into a finalized Stripe Quote; accept-procurement-quote accepts it into a subscription.
|
||||
// Both persist their results via SECURITY DEFINER RPCs; the snapshot above reflects them.
|
||||
|
||||
/**
|
||||
* Advance an issued quote to the agreement (security) stage, where the buyer reviews + agrees.
|
||||
*/
|
||||
@PostMapping("/agreement")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startAgreement(Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startAgreement(teamId)));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Demo/manual stand-in for the {@code invoice.paid} webhook: mark the deal live (issue the
|
||||
* annual licence, advance to active). The real go-live is webhook-driven once payment settles.
|
||||
*/
|
||||
@PostMapping("/go-live")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> goLive(Authentication auth) {
|
||||
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
try {
|
||||
return ResponseEntity.ok(toSnapshot(procurement.markLive(teamId)));
|
||||
} catch (IllegalStateException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).build();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset the team's procurement (delete the deal + quotes); returns the empty snapshot. */
|
||||
@PostMapping("/reset")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> reset(Authentication auth) {
|
||||
if (!config.isDemoControlsEnabled()) return ResponseEntity.notFound().build();
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
procurement.resetDeal(teamId);
|
||||
return ResponseEntity.ok(EMPTY_SNAPSHOT);
|
||||
}
|
||||
|
||||
// ---- helpers ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the caller's team from their primary membership; null when unauthenticated/teamless.
|
||||
*/
|
||||
private Long resolveTeam(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return null;
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
return rows.isEmpty() ? null : rows.get(0).getTeam().getId();
|
||||
}
|
||||
|
||||
/** Team id only when the caller is the team leader; null otherwise (commercial actions). */
|
||||
private Long requireLeader(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return null;
|
||||
}
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(user.getId());
|
||||
if (rows.isEmpty() || rows.get(0).getRole() != TeamRole.LEADER) return null;
|
||||
return rows.get(0).getTeam().getId();
|
||||
}
|
||||
|
||||
private SnapshotResponse toSnapshot(ProcurementDeal deal) {
|
||||
QuoteResponse latest =
|
||||
procurement.quotesForDeal(deal.getDealId()).stream()
|
||||
.findFirst()
|
||||
.map(this::toQuote)
|
||||
.orElse(null);
|
||||
return new SnapshotResponse(
|
||||
deal.getDealId(),
|
||||
deal.getStage(),
|
||||
str(deal.getTrialStartedAt()),
|
||||
str(deal.getTrialEndsAt()),
|
||||
deal.getTrialExtensionsUsed(),
|
||||
deal.getLicenseRef() != null,
|
||||
latest);
|
||||
}
|
||||
|
||||
private QuoteResponse toQuote(ProcurementQuote q) {
|
||||
return new QuoteResponse(
|
||||
q.getQuoteId(),
|
||||
q.getQuoteNumber(),
|
||||
q.getStatus(),
|
||||
q.getCurrency(),
|
||||
q.getAnnualNetMinor(),
|
||||
q.getTcvMinor(),
|
||||
parseLineItems(q.getLineItemsJson()),
|
||||
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
|
||||
q.getStripeQuoteId(),
|
||||
q.getStripeInvoiceUrl(),
|
||||
new QuoteConfigEcho(
|
||||
q.getVolume(),
|
||||
0,
|
||||
q.getDeployment(),
|
||||
q.getTermYears(),
|
||||
q.getServiceLevel(),
|
||||
q.isIndemnification(),
|
||||
q.isTraining(),
|
||||
q.isQbr(),
|
||||
q.getCurrency(),
|
||||
q.getBusinessName()));
|
||||
}
|
||||
|
||||
private List<QuoteLineItem> parseLineItems(String json) {
|
||||
if (json == null || json.isBlank()) return List.of();
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json,
|
||||
OBJECT_MAPPER
|
||||
.getTypeFactory()
|
||||
.constructCollectionType(List.class, QuoteLineItem.class));
|
||||
} catch (Exception e) {
|
||||
log.warn("[procurement] failed to parse line items", e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : o.toString();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package stirling.software.saas.procurement.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/** Tunables for the enterprise procurement flow. Prefix {@code stirling.procurement}. */
|
||||
@Getter
|
||||
@Setter
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@ConfigurationProperties(prefix = "stirling.procurement")
|
||||
public class ProcurementConfigurationProperties {
|
||||
|
||||
/** Free trial length, in days (no card). */
|
||||
private int trialDurationDays = 14;
|
||||
|
||||
/** Days added per trial extension. */
|
||||
private int trialExtensionDays = 7;
|
||||
|
||||
/** Maximum number of trial extensions a buyer may take. */
|
||||
private int maxTrialExtensions = 2;
|
||||
|
||||
/**
|
||||
* Enables the demo-only endpoints (POST /reset, POST /go-live) that reset a team's procurement
|
||||
* or mark it live without payment. Off by default; turn on ONLY in demo/dev environments —
|
||||
* /go-live is a stand-in for the invoice.paid webhook and would let a leader activate unpaid.
|
||||
*/
|
||||
private boolean demoControlsEnabled = false;
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Issues and modifies the customer-facing entitlement that actually unlocks the product for an
|
||||
* enterprise deal — a Keygen licence (trial or annual, connected or air-gapped). This is the seam
|
||||
* the real Keygen management client plugs into; today {@link MockEnterpriseLicenseService} records
|
||||
* intent without calling Keygen. Distinct from the EE {@code KeygenLicenseVerifier}, which only
|
||||
* verifies this instance's own licence.
|
||||
*/
|
||||
public interface EnterpriseLicenseService {
|
||||
|
||||
/** Issue a time-boxed trial licence for the team; returns the licence reference. */
|
||||
String issueTrialLicense(Long teamId, LocalDateTime expiresAt);
|
||||
|
||||
/** Move a licence's expiry out (trial extension). */
|
||||
void extendLicense(String licenseRef, LocalDateTime newExpiry);
|
||||
|
||||
/** Issue/upgrade to a committed annual licence with the quote's entitlements. */
|
||||
String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt);
|
||||
|
||||
/** Suspend a licence (e.g. payment failed, deal lost). */
|
||||
void suspendLicense(String licenseRef);
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.saas.procurement.license;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link EnterpriseLicenseService}: records the intended licence action and
|
||||
* returns a synthetic reference, without calling Keygen. Lets the whole procurement journey run
|
||||
* end-to-end while the real Keygen management client is a later drop-in — the seam and the stored
|
||||
* {@code license_ref} on the deal stay identical.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
|
||||
|
||||
@Override
|
||||
public String issueTrialLicense(Long teamId, LocalDateTime expiresAt) {
|
||||
String ref = "mock-trial-" + UUID.randomUUID();
|
||||
log.info(
|
||||
"[procurement][mock-license] issue trial team={} expires={} ref={}",
|
||||
teamId,
|
||||
expiresAt,
|
||||
ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extendLicense(String licenseRef, LocalDateTime newExpiry) {
|
||||
log.info("[procurement][mock-license] extend ref={} newExpiry={}", licenseRef, newExpiry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String issueAnnualLicense(Long teamId, String deployment, LocalDateTime expiresAt) {
|
||||
String ref = "mock-annual-" + UUID.randomUUID();
|
||||
log.info(
|
||||
"[procurement][mock-license] issue annual team={} deployment={} expires={} ref={}",
|
||||
teamId,
|
||||
deployment,
|
||||
expiresAt,
|
||||
ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void suspendLicense(String licenseRef) {
|
||||
log.info("[procurement][mock-license] suspend ref={}", licenseRef);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
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.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A linked team's enterprise commercial journey (one per team). Stage mirrors the buyer journey the
|
||||
* portal renders (trial -> quote -> agreement -> payment -> live). The entitlement that
|
||||
* actually unlocks the product is the Keygen licence in {@code licenseRef}; the paid subscription,
|
||||
* once commercial, is mirrored in {@code billing_subscriptions} and referenced by {@code
|
||||
* subscriptionId}.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "procurement_deal")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcurementDeal implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String STAGE_TRIAL = "trial";
|
||||
public static final String STAGE_QUOTE = "quote";
|
||||
public static final String STAGE_AGREEMENT = "security";
|
||||
public static final String STAGE_PAYMENT = "procurement";
|
||||
public static final String STAGE_LIVE = "active";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "deal_id")
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "team_id", nullable = false, unique = true)
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "stage", nullable = false, length = 32)
|
||||
private String stage = STAGE_TRIAL;
|
||||
|
||||
@Column(name = "trial_started_at")
|
||||
private LocalDateTime trialStartedAt;
|
||||
|
||||
@Column(name = "trial_ends_at")
|
||||
private LocalDateTime trialEndsAt;
|
||||
|
||||
@Column(name = "trial_extensions_used", nullable = false)
|
||||
private int trialExtensionsUsed;
|
||||
|
||||
@Column(name = "license_ref", length = 128)
|
||||
private String licenseRef;
|
||||
|
||||
@Column(name = "subscription_id", length = 255)
|
||||
private String subscriptionId;
|
||||
|
||||
@Column(name = "accepted_quote_id")
|
||||
private Long acceptedQuoteId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
|
||||
public ProcurementDeal(Long teamId) {
|
||||
this.teamId = teamId;
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
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.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A priced, itemised offer built against a {@link ProcurementDeal}. The config columns are the
|
||||
* buyer's choices; {@code annualNetMinor}/{@code tcvMinor} and {@code lineItemsJson} are the
|
||||
* server-computed result (never trusted from the client). Stripe fields are populated when the
|
||||
* accepted quote is turned into a checkout.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "procurement_quote")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcurementQuote implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final String STATUS_DRAFT = "draft";
|
||||
public static final String STATUS_SENT = "sent";
|
||||
public static final String STATUS_ACCEPTED = "accepted";
|
||||
public static final String STATUS_EXPIRED = "expired";
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "quote_id")
|
||||
private Long quoteId;
|
||||
|
||||
@Column(name = "deal_id", nullable = false)
|
||||
private Long dealId;
|
||||
|
||||
@Column(name = "quote_number", nullable = false, length = 64)
|
||||
private String quoteNumber;
|
||||
|
||||
@Column(name = "status", nullable = false, length = 24)
|
||||
private String status = STATUS_DRAFT;
|
||||
|
||||
@Column(name = "currency", nullable = false, length = 8)
|
||||
private String currency = "USD";
|
||||
|
||||
@Column(name = "volume", nullable = false)
|
||||
private long volume;
|
||||
|
||||
@Column(name = "seats")
|
||||
private Integer seats;
|
||||
|
||||
@Column(name = "deployment", length = 24)
|
||||
private String deployment;
|
||||
|
||||
@Column(name = "term_years", nullable = false)
|
||||
private int termYears;
|
||||
|
||||
@Column(name = "service_level", nullable = false, length = 24)
|
||||
private String serviceLevel;
|
||||
|
||||
@Column(name = "indemnification", nullable = false)
|
||||
private boolean indemnification;
|
||||
|
||||
@Column(name = "training", nullable = false)
|
||||
private boolean training;
|
||||
|
||||
@Column(name = "qbr", nullable = false)
|
||||
private boolean qbr;
|
||||
|
||||
@Column(name = "annual_net_minor", nullable = false)
|
||||
private long annualNetMinor;
|
||||
|
||||
@Column(name = "tcv_minor", nullable = false)
|
||||
private long tcvMinor;
|
||||
|
||||
@Column(name = "line_items", columnDefinition = "text")
|
||||
private String lineItemsJson;
|
||||
|
||||
// The Stripe Quote this was issued as (finalized → has a number + PDF). Set by the edge fn.
|
||||
@Column(name = "stripe_quote_id", length = 128)
|
||||
private String stripeQuoteId;
|
||||
|
||||
// Hosted Stripe invoice URL for the subscription's first invoice, set once the quote is
|
||||
// accepted.
|
||||
@Column(name = "stripe_invoice_url", columnDefinition = "text")
|
||||
private String stripeInvoiceUrl;
|
||||
|
||||
// Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
|
||||
@Column(name = "business_name", length = 255)
|
||||
private String businessName;
|
||||
|
||||
@Column(name = "valid_until")
|
||||
private LocalDate validUntil;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version", nullable = false)
|
||||
private Long version;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package stirling.software.saas.procurement.pricing;
|
||||
|
||||
/**
|
||||
* The enterprise rate card: the inputs pricing multiplies against. In production these are read
|
||||
* from the Stripe price mirror (see {@code StripeMirrorPriceCatalog}); {@link #defaults()} is the
|
||||
* fallback used when the {@code stripe} schema isn't synced (dev / tests) and is the single source
|
||||
* of the numbers the marketing prototype encodes.
|
||||
*
|
||||
* <p>Per-PDF rates are in minor units (cents) per document. Multipliers are fractions (e.g. 0.15 =
|
||||
* +15%). Flat/one-time fees are in minor units.
|
||||
*/
|
||||
public record PricingRates(
|
||||
long perPdfMinorUnder1M,
|
||||
long perPdfMinorUnder5M,
|
||||
long perPdfMinor5MPlus,
|
||||
double priorityUplift,
|
||||
double dedicatedUplift,
|
||||
double indemnificationUplift,
|
||||
double[] termDiscountByYear, // index 0 = 1yr … index 4 = 5yr
|
||||
long qbrAnnualMinor,
|
||||
long trainingOneTimeMinor) {
|
||||
|
||||
public static PricingRates defaults() {
|
||||
return new PricingRates(
|
||||
5, // $0.05 / PDF under 1M/yr
|
||||
4, // $0.04 / PDF at 1M–5M/yr
|
||||
3, // $0.03 / PDF at 5M+/yr
|
||||
0.15, // priority +15%
|
||||
0.30, // dedicated +30%
|
||||
0.05, // IP indemnification +5%
|
||||
new double[] {0.0, 0.05, 0.10, 0.12, 0.15},
|
||||
800_000, // QBRs $8,000 / yr
|
||||
750_000); // onboarding & training $7,500 one-time
|
||||
}
|
||||
|
||||
/** Volume-banded per-PDF rate for an annual volume, in minor units. */
|
||||
public long perPdfMinor(long annualVolume) {
|
||||
if (annualVolume >= 5_000_000) return perPdfMinor5MPlus;
|
||||
if (annualVolume >= 1_000_000) return perPdfMinorUnder5M;
|
||||
return perPdfMinorUnder1M;
|
||||
}
|
||||
|
||||
public double termDiscount(int termYears) {
|
||||
int idx = Math.max(1, Math.min(termYears, 5)) - 1;
|
||||
return termDiscountByYear[idx];
|
||||
}
|
||||
|
||||
public double serviceLevelUplift(String serviceLevel) {
|
||||
if ("priority".equalsIgnoreCase(serviceLevel)) return priorityUplift;
|
||||
if ("dedicated".equalsIgnoreCase(serviceLevel)) return dedicatedUplift;
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user