diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD
index c02bde345e..5d92425b19 100644
--- a/.github/aur/stirling-pdf-desktop/PKGBUILD
+++ b/.github/aur/stirling-pdf-desktop/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc
pkgname=stirling-pdf-desktop
-pkgver=2.14.0
+pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)"
arch=('x86_64')
diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD
index c73b1c5087..f5a2bf3c6c 100644
--- a/.github/aur/stirling-pdf-server-bin/PKGBUILD
+++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc
pkgname=stirling-pdf-server-bin
-pkgver=2.14.0
+pkgver=2.14.1
pkgrel=1
pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)"
arch=('any')
diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml
index 2d617f8f20..5e85976937 100644
--- a/.github/config/.files.yaml
+++ b/.github/config/.files.yaml
@@ -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"
diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml
index 223490f45f..554100f535 100644
--- a/.github/workflows/ai-engine.yml
+++ b/.github/workflows/ai-engine.yml
@@ -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 = '';
- 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 = '';
- 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
diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml
index 99b3b127a4..9e1a4efb83 100644
--- a/.github/workflows/build-enterprise.yml
+++ b/.github/workflows/build-enterprise.yml
@@ -167,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()
@@ -240,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()
@@ -270,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()
@@ -282,6 +288,19 @@ 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
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0e0a702cc2..8142c37fca 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -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:
@@ -228,6 +243,7 @@ jobs:
- test-build-docker-images
- tauri-build
- ai-engine
+ - generated-models
- pre-commit
- dependency-review
runs-on: ubuntu-latest
@@ -253,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: |
diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml
new file mode 100644
index 0000000000..db9c49fba2
--- /dev/null
+++ b/.github/workflows/check-generated-models.yml
@@ -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 = '';
+ 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 = '';
+ 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,
+ });
+ }
diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml
index 44d5443a73..57a4357dad 100644
--- a/.github/workflows/e2e-live.yml
+++ b/.github/workflows/e2e-live.yml
@@ -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
diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml
index 33bb24ee4c..ccfdf0052f 100644
--- a/.github/workflows/e2e-stubbed.yml
+++ b/.github/workflows/e2e-stubbed.yml
@@ -44,7 +44,18 @@ 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
diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml
index 52277b5b04..4f87226f93 100644
--- a/.taskfiles/frontend.yml
+++ b/.taskfiles/frontend.yml
@@ -396,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]
diff --git a/Taskfile.yml b/Taskfile.yml
index 26895723d0..705ad4a1db 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -185,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
# ============================================================
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java
index 20d7eaf70b..222b89022a 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/PosterPdfRequest.java
@@ -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;
+ }
}
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java
index 541a8717f3..2227803372 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/AddPasswordRequest.java
@@ -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")
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java
index 2bed56f0f0..a7239e8f90 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ai/AiWorkflowOutcome.java
@@ -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;
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
index 4e6c515318..d3b2d21a00 100644
--- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiWorkflowService.java
@@ -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,72 +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 filesById,
- ProgressListener listener) {
- List filesToConvert = response.getFilesToIngest();
- if (filesToConvert == null || filesToConvert.isEmpty()) {
- return new WorkflowState.Terminal(
- cannotContinue(
- "AI engine requested markdown conversion without listing any files."));
- }
-
- try {
- List resultFiles = new ArrayList<>();
- List 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);
- resultFiles.addAll(result.files());
- inputNames.add(multipartFile.getOriginalFilename());
- }
- return new WorkflowState.Terminal(
- buildCompletedResponse(null, resultFiles, 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());
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java
index cc8388b8c2..7c44a40a5d 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceMoreTest.java
@@ -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 {
diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java
index 9e611cf32e..7dc174c041 100644
--- a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java
+++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiWorkflowServiceTest.java
@@ -78,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;
@@ -440,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"));
@@ -464,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
diff --git a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
index 5e90df6bf5..53a5f34c15 100644
--- a/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
+++ b/app/saas/src/main/java/stirling/software/saas/config/SaasJpaConfig.java
@@ -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 {}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java
new file mode 100644
index 0000000000..1f27896b87
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java
@@ -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 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 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 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 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 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 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 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 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 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 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 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();
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java
new file mode 100644
index 0000000000..98ec0cd573
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/config/ProcurementConfigurationProperties.java
@@ -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;
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java
new file mode 100644
index 0000000000..0f0c242601
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/EnterpriseLicenseService.java
@@ -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);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java
new file mode 100644
index 0000000000..bde31a4119
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/MockEnterpriseLicenseService.java
@@ -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);
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java
new file mode 100644
index 0000000000..f97d66f365
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementDeal.java
@@ -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;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java
new file mode 100644
index 0000000000..d8f4c3edb7
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/model/ProcurementQuote.java
@@ -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;
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java
new file mode 100644
index 0000000000..19a3ab759d
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/PricingRates.java
@@ -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.
+ *
+ * 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;
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java
new file mode 100644
index 0000000000..62d5baed9d
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/ProcurementPricingService.java
@@ -0,0 +1,125 @@
+package stirling.software.saas.procurement.pricing;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.springframework.stereotype.Service;
+
+/**
+ * The canonical enterprise pricing engine — the single server-side definition the quote builder,
+ * the order form, and the Stripe checkout all derive from. A faithful port of the marketing
+ * prototype's {@code quotePricing}:
+ *
+ *
+ * annual = volume x perPdfRate x serviceLevelMult x (indemnification ? 1.05 : 1)
+ * annualNet = round(annual x (1 - termDiscount)) + qbr
+ * tcv = annualNet x termYears + training
+ *
+ *
+ * The multi-year discount applies to usage + service level + indemnification, but NOT to the flat
+ * QBR fee (added after), and one-time training sits outside the recurring total. All money is in
+ * minor units (cents). Rates come from {@link PricingRates} (Stripe-backed in prod).
+ */
+@Service
+public class ProcurementPricingService {
+
+ /** Bespoke/committed deals don't price below this ACV; the builder floors against it. */
+ public static final long MIN_ACV_MINOR = 5_000_000L; // $50,000
+
+ /**
+ * Estimated annual PDF volume from seat count (~2,012.5 PDFs/user/yr = 5 docs/day x 230 working
+ * days x 1.75), used to prefill the builder's volume step. Rounded, matching the prototype's
+ * {@code users x 5 x 230 x 1.75}.
+ */
+ public long estimateAnnualVolume(int users) {
+ return Math.round(Math.max(0, users) * 5.0 * 230.0 * 1.75);
+ }
+
+ public QuoteBreakdown price(QuoteConfig cfg) {
+ return price(cfg, PricingRates.defaults());
+ }
+
+ public QuoteBreakdown price(QuoteConfig cfg, PricingRates rates) {
+ // Never trust the client's volume: clamp to non-negative so a tampered request can't drive
+ // a
+ // negative amount. The rate card and formula are server-side, so the browser can't lower
+ // the
+ // price — only pick a smaller, legitimate config. (See MIN_ACV_MINOR for the committed
+ // floor,
+ // a policy decision that is intentionally not force-applied here — see the review notes.)
+ long volume = Math.max(0, cfg.volume());
+ long perPdf = rates.perPdfMinor(volume);
+ long usage = Math.round(volume * (double) perPdf); // base, pre-service-level
+ double slaUplift = rates.serviceLevelUplift(cfg.serviceLevel());
+ long withSla = Math.round(usage * (1.0 + slaUplift));
+ long withIndemnity =
+ cfg.indemnification()
+ ? Math.round(withSla * (1.0 + rates.indemnificationUplift()))
+ : withSla;
+
+ double termDiscount = rates.termDiscount(cfg.termYears());
+ long discount = Math.round(withIndemnity * termDiscount);
+ long qbr = cfg.qbr() ? rates.qbrAnnualMinor() : 0L;
+ long training = cfg.training() ? rates.trainingOneTimeMinor() : 0L;
+
+ long annualNet = (withIndemnity - discount) + qbr;
+ long tcv = annualNet * cfg.termYears() + training;
+
+ List lines = new ArrayList<>();
+ lines.add(
+ new QuoteLineItem("usage", "PDF processing", QuoteLineItem.Kind.RECURRING, usage));
+ lines.add(
+ new QuoteLineItem(
+ "seats",
+ "Unlimited users + SSO / SCIM / RBAC",
+ QuoteLineItem.Kind.INCLUDED,
+ 0L));
+ if (withSla != usage) {
+ lines.add(
+ new QuoteLineItem(
+ "service-level",
+ serviceLevelLabel(cfg.serviceLevel()),
+ QuoteLineItem.Kind.RECURRING,
+ withSla - usage));
+ }
+ if (withIndemnity != withSla) {
+ lines.add(
+ new QuoteLineItem(
+ "indemnification",
+ "IP indemnification",
+ QuoteLineItem.Kind.RECURRING,
+ withIndemnity - withSla));
+ }
+ if (qbr > 0) {
+ lines.add(
+ new QuoteLineItem(
+ "qbr",
+ "Quarterly business reviews",
+ QuoteLineItem.Kind.RECURRING,
+ qbr));
+ }
+ if (discount > 0) {
+ lines.add(
+ new QuoteLineItem(
+ "multi-year",
+ cfg.termYears() + "-year commitment",
+ QuoteLineItem.Kind.DISCOUNT,
+ -discount));
+ }
+ if (training > 0) {
+ lines.add(
+ new QuoteLineItem(
+ "training",
+ "Onboarding & training",
+ QuoteLineItem.Kind.ONE_TIME,
+ training));
+ }
+ return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
+ }
+
+ private static String serviceLevelLabel(String serviceLevel) {
+ if ("priority".equalsIgnoreCase(serviceLevel)) return "Priority service level";
+ if ("dedicated".equalsIgnoreCase(serviceLevel)) return "Dedicated service level";
+ return "Standard service level";
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java
new file mode 100644
index 0000000000..10ffde5271
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteBreakdown.java
@@ -0,0 +1,12 @@
+package stirling.software.saas.procurement.pricing;
+
+import java.util.List;
+
+/**
+ * The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
+ * order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
+ * after the multi-year discount; {@code tcvMinor} is total contract value across the term including
+ * one-time fees. Minor units (cents).
+ */
+public record QuoteBreakdown(
+ List lineItems, long annualNetMinor, long tcvMinor, String currency) {}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java
new file mode 100644
index 0000000000..30917a73a6
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteConfig.java
@@ -0,0 +1,26 @@
+package stirling.software.saas.procurement.pricing;
+
+/**
+ * The buyer-configurable inputs to an enterprise quote. Mirrors the quote builder's four steps
+ * (volume, commitment & service, add-ons) and is the sole input to {@link
+ * ProcurementPricingService}. Amounts are never carried here — the service derives them from these
+ * choices and the rate card.
+ */
+public record QuoteConfig(
+ long volume, // committed PDFs per year
+ int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
+ String deployment, // cloud | selfhost | airgap (inherited from the trial; not priced)
+ int termYears, // 1..5
+ String serviceLevel, // standard | priority | dedicated
+ boolean indemnification,
+ boolean training,
+ boolean qbr,
+ String currency) { // USD | EUR | GBP
+
+ public QuoteConfig {
+ if (termYears < 1) termYears = 1;
+ if (termYears > 5) termYears = 5;
+ if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
+ if (currency == null || currency.isBlank()) currency = "USD";
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java
new file mode 100644
index 0000000000..a8b0d29352
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/pricing/QuoteLineItem.java
@@ -0,0 +1,16 @@
+package stirling.software.saas.procurement.pricing;
+
+/**
+ * One line on the itemised quote. {@code amountMinor} is in the currency's minor unit (cents);
+ * discounts are negative. {@code kind} drives how the portal groups it (recurring annual vs a
+ * one-time fee vs a discount line).
+ */
+public record QuoteLineItem(String key, String label, Kind kind, long amountMinor) {
+
+ public enum Kind {
+ RECURRING,
+ ONE_TIME,
+ DISCOUNT,
+ INCLUDED
+ }
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java
new file mode 100644
index 0000000000..14247ba1ba
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementDealRepository.java
@@ -0,0 +1,17 @@
+package stirling.software.saas.procurement.repository;
+
+import java.util.Optional;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import stirling.software.saas.procurement.model.ProcurementDeal;
+
+public interface ProcurementDealRepository extends JpaRepository {
+
+ Optional findByTeamId(Long teamId);
+
+ boolean existsByTeamId(Long teamId);
+
+ /** Reset: drop the team's deal (quotes + activity cascade via FK). */
+ void deleteByTeamId(Long teamId);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java
new file mode 100644
index 0000000000..45f7af8225
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/repository/ProcurementQuoteRepository.java
@@ -0,0 +1,12 @@
+package stirling.software.saas.procurement.repository;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import stirling.software.saas.procurement.model.ProcurementQuote;
+
+public interface ProcurementQuoteRepository extends JpaRepository {
+
+ List findByDealIdOrderByCreatedAtDesc(Long dealId);
+}
diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java
new file mode 100644
index 0000000000..52e19f751e
--- /dev/null
+++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java
@@ -0,0 +1,244 @@
+package stirling.software.saas.procurement.service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+import java.util.UUID;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
+import stirling.software.saas.procurement.license.EnterpriseLicenseService;
+import stirling.software.saas.procurement.model.ProcurementDeal;
+import stirling.software.saas.procurement.model.ProcurementQuote;
+import stirling.software.saas.procurement.pricing.ProcurementPricingService;
+import stirling.software.saas.procurement.pricing.QuoteBreakdown;
+import stirling.software.saas.procurement.pricing.QuoteConfig;
+import stirling.software.saas.procurement.repository.ProcurementDealRepository;
+import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
+
+/**
+ * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
+ * server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the
+ * portal calls with the accepted quote; on payment the webhook seeds {@code billing_subscriptions}
+ * and this service issues the annual licence. All amounts are minor units (cents).
+ */
+@Slf4j
+@Service
+@Profile("saas")
+public class ProcurementService {
+
+ // Local mapper for the line-items JSON snapshot; the saas context exposes no injectable
+ // ObjectMapper bean, and this (de)serialisation doesn't need Spring's configured one.
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final ProcurementDealRepository dealRepo;
+ private final ProcurementQuoteRepository quoteRepo;
+ private final ProcurementPricingService pricing;
+ private final EnterpriseLicenseService licenses;
+ private final ProcurementConfigurationProperties config;
+
+ public ProcurementService(
+ ProcurementDealRepository dealRepo,
+ ProcurementQuoteRepository quoteRepo,
+ ProcurementPricingService pricing,
+ EnterpriseLicenseService licenses,
+ ProcurementConfigurationProperties config) {
+ this.dealRepo = dealRepo;
+ this.quoteRepo = quoteRepo;
+ this.pricing = pricing;
+ this.licenses = licenses;
+ this.config = config;
+ }
+
+ @Transactional(readOnly = true)
+ public Optional getDeal(Long teamId) {
+ return dealRepo.findByTeamId(teamId);
+ }
+
+ @Transactional(readOnly = true)
+ public List quotesForDeal(Long dealId) {
+ return quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId);
+ }
+
+ /**
+ * Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
+ * window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
+ * Keygen licence, and the deal row is the journey state.
+ */
+ @Transactional
+ public ProcurementDeal startTrial(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
+ deal.setStage(ProcurementDeal.STAGE_TRIAL);
+ deal.setTrialStartedAt(now);
+ deal.setTrialEndsAt(ends);
+ deal.setTrialExtensionsUsed(0);
+ deal.setLicenseRef(licenses.issueTrialLicense(teamId, ends));
+ deal = dealRepo.save(deal);
+ log.info(
+ "[procurement] trial started team={} deal={} ends={}",
+ teamId,
+ deal.getDealId(),
+ ends);
+ return deal;
+ }
+
+ /** Extend the current trial by the configured increment, up to the cap. */
+ @Transactional
+ public ProcurementDeal extendTrial(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ // Only extend while still in the trial. Past the trial (e.g. active), licenseRef points at
+ // the committed annual licence — extending would rewind its expiry.
+ if (!ProcurementDeal.STAGE_TRIAL.equals(deal.getStage())) {
+ throw new IllegalStateException("Trial extension only allowed during the trial stage");
+ }
+ if (deal.getTrialExtensionsUsed() >= config.getMaxTrialExtensions()) {
+ throw new IllegalStateException("Trial extension cap reached");
+ }
+ LocalDateTime base =
+ deal.getTrialEndsAt() != null ? deal.getTrialEndsAt() : LocalDateTime.now();
+ LocalDateTime newEnd = base.plusDays(config.getTrialExtensionDays());
+ deal.setTrialEndsAt(newEnd);
+ deal.setTrialExtensionsUsed(deal.getTrialExtensionsUsed() + 1);
+ if (deal.getLicenseRef() != null) {
+ licenses.extendLicense(deal.getLicenseRef(), newEnd);
+ }
+ return dealRepo.save(deal);
+ }
+
+ /** Price a quote config server-side and persist it as a draft against the team's deal. */
+ @Transactional
+ public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
+ if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
+ throw new IllegalStateException("Cannot rebuild a quote on a live deal");
+ }
+ // (Re)building a quote returns the deal to the quote stage and drops any prior acceptance,
+ // so a rebuild from security/payment can't leave a stale stage or accepted-quote pointer.
+ deal.setStage(ProcurementDeal.STAGE_QUOTE);
+ deal.setAcceptedQuoteId(null);
+ deal = dealRepo.save(deal);
+
+ QuoteBreakdown breakdown = pricing.price(cfg);
+
+ ProcurementQuote quote = new ProcurementQuote();
+ quote.setDealId(deal.getDealId());
+ quote.setQuoteNumber(nextQuoteNumber(deal.getDealId()));
+ // Priced but not yet issued: the edge fn creates the Stripe Quote and flips this to SENT.
+ quote.setStatus(ProcurementQuote.STATUS_DRAFT);
+ quote.setCurrency(cfg.currency());
+ quote.setVolume(cfg.volume());
+ quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
+ quote.setDeployment(cfg.deployment());
+ quote.setTermYears(cfg.termYears());
+ quote.setServiceLevel(cfg.serviceLevel());
+ quote.setIndemnification(cfg.indemnification());
+ quote.setTraining(cfg.training());
+ quote.setQbr(cfg.qbr());
+ quote.setBusinessName(businessName);
+ quote.setAnnualNetMinor(breakdown.annualNetMinor());
+ quote.setTcvMinor(breakdown.tcvMinor());
+ quote.setLineItemsJson(writeLineItems(breakdown));
+ quote.setValidUntil(LocalDate.now().plusDays(30));
+ quote = quoteRepo.save(quote);
+ log.info(
+ "[procurement] quote built team={} quote={} annualNet={} tcv={}",
+ teamId,
+ quote.getQuoteNumber(),
+ quote.getAnnualNetMinor(),
+ quote.getTcvMinor());
+ return quote;
+ }
+
+ /**
+ * Advance the deal to the agreement (security) stage: the buyer has an issued quote and is
+ * reviewing the enterprise agreement before it's accepted into a subscription. Requires an
+ * issued quote on the deal.
+ */
+ @Transactional
+ public ProcurementDeal startAgreement(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ boolean hasIssuedQuote =
+ quoteRepo.findByDealIdOrderByCreatedAtDesc(deal.getDealId()).stream()
+ .anyMatch(q -> ProcurementQuote.STATUS_SENT.equals(q.getStatus()));
+ if (!hasIssuedQuote) {
+ throw new IllegalStateException("No issued quote for team " + teamId);
+ }
+ deal.setStage(ProcurementDeal.STAGE_AGREEMENT);
+ deal = dealRepo.save(deal);
+ log.info("[procurement] agreement stage team={} deal={}", teamId, deal.getDealId());
+ return deal;
+ }
+
+ /**
+ * Mark the deal live: issue the annual licence and advance to the active stage. In production
+ * this is driven by the {@code invoice.paid} webhook once the first invoice is settled; this
+ * method is the demo/manual stand-in until that webhook lands.
+ */
+ @Transactional
+ public ProcurementDeal markLive(Long teamId) {
+ ProcurementDeal deal =
+ dealRepo.findByTeamId(teamId)
+ .orElseThrow(() -> new IllegalStateException("No deal for team " + teamId));
+ int term = 1;
+ String deployment = "cloud";
+ if (deal.getAcceptedQuoteId() != null) {
+ ProcurementQuote q = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
+ if (q != null) {
+ term = Math.max(1, q.getTermYears());
+ if (q.getDeployment() != null && !q.getDeployment().isBlank()) {
+ deployment = q.getDeployment();
+ }
+ }
+ }
+ deal.setLicenseRef(
+ licenses.issueAnnualLicense(
+ teamId, deployment, LocalDateTime.now().plusYears(term)));
+ deal.setStage(ProcurementDeal.STAGE_LIVE);
+ deal = dealRepo.save(deal);
+ log.info("[procurement] deal live team={} deal={}", teamId, deal.getDealId());
+ return deal;
+ }
+
+ /**
+ * Reset a team's procurement: delete the deal (quotes + activity cascade). For
+ * re-demos/testing.
+ */
+ @Transactional
+ public void resetDeal(Long teamId) {
+ dealRepo.deleteByTeamId(teamId);
+ log.info("[procurement] deal reset team={}", teamId);
+ }
+
+ private String nextQuoteNumber(Long dealId) {
+ int seq = quoteRepo.findByDealIdOrderByCreatedAtDesc(dealId).size() + 1;
+ String token = UUID.randomUUID().toString().substring(0, 4).toUpperCase(Locale.ROOT);
+ return String.format(Locale.ROOT, "QT-%s-%04d", token, seq);
+ }
+
+ private String writeLineItems(QuoteBreakdown breakdown) {
+ try {
+ return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems());
+ } catch (JsonProcessingException e) {
+ log.warn("[procurement] failed to serialise line items", e);
+ return "[]";
+ }
+ }
+}
diff --git a/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql
new file mode 100644
index 0000000000..0048f999c1
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V27__procurement.sql
@@ -0,0 +1,72 @@
+-- Enterprise procurement: the tables that track a linked team's journey from trial to live.
+--
+-- One deal per team (the commercial journey: trial -> quote -> agreement -> payment -> live), the
+-- quotes built against it (the itemised, priced offers), and an append-only activity log for the
+-- money/licence-touching actions. The resulting subscription is mirrored in billing_subscriptions
+-- (seeded on trial start / payment); the entitlement that unlocks the product is a Keygen licence
+-- referenced by procurement_deal.license_ref. Prices are computed server-side (ProcurementPricingService).
+--
+-- Additive and idempotent (IF NOT EXISTS) — safe on the shared dev branch.
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_deal (
+ deal_id BIGSERIAL PRIMARY KEY,
+ team_id BIGINT NOT NULL UNIQUE REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
+ -- one active deal per team; the journey lives on this row.
+ stage VARCHAR(32) NOT NULL DEFAULT 'trial',
+ -- trial | quote | security (agreement) | procurement (payment) | active (live)
+ trial_started_at TIMESTAMP,
+ trial_ends_at TIMESTAMP,
+ trial_extensions_used INT NOT NULL DEFAULT 0,
+ license_ref VARCHAR(128),
+ -- Keygen licence id issued for this deal (trial or annual). Mocked until Keygen mgmt lands.
+ subscription_id VARCHAR(255),
+ -- Stripe subscription id, mirrored into billing_subscriptions once commercial.
+ accepted_quote_id BIGINT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_quote (
+ quote_id BIGSERIAL PRIMARY KEY,
+ deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
+ quote_number VARCHAR(64) NOT NULL,
+ status VARCHAR(24) NOT NULL DEFAULT 'draft',
+ -- draft | sent | accepted | expired
+ currency VARCHAR(8) NOT NULL DEFAULT 'USD',
+ volume BIGINT NOT NULL,
+ seats INT,
+ deployment VARCHAR(24),
+ term_years INT NOT NULL,
+ service_level VARCHAR(24) NOT NULL,
+ indemnification BOOLEAN NOT NULL DEFAULT FALSE,
+ training BOOLEAN NOT NULL DEFAULT FALSE,
+ qbr BOOLEAN NOT NULL DEFAULT FALSE,
+ annual_net_minor BIGINT NOT NULL,
+ -- recurring annual fee after the multi-year discount, in minor units (cents).
+ tcv_minor BIGINT NOT NULL,
+ -- total contract value across the term incl. one-time fees, minor units.
+ line_items TEXT,
+ -- JSON snapshot of the itemised lines the order form renders.
+ stripe_price_id VARCHAR(128),
+ checkout_session_id VARCHAR(255),
+ checkout_url TEXT,
+ valid_until DATE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ version BIGINT NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS stirling_pdf.procurement_activity (
+ activity_id BIGSERIAL PRIMARY KEY,
+ deal_id BIGINT NOT NULL REFERENCES stirling_pdf.procurement_deal(deal_id) ON DELETE CASCADE,
+ actor_user_id BIGINT,
+ -- the internal/portal user who took the action; informational (no FK).
+ action VARCHAR(48) NOT NULL,
+ -- trial_started | trial_extended | quote_built | quote_accepted | checkout_created | went_live ...
+ detail TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_procurement_quote_deal ON stirling_pdf.procurement_quote (deal_id);
+CREATE INDEX IF NOT EXISTS idx_procurement_activity_deal ON stirling_pdf.procurement_activity (deal_id);
diff --git a/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql
new file mode 100644
index 0000000000..5d83ee35f8
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V28__procurement_stripe_quote.sql
@@ -0,0 +1,8 @@
+-- Stripe Quote support: a procurement quote is issued as a real Stripe Quote (finalized → PDF +
+-- shareable), and on acceptance Stripe creates the committed subscription + first invoice. The
+-- Stripe operations live in Supabase edge functions; these columns hold the references they write
+-- back. Twin of Supabase migration 20260703000000_procurement_stripe_quote.sql.
+
+ALTER TABLE stirling_pdf.procurement_quote
+ ADD COLUMN IF NOT EXISTS stripe_quote_id VARCHAR(128),
+ ADD COLUMN IF NOT EXISTS stripe_invoice_url TEXT;
diff --git a/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql
new file mode 100644
index 0000000000..ba9c74dde8
--- /dev/null
+++ b/app/saas/src/main/resources/db/migration/saas/V29__procurement_business_name.sql
@@ -0,0 +1,5 @@
+-- Persist the buyer's company name on the quote so re-editing remembers it and it can be shown on
+-- the quote/agreement. Twin of Supabase migration 20260705000000_procurement_business_name.sql.
+
+ALTER TABLE stirling_pdf.procurement_quote
+ ADD COLUMN IF NOT EXISTS business_name VARCHAR(255);
diff --git a/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java
new file mode 100644
index 0000000000..1f903a1ee2
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/procurement/pricing/ProcurementPricingServiceTest.java
@@ -0,0 +1,87 @@
+package stirling.software.saas.procurement.pricing;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+import stirling.software.saas.procurement.pricing.QuoteLineItem.Kind;
+
+/**
+ * Locks the pricing engine to the numbers the marketing prototype encodes — most importantly the
+ * canonical quote QT-AC9F-0001 (1M PDFs, priority, 3-year) = $41,400/yr, $124,200 TCV.
+ */
+class ProcurementPricingServiceTest {
+
+ private final ProcurementPricingService pricing = new ProcurementPricingService();
+
+ private static QuoteConfig cfg(long volume, String sla, int term) {
+ return new QuoteConfig(volume, 0, "cloud", term, sla, false, false, false, "USD");
+ }
+
+ @Test
+ void canonicalQuoteMatchesPrototype() {
+ QuoteBreakdown q = pricing.price(cfg(1_000_000, "priority", 3));
+
+ assertThat(q.annualNetMinor()).isEqualTo(4_140_000L); // $41,400
+ assertThat(q.tcvMinor()).isEqualTo(12_420_000L); // $124,200
+ assertThat(lineAmount(q, "usage")).isEqualTo(4_000_000L); // $40,000 @ $0.04
+ assertThat(lineAmount(q, "service-level")).isEqualTo(600_000L); // +15%
+ assertThat(lineAmount(q, "multi-year")).isEqualTo(-460_000L); // -10%
+ }
+
+ @Test
+ void volumeBandsPickTheRightPerPdfRate() {
+ assertThat(lineAmount(pricing.price(cfg(500_000, "standard", 1)), "usage"))
+ .isEqualTo(2_500_000L); // 500k @ $0.05
+ assertThat(lineAmount(pricing.price(cfg(1_000_000, "standard", 1)), "usage"))
+ .isEqualTo(4_000_000L); // 1M @ $0.04
+ assertThat(lineAmount(pricing.price(cfg(5_000_000, "standard", 1)), "usage"))
+ .isEqualTo(15_000_000L); // 5M @ $0.03
+ }
+
+ @Test
+ void addOnsAndTermStack() {
+ QuoteConfig c =
+ new QuoteConfig(1_000_000, 0, "cloud", 5, "dedicated", true, true, true, "USD");
+ QuoteBreakdown q = pricing.price(c);
+
+ long usage = 4_000_000L;
+ long withSla = Math.round(usage * 1.30); // 5,200,000
+ long withIndemnity = Math.round(withSla * 1.05); // 5,460,000
+ long discount = Math.round(withIndemnity * 0.15); // 819,000
+ long qbr = 800_000L;
+ long expectedAnnual = (withIndemnity - discount) + qbr;
+ long expectedTcv = expectedAnnual * 5 + 750_000L; // + training one-time
+
+ assertThat(q.annualNetMinor()).isEqualTo(expectedAnnual);
+ assertThat(q.tcvMinor()).isEqualTo(expectedTcv);
+ assertThat(q.lineItems())
+ .anyMatch(l -> l.key().equals("training") && l.kind() == Kind.ONE_TIME);
+ assertThat(q.lineItems()).anyMatch(l -> l.key().equals("qbr"));
+ assertThat(q.lineItems()).anyMatch(l -> l.key().equals("indemnification"));
+ }
+
+ @Test
+ void standardSingleYearHasNoUpliftOrDiscountLines() {
+ QuoteBreakdown q = pricing.price(cfg(1_000_000, "standard", 1));
+ assertThat(q.annualNetMinor()).isEqualTo(4_000_000L);
+ assertThat(q.tcvMinor()).isEqualTo(4_000_000L);
+ assertThat(q.lineItems()).noneMatch(l -> l.key().equals("service-level"));
+ assertThat(q.lineItems()).noneMatch(l -> l.key().equals("multi-year"));
+ }
+
+ @Test
+ void volumeEstimateFromSeats() {
+ // ~2,013 PDFs/user/yr
+ assertThat(pricing.estimateAnnualVolume(100)).isEqualTo(201_250L);
+ assertThat(pricing.estimateAnnualVolume(0)).isZero();
+ }
+
+ private static long lineAmount(QuoteBreakdown q, String key) {
+ return q.lineItems().stream()
+ .filter(l -> l.key().equals(key))
+ .mapToLong(QuoteLineItem::amountMinor)
+ .findFirst()
+ .orElseThrow();
+ }
+}
diff --git a/build.gradle b/build.gradle
index 3946b32449..5c89fd7af2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -91,7 +91,7 @@ springBoot {
allprojects {
group = 'stirling.software'
- version = '2.14.0'
+ version = '2.14.1'
configurations.configureEach {
exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat"
diff --git a/engine/scripts/generate_tool_models.py b/engine/scripts/generate_tool_models.py
index 9bc1458fea..dfe461fcca 100644
--- a/engine/scripts/generate_tool_models.py
+++ b/engine/scripts/generate_tool_models.py
@@ -59,6 +59,36 @@ class ToolDiscovery:
"/api/v1/convert/",
)
+ # Endpoints under the allowed prefixes that are NOT edit-agent operations. A listed
+ # path and everything nested under it is dropped. Several kinds live here:
+ EXCLUDED_PATHS = (
+ # 1. Cert-signing family: needs certificate/key files the agent can't supply, plus
+ # interactive session and hardware-token management. The whole subtree is dropped.
+ "/api/v1/security/cert-sign",
+ # 2. Interactive PDF text-editor endpoints, not one-shot operations.
+ "/api/v1/convert/pdf/text-editor",
+ "/api/v1/convert/text-editor/pdf",
+ # 3. Introspection / query endpoints that return metadata, a listing, or a
+ # verification verdict rather than a transformed document, so they belong to
+ # the question path, not the edit agent. (decompress is a dev-only stream op.)
+ "/api/v1/security/get-info-on-pdf",
+ "/api/v1/security/verify-pdf",
+ "/api/v1/security/validate-signature",
+ "/api/v1/misc/list-attachments",
+ "/api/v1/misc/show-javascript",
+ "/api/v1/misc/decompress-pdf",
+ "/api/v1/general/extract-bookmarks",
+ # 4. Require a secondary file (image, overlay PDF, attachments) on top of the input
+ # PDF. The agent only ever supplies the input PDF(s), so these can never run.
+ # (add-stamp / add-watermark stay: their text mode needs no extra file.)
+ "/api/v1/misc/add-image",
+ "/api/v1/misc/add-attachments",
+ "/api/v1/general/overlay-pdfs",
+ )
+
+ def _is_excluded(self, path: str) -> bool:
+ return any(path == p or path.startswith(p + "/") for p in self.EXCLUDED_PATHS)
+
def __init__(self, spec: dict[str, Any]):
resource = Resource.from_contents(spec, default_specification=DRAFT202012)
self.resolver = Registry().with_resource("", resource).resolver()
@@ -73,17 +103,15 @@ class ToolDiscovery:
for path, path_item in sorted(self.spec.get("paths", {}).items()):
if "{" in path or not any(path.startswith(p) for p in self.ALLOWED_PATH_PREFIXES):
continue
+ if self._is_excluded(path):
+ continue
body_schema = self._get_request_body_schema(path_item) or {}
query_props = self._get_query_parameters(path_item)
body_props = body_schema.get("properties") or {}
# Body properties win on name collision — body is the canonical param source
# for the existing tools; query params are additive.
properties = {**query_props, **body_props}
- if not properties:
- continue
clean_props = self._filter_properties(properties)
- if not clean_props:
- continue
enum_name = _deduplicate(_path_to_enum_name(path), used_enum)
class_name = _deduplicate(_path_to_class_name(path), used_class)
diff --git a/engine/src/stirling/agents/orchestrator.py b/engine/src/stirling/agents/orchestrator.py
index c73d9dab32..e07e771e34 100644
--- a/engine/src/stirling/agents/orchestrator.py
+++ b/engine/src/stirling/agents/orchestrator.py
@@ -15,7 +15,6 @@ from stirling.agents.pdf_review import PdfReviewAgent
from stirling.agents.user_spec import UserSpecAgent
from stirling.contracts import (
AgentDraftWorkflowResponse,
- ConvertMarkdownResponse,
ExtractedTextArtifact,
OrchestratorRequest,
OrchestratorResponse,
@@ -48,7 +47,7 @@ class OrchestratorAgent:
ToolOutput(
self.delegate_pdf_edit,
name="delegate_pdf_edit",
- description="Delegate requests for PDF modifications and return the PDF edit result.",
+ description="Delegate requests to modify or convert PDFs and return the PDF edit result.",
),
ToolOutput(
self.delegate_pdf_question,
@@ -71,13 +70,6 @@ class OrchestratorAgent:
" feedback')."
),
),
- ToolOutput(
- self.delegate_pdf_ingest,
- name="delegate_pdf_ingest",
- description=(
- "Delegate requests to convert a PDF to Markdown or extract its content as readable text."
- ),
- ),
ToolOutput(
self.delegate_pdf_create,
name="delegate_pdf_create",
@@ -98,7 +90,7 @@ class OrchestratorAgent:
system_prompt=(
"You are the top-level orchestrator. "
"Choose exactly one output function that best handles the request. "
- "Use delegate_pdf_edit for any requested modification of one or more PDFs. "
+ "Use delegate_pdf_edit for any request to modify or convert one or more PDFs. "
"Use delegate_pdf_question for questions about the contents of the attached PDFs. "
"Use delegate_user_spec for requests to create or define an agent spec. "
"Use delegate_pdf_review when the user wants the PDF returned with review"
@@ -106,8 +98,6 @@ class OrchestratorAgent:
" 'leave feedback on the PDF'. "
"Use delegate_pdf_create when the user wants to generate a new document from"
" scratch with no input file — invoices, reports, letters, contracts, etc. "
- "Use delegate_pdf_ingest for any request to convert a PDF to Markdown "
- "or extract its content as readable text. "
"Use unsupported_capability when the user asks about the assistant itself "
"or when none of the other outputs fit; supply a helpful message."
),
@@ -177,13 +167,6 @@ class OrchestratorAgent:
async def _run_agent_draft(self, request: OrchestratorRequest) -> AgentDraftWorkflowResponse:
return await UserSpecAgent(self.runtime).orchestrate(request)
- async def delegate_pdf_ingest(self, ctx: RunContext[OrchestratorDeps]) -> ConvertMarkdownResponse:
- request = ctx.deps.request
- return ConvertMarkdownResponse(
- reason="PDF to Markdown requested — Java converts deterministically.",
- files_to_ingest=request.files,
- )
-
async def delegate_pdf_review(self, ctx: RunContext[OrchestratorDeps]) -> PdfReviewOrchestrateResponse:
return await self._run_pdf_review(ctx.deps.request)
diff --git a/engine/src/stirling/contracts/__init__.py b/engine/src/stirling/contracts/__init__.py
index 6c8d99d120..77ce40301e 100644
--- a/engine/src/stirling/contracts/__init__.py
+++ b/engine/src/stirling/contracts/__init__.py
@@ -13,7 +13,6 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
- ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
MathAuditorToolReportArtifact,
@@ -163,7 +162,6 @@ __all__ = [
"NeedContentFileRequest",
"NeedContentResponse",
"NeedIngestResponse",
- "ConvertMarkdownResponse",
"NextExecutionAction",
"OrchestratorRequest",
"OrchestratorResponse",
diff --git a/engine/src/stirling/contracts/common.py b/engine/src/stirling/contracts/common.py
index 8f35c9ceb4..d9105bad4e 100644
--- a/engine/src/stirling/contracts/common.py
+++ b/engine/src/stirling/contracts/common.py
@@ -62,7 +62,6 @@ class WorkflowOutcome(StrEnum):
CANNOT_CONTINUE = "cannot_continue"
UNSUPPORTED_CAPABILITY = "unsupported_capability"
GENERATE_FILE = "generate_file"
- CONVERT_MARKDOWN = "convert_markdown"
class ArtifactKind(StrEnum):
@@ -184,19 +183,6 @@ class NeedIngestResponse(ApiModel):
content_types: list[PdfContentType] = Field(default_factory=list)
-class ConvertMarkdownResponse(ApiModel):
- """Terminal signal: convert the listed files to Markdown deterministically.
-
- This is a deterministic, non-AI conversion. Java runs the PDF→Markdown converter
- (``PdfMarkdownConverter``) on each file and returns the resulting ``.md`` file(s) as a
- completed result. There is no resume turn — the conversion output is the final answer.
- """
-
- outcome: Literal[WorkflowOutcome.CONVERT_MARKDOWN] = WorkflowOutcome.CONVERT_MARKDOWN
- reason: str
- files_to_ingest: list[AiFile]
-
-
class ToolOperationStep(ApiModel):
kind: Literal[StepKind.TOOL] = StepKind.TOOL
tool: AnyToolId
diff --git a/engine/src/stirling/contracts/orchestrator.py b/engine/src/stirling/contracts/orchestrator.py
index 8b916ccaff..2d85853c51 100644
--- a/engine/src/stirling/contracts/orchestrator.py
+++ b/engine/src/stirling/contracts/orchestrator.py
@@ -11,7 +11,6 @@ from .common import (
AiFile,
ArtifactKind,
ConversationMessage,
- ConvertMarkdownResponse,
ExtractedFileText,
GenerateFileResponse,
NeedContentResponse,
@@ -61,7 +60,6 @@ type OrchestratorResponse = Annotated[
| GenerateFileResponse
| NeedContentResponse
| NeedIngestResponse
- | ConvertMarkdownResponse
| AgentDraftResponse
| NextExecutionAction
| UnsupportedCapabilityResponse,
diff --git a/engine/src/stirling/models/tool_models.py b/engine/src/stirling/models/tool_models.py
index 3e179eaf0c..1827730283 100644
--- a/engine/src/stirling/models/tool_models.py
+++ b/engine/src/stirling/models/tool_models.py
@@ -11,13 +11,6 @@ from pydantic import Field, RootModel, SecretStr
from stirling.models.base import ApiModel
-class AddAttachmentsParams(ApiModel):
- attachments: list[bytes] = Field(..., description="The image file to be overlaid onto the PDF.")
- convert_to_pdf_a3b: bool = Field(
- False, description="Convert the resulting PDF to PDF/A-3b format after adding attachments"
- )
-
-
class AddCommentsParams(ApiModel):
comments: str = Field(
...,
@@ -28,12 +21,6 @@ class AddCommentsParams(ApiModel):
)
-class AddImageParams(ApiModel):
- every_page: bool = Field(False, description="Whether to overlay the image onto every page of the PDF.")
- x: float = Field(0, description="The x-coordinate at which to place the top-left corner of the image.")
- y: float = Field(0, description="The y-coordinate at which to place the top-left corner of the image.")
-
-
class CustomMargin(StrEnum):
"""
Custom margin: small/medium/large/x-large
@@ -107,7 +94,7 @@ class KeyLength(IntEnum):
class AddPasswordParams(ApiModel):
- key_length: KeyLength = Field(..., description="The length of the encryption key")
+ key_length: KeyLength = Field(KeyLength.integer_256, description="The length of the encryption key")
owner_password: SecretStr | None = Field(
None,
description="The owner password to be added to the PDF file (Restricts what can be done with the document once it is opened)",
@@ -312,50 +299,6 @@ class CbzToPdfParams(ApiModel):
optimize_for_ebook: bool = Field(False, description="Optimize the output PDF for ebook reading using Ghostscript")
-class CertType(StrEnum):
- """
- The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.
- """
-
- pem = "PEM"
- pkcs12 = "PKCS12"
- pfx = "PFX"
- jks = "JKS"
- server = "SERVER"
- windows_store = "WINDOWS_STORE"
- pkcs11 = "PKCS11"
-
-
-class CertSignParams(ApiModel):
- alias: str | None = Field(
- None,
- description="The alias of the certificate to sign with. Required for WINDOWS_STORE and recommended for PKCS11 tokens holding multiple certificates.",
- )
- cert_type: CertType = Field(
- ...,
- description="The type of the digital certificate. WINDOWS_STORE and PKCS11 are hardware-backed and only available in the desktop app.",
- )
- location: str = Field("SPDF", description="The location where the PDF is signed")
- name: str = Field("SPDF", description="The name of the signer")
- page_number: int = Field(
- 1,
- description="The page number where the signature should be visible. This is required if showSignature is set to true",
- )
- password: SecretStr | None = Field(
- None, description="The password for the keystore / private key, or the token PIN for PKCS11"
- )
- pkcs11_library_path: str | None = Field(
- None,
- description="Absolute path to the PKCS#11 driver library (required for PKCS11 type). Must be an allowed driver - a detected one or configured via STIRLING_PKCS11_LIBRARIES.",
- )
- pkcs11_slot: int | None = Field(
- None, description="Optional PKCS#11 slot index. When omitted the first slot with a token is used."
- )
- reason: str = Field("Signed by SPDF", description="The reason for signing the PDF")
- show_logo: bool = Field(True, description="Whether to visually show a signature logo along with the signature")
- show_signature: bool = Field(False, description="Whether to visually show the signature in the PDF file")
-
-
class LineArtEdgeLevel(IntEnum):
"""
Edge detection strength to use for line art conversion (1-3). This maps to ImageMagick's -edge radius.
@@ -525,6 +468,10 @@ class EmlToPdfParams(ApiModel):
)
+class ExtractAttachmentsParams(ApiModel):
+ pass
+
+
class ExtractImageScansParams(ApiModel):
angle_threshold: int = Field(5, description="The angle threshold for the image scan extraction")
border_size: int = Field(1, description="The border size for the image scan extraction")
@@ -547,6 +494,10 @@ class ExtractImagesParams(ApiModel):
format: Format = Field(Format.png, description="The output image format e.g., 'png', 'jpeg', or 'gif'")
+class FileToPdfParams(ApiModel):
+ pass
+
+
class FlattenParams(ApiModel):
flatten_only_forms: bool = Field(
False, description="True to flatten only the forms, false to flatten full PDF (Convert page to image)"
@@ -602,6 +553,10 @@ class ImgToPdfParams(ApiModel):
)
+class MarkdownToPdfParams(ApiModel):
+ pass
+
+
class SortType(StrEnum):
"""
The type of sorting to be applied on the input files before merging.
@@ -750,41 +705,6 @@ class OcrPdfParams(ApiModel):
sidecar: bool | None = Field(None, description="Include OCR text in a sidecar text file if set to true")
-class OverlayMode(StrEnum):
- """
- The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts
- """
-
- sequential_overlay = "SequentialOverlay"
- interleaved_overlay = "InterleavedOverlay"
- fixed_repeat_overlay = "FixedRepeatOverlay"
-
-
-class OverlayPosition(Enum):
- """
- Overlay position 0 is Foregound, 1 is Background
- """
-
- number_0 = 0
- number_1 = 1
-
-
-class OverlayPdfsParams(ApiModel):
- counts: list[int] | None = Field(
- None,
- description="An array of integers specifying the number of times each corresponding overlay file should be applied in the 'FixedRepeatOverlay' mode. This should match the length of the overlayFiles array.",
- )
- overlay_files: list[bytes] = Field(
- ...,
- description="An array of PDF files to be used as overlays on the base PDF. The order in these files is applied based on the selected mode.",
- )
- overlay_mode: OverlayMode = Field(
- ...,
- description="The mode of overlaying: 'SequentialOverlay' for sequential application, 'InterleavedOverlay' for round-robin application, 'FixedRepeatOverlay' for fixed repetition based on provided counts",
- )
- overlay_position: OverlayPosition = Field(..., description="Overlay position 0 is Foregound, 1 is Background")
-
-
class PdfToCbrParams(ApiModel):
dpi: int = Field(..., description="The DPI (Dots Per Inch) for rendering PDF pages as images", examples=[150])
@@ -841,6 +761,12 @@ class PdfToEpubParams(ApiModel):
)
+class PdfToHtmlParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class ImageFormat(StrEnum):
"""
The output image format
@@ -877,6 +803,12 @@ class PdfToImgParams(ApiModel):
)
+class PdfToMarkdownParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class OutputFormat1(StrEnum):
"""
The output format type (PDF/A or PDF/X)
@@ -912,13 +844,11 @@ class PdfToPresentationParams(ApiModel):
output_format: OutputFormat2 = Field(..., description="The output Presentation format")
-class PdfToTextEditorParams(ApiModel):
+class PdfToSinglePageParams(ApiModel):
"""
Either upload a file or provide a server-side file ID
"""
- lightweight: bool = False
-
class OutputFormat3(StrEnum):
"""
@@ -979,10 +909,10 @@ class PdfToXlsxParams(ApiModel):
)
-class Pkcs11CertificatesParams(ApiModel):
- library_path: str | None = None
- pin: str | None = None
- slot: int | None = None
+class PdfToXmlParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
class CustomMode(StrEnum):
@@ -1061,6 +991,18 @@ class RemoveBlanksParams(ApiModel):
)
+class RemoveCertSignParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
+class RemoveImagePdfParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class RemovePagesParams(ApiModel):
page_numbers: str = Field(
"all",
@@ -1077,6 +1019,12 @@ class RenameAttachmentParams(ApiModel):
new_name: str = Field(..., description="The new name for the attachment")
+class RepairParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class HighContrastColorCombination(StrEnum):
"""
If HIGH_CONTRAST_COLOR option selected, then pick the default color option for text and background.
@@ -1237,27 +1185,6 @@ class ScannerEffectParams(ApiModel):
yellowish: bool | None = Field(None, description="Simulate yellowed paper", examples=[False])
-class WorkflowType(StrEnum):
- signing = "SIGNING"
- review = "REVIEW"
- approval = "APPROVAL"
-
-
-class Request(ApiModel):
- document_name: str | None = None
- due_date: str | None = None
- message: str | None = None
- owner_email: str | None = None
- participant_emails: list[str] | None = None
- participant_user_ids: list[int] | None = None
- workflow_metadata: str | None = None
- workflow_type: WorkflowType | None = None
-
-
-class SessionsParams(ApiModel):
- request: Request | None = None
-
-
class SplitBySizeOrCountParams(ApiModel):
split_type: int = Field(
0, description="Determines the type of split: 0 for size, 1 for page count, 2 for document count"
@@ -1283,8 +1210,8 @@ class PageSize1(StrEnum):
class SplitForPosterPrintParams(ApiModel):
page_size: PageSize1 = Field(..., description="Target page size for output chunks (e.g., 'A4', 'Letter', 'A3')")
right_to_left: bool = Field(False, description="Split right-to-left instead of left-to-right")
- xfactor: int | None = None
- yfactor: int | None = None
+ x_factor: int = Field(2, description="Horizontal decimation factor (how many columns to split into)", ge=1, le=10)
+ y_factor: int = Field(2, description="Vertical decimation factor (how many rows to split into)", ge=1, le=10)
class SplitPagesParams(ApiModel):
@@ -1360,6 +1287,12 @@ class TimestampPdfParams(ApiModel):
)
+class UnlockPdfFormsParams(ApiModel):
+ """
+ Either upload a file or provide a server-side file ID
+ """
+
+
class Trapped(StrEnum):
"""
The trapped status of the document
@@ -1399,11 +1332,6 @@ class UrlToPdfParams(ApiModel):
url_input: str = Field(..., description="The input URL to be converted to a PDF file")
-class ValidateCertificateParams(ApiModel):
- cert_type: str | None = None
- password: str | None = None
-
-
class OutputFormat6(StrEnum):
"""
Target vector format extension
@@ -1462,20 +1390,24 @@ class Model(
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
+ | FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
+ | MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
+ | PdfToHtmlParams
| PdfToImgParams
+ | PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
- | PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
+ | PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1485,8 +1417,9 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
- | OverlayPdfsParams
+ | PdfToSinglePageParams
| RearrangePagesParams
+ | RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1495,33 +1428,31 @@ class Model(
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
- | AddAttachmentsParams
| AddCommentsParams
- | AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
+ | ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
+ | RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
+ | UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
- | CertSignParams
- | Pkcs11CertificatesParams
- | SessionsParams
- | ValidateCertificateParams
| RedactParams
| RedactExecuteParams
+ | RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1532,20 +1463,24 @@ class Model(
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
+ | FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
+ | MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
+ | PdfToHtmlParams
| PdfToImgParams
+ | PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
- | PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
+ | PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1555,8 +1490,9 @@ class Model(
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
- | OverlayPdfsParams
+ | PdfToSinglePageParams
| RearrangePagesParams
+ | RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1565,33 +1501,31 @@ class Model(
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
- | AddAttachmentsParams
| AddCommentsParams
- | AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
+ | ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
+ | RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
+ | UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
- | CertSignParams
- | Pkcs11CertificatesParams
- | SessionsParams
- | ValidateCertificateParams
| RedactParams
| RedactExecuteParams
+ | RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1603,20 +1537,24 @@ type ParamToolModel = (
| CbzToPdfParams
| EbookToPdfParams
| EmlToPdfParams
+ | FileToPdfParams
| HtmlToPdfParams
| ImgToPdfParams
+ | MarkdownToPdfParams
| PdfToCbrParams
| PdfToCbzParams
| PdfToCsvParams
| PdfToEpubParams
+ | PdfToHtmlParams
| PdfToImgParams
+ | PdfToMarkdownParams
| PdfToPdfaParams
| PdfToPresentationParams
| PdfToTextParams
- | PdfToTextEditorParams
| PdfToVectorParams
| PdfToWordParams
| PdfToXlsxParams
+ | PdfToXmlParams
| SvgToPdfParams
| UrlToPdfParams
| VectorToPdfParams
@@ -1626,8 +1564,9 @@ type ParamToolModel = (
| EditTextParams
| MergePdfsParams
| MultiPageLayoutParams
- | OverlayPdfsParams
+ | PdfToSinglePageParams
| RearrangePagesParams
+ | RemoveImagePdfParams
| RemovePagesParams
| RotatePdfParams
| ScalePagesParams
@@ -1636,33 +1575,31 @@ type ParamToolModel = (
| SplitPagesParams
| SplitPdfByChaptersParams
| SplitPdfBySectionsParams
- | AddAttachmentsParams
| AddCommentsParams
- | AddImageParams
| AddPageNumbersParams
| AddStampParams
| AutoRenameParams
| AutoSplitPdfParams
| CompressPdfParams
| DeleteAttachmentParams
+ | ExtractAttachmentsParams
| ExtractImageScansParams
| ExtractImagesParams
| FlattenParams
| OcrPdfParams
| RemoveBlanksParams
| RenameAttachmentParams
+ | RepairParams
| ReplaceInvertPdfParams
| ScannerEffectParams
+ | UnlockPdfFormsParams
| UpdateMetadataParams
| AddPasswordParams
| AddWatermarkParams
| AutoRedactParams
- | CertSignParams
- | Pkcs11CertificatesParams
- | SessionsParams
- | ValidateCertificateParams
| RedactParams
| RedactExecuteParams
+ | RemoveCertSignParams
| RemovePasswordParams
| SanitizePdfParams
| TimestampPdfParams
@@ -1675,20 +1612,24 @@ class ToolEndpoint(StrEnum):
CBZ_TO_PDF = "/api/v1/convert/cbz/pdf"
EBOOK_TO_PDF = "/api/v1/convert/ebook/pdf"
EML_TO_PDF = "/api/v1/convert/eml/pdf"
+ FILE_TO_PDF = "/api/v1/convert/file/pdf"
HTML_TO_PDF = "/api/v1/convert/html/pdf"
IMG_TO_PDF = "/api/v1/convert/img/pdf"
+ MARKDOWN_TO_PDF = "/api/v1/convert/markdown/pdf"
PDF_TO_CBR = "/api/v1/convert/pdf/cbr"
PDF_TO_CBZ = "/api/v1/convert/pdf/cbz"
PDF_TO_CSV = "/api/v1/convert/pdf/csv"
PDF_TO_EPUB = "/api/v1/convert/pdf/epub"
+ PDF_TO_HTML = "/api/v1/convert/pdf/html"
PDF_TO_IMG = "/api/v1/convert/pdf/img"
+ PDF_TO_MARKDOWN = "/api/v1/convert/pdf/markdown"
PDF_TO_PDFA = "/api/v1/convert/pdf/pdfa"
PDF_TO_PRESENTATION = "/api/v1/convert/pdf/presentation"
PDF_TO_TEXT = "/api/v1/convert/pdf/text"
- PDF_TO_TEXT_EDITOR = "/api/v1/convert/pdf/text-editor"
PDF_TO_VECTOR = "/api/v1/convert/pdf/vector"
PDF_TO_WORD = "/api/v1/convert/pdf/word"
PDF_TO_XLSX = "/api/v1/convert/pdf/xlsx"
+ PDF_TO_XML = "/api/v1/convert/pdf/xml"
SVG_TO_PDF = "/api/v1/convert/svg/pdf"
URL_TO_PDF = "/api/v1/convert/url/pdf"
VECTOR_TO_PDF = "/api/v1/convert/vector/pdf"
@@ -1698,8 +1639,9 @@ class ToolEndpoint(StrEnum):
EDIT_TEXT = "/api/v1/general/edit-text"
MERGE_PDFS = "/api/v1/general/merge-pdfs"
MULTI_PAGE_LAYOUT = "/api/v1/general/multi-page-layout"
- OVERLAY_PDFS = "/api/v1/general/overlay-pdfs"
+ PDF_TO_SINGLE_PAGE = "/api/v1/general/pdf-to-single-page"
REARRANGE_PAGES = "/api/v1/general/rearrange-pages"
+ REMOVE_IMAGE_PDF = "/api/v1/general/remove-image-pdf"
REMOVE_PAGES = "/api/v1/general/remove-pages"
ROTATE_PDF = "/api/v1/general/rotate-pdf"
SCALE_PAGES = "/api/v1/general/scale-pages"
@@ -1708,33 +1650,31 @@ class ToolEndpoint(StrEnum):
SPLIT_PAGES = "/api/v1/general/split-pages"
SPLIT_PDF_BY_CHAPTERS = "/api/v1/general/split-pdf-by-chapters"
SPLIT_PDF_BY_SECTIONS = "/api/v1/general/split-pdf-by-sections"
- ADD_ATTACHMENTS = "/api/v1/misc/add-attachments"
ADD_COMMENTS = "/api/v1/misc/add-comments"
- ADD_IMAGE = "/api/v1/misc/add-image"
ADD_PAGE_NUMBERS = "/api/v1/misc/add-page-numbers"
ADD_STAMP = "/api/v1/misc/add-stamp"
AUTO_RENAME = "/api/v1/misc/auto-rename"
AUTO_SPLIT_PDF = "/api/v1/misc/auto-split-pdf"
COMPRESS_PDF = "/api/v1/misc/compress-pdf"
DELETE_ATTACHMENT = "/api/v1/misc/delete-attachment"
+ EXTRACT_ATTACHMENTS = "/api/v1/misc/extract-attachments"
EXTRACT_IMAGE_SCANS = "/api/v1/misc/extract-image-scans"
EXTRACT_IMAGES = "/api/v1/misc/extract-images"
FLATTEN = "/api/v1/misc/flatten"
OCR_PDF = "/api/v1/misc/ocr-pdf"
REMOVE_BLANKS = "/api/v1/misc/remove-blanks"
RENAME_ATTACHMENT = "/api/v1/misc/rename-attachment"
+ REPAIR = "/api/v1/misc/repair"
REPLACE_INVERT_PDF = "/api/v1/misc/replace-invert-pdf"
SCANNER_EFFECT = "/api/v1/misc/scanner-effect"
+ UNLOCK_PDF_FORMS = "/api/v1/misc/unlock-pdf-forms"
UPDATE_METADATA = "/api/v1/misc/update-metadata"
ADD_PASSWORD = "/api/v1/security/add-password"
ADD_WATERMARK = "/api/v1/security/add-watermark"
AUTO_REDACT = "/api/v1/security/auto-redact"
- CERT_SIGN = "/api/v1/security/cert-sign"
- PKCS11_CERTIFICATES = "/api/v1/security/cert-sign/hardware/pkcs11-certificates"
- SESSIONS = "/api/v1/security/cert-sign/sessions"
- VALIDATE_CERTIFICATE = "/api/v1/security/cert-sign/validate-certificate"
REDACT = "/api/v1/security/redact"
REDACT_EXECUTE = "/api/v1/security/redact-execute"
+ REMOVE_CERT_SIGN = "/api/v1/security/remove-cert-sign"
REMOVE_PASSWORD = "/api/v1/security/remove-password"
SANITIZE_PDF = "/api/v1/security/sanitize-pdf"
TIMESTAMP_PDF = "/api/v1/security/timestamp-pdf"
@@ -1745,20 +1685,24 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.CBZ_TO_PDF: CbzToPdfParams,
ToolEndpoint.EBOOK_TO_PDF: EbookToPdfParams,
ToolEndpoint.EML_TO_PDF: EmlToPdfParams,
+ ToolEndpoint.FILE_TO_PDF: FileToPdfParams,
ToolEndpoint.HTML_TO_PDF: HtmlToPdfParams,
ToolEndpoint.IMG_TO_PDF: ImgToPdfParams,
+ ToolEndpoint.MARKDOWN_TO_PDF: MarkdownToPdfParams,
ToolEndpoint.PDF_TO_CBR: PdfToCbrParams,
ToolEndpoint.PDF_TO_CBZ: PdfToCbzParams,
ToolEndpoint.PDF_TO_CSV: PdfToCsvParams,
ToolEndpoint.PDF_TO_EPUB: PdfToEpubParams,
+ ToolEndpoint.PDF_TO_HTML: PdfToHtmlParams,
ToolEndpoint.PDF_TO_IMG: PdfToImgParams,
+ ToolEndpoint.PDF_TO_MARKDOWN: PdfToMarkdownParams,
ToolEndpoint.PDF_TO_PDFA: PdfToPdfaParams,
ToolEndpoint.PDF_TO_PRESENTATION: PdfToPresentationParams,
ToolEndpoint.PDF_TO_TEXT: PdfToTextParams,
- ToolEndpoint.PDF_TO_TEXT_EDITOR: PdfToTextEditorParams,
ToolEndpoint.PDF_TO_VECTOR: PdfToVectorParams,
ToolEndpoint.PDF_TO_WORD: PdfToWordParams,
ToolEndpoint.PDF_TO_XLSX: PdfToXlsxParams,
+ ToolEndpoint.PDF_TO_XML: PdfToXmlParams,
ToolEndpoint.SVG_TO_PDF: SvgToPdfParams,
ToolEndpoint.URL_TO_PDF: UrlToPdfParams,
ToolEndpoint.VECTOR_TO_PDF: VectorToPdfParams,
@@ -1768,8 +1712,9 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.EDIT_TEXT: EditTextParams,
ToolEndpoint.MERGE_PDFS: MergePdfsParams,
ToolEndpoint.MULTI_PAGE_LAYOUT: MultiPageLayoutParams,
- ToolEndpoint.OVERLAY_PDFS: OverlayPdfsParams,
+ ToolEndpoint.PDF_TO_SINGLE_PAGE: PdfToSinglePageParams,
ToolEndpoint.REARRANGE_PAGES: RearrangePagesParams,
+ ToolEndpoint.REMOVE_IMAGE_PDF: RemoveImagePdfParams,
ToolEndpoint.REMOVE_PAGES: RemovePagesParams,
ToolEndpoint.ROTATE_PDF: RotatePdfParams,
ToolEndpoint.SCALE_PAGES: ScalePagesParams,
@@ -1778,33 +1723,31 @@ OPERATIONS: dict[ToolEndpoint, ParamToolModelType] = {
ToolEndpoint.SPLIT_PAGES: SplitPagesParams,
ToolEndpoint.SPLIT_PDF_BY_CHAPTERS: SplitPdfByChaptersParams,
ToolEndpoint.SPLIT_PDF_BY_SECTIONS: SplitPdfBySectionsParams,
- ToolEndpoint.ADD_ATTACHMENTS: AddAttachmentsParams,
ToolEndpoint.ADD_COMMENTS: AddCommentsParams,
- ToolEndpoint.ADD_IMAGE: AddImageParams,
ToolEndpoint.ADD_PAGE_NUMBERS: AddPageNumbersParams,
ToolEndpoint.ADD_STAMP: AddStampParams,
ToolEndpoint.AUTO_RENAME: AutoRenameParams,
ToolEndpoint.AUTO_SPLIT_PDF: AutoSplitPdfParams,
ToolEndpoint.COMPRESS_PDF: CompressPdfParams,
ToolEndpoint.DELETE_ATTACHMENT: DeleteAttachmentParams,
+ ToolEndpoint.EXTRACT_ATTACHMENTS: ExtractAttachmentsParams,
ToolEndpoint.EXTRACT_IMAGE_SCANS: ExtractImageScansParams,
ToolEndpoint.EXTRACT_IMAGES: ExtractImagesParams,
ToolEndpoint.FLATTEN: FlattenParams,
ToolEndpoint.OCR_PDF: OcrPdfParams,
ToolEndpoint.REMOVE_BLANKS: RemoveBlanksParams,
ToolEndpoint.RENAME_ATTACHMENT: RenameAttachmentParams,
+ ToolEndpoint.REPAIR: RepairParams,
ToolEndpoint.REPLACE_INVERT_PDF: ReplaceInvertPdfParams,
ToolEndpoint.SCANNER_EFFECT: ScannerEffectParams,
+ ToolEndpoint.UNLOCK_PDF_FORMS: UnlockPdfFormsParams,
ToolEndpoint.UPDATE_METADATA: UpdateMetadataParams,
ToolEndpoint.ADD_PASSWORD: AddPasswordParams,
ToolEndpoint.ADD_WATERMARK: AddWatermarkParams,
ToolEndpoint.AUTO_REDACT: AutoRedactParams,
- ToolEndpoint.CERT_SIGN: CertSignParams,
- ToolEndpoint.PKCS11_CERTIFICATES: Pkcs11CertificatesParams,
- ToolEndpoint.SESSIONS: SessionsParams,
- ToolEndpoint.VALIDATE_CERTIFICATE: ValidateCertificateParams,
ToolEndpoint.REDACT: RedactParams,
ToolEndpoint.REDACT_EXECUTE: RedactExecuteParams,
+ ToolEndpoint.REMOVE_CERT_SIGN: RemoveCertSignParams,
ToolEndpoint.REMOVE_PASSWORD: RemovePasswordParams,
ToolEndpoint.SANITIZE_PDF: SanitizePdfParams,
ToolEndpoint.TIMESTAMP_PDF: TimestampPdfParams,
diff --git a/frontend/.gitignore b/frontend/.gitignore
index e07dce196a..0605e6e79f 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -11,6 +11,7 @@
# production
/build
/dist
+/dist-portal
/storybook-static
/editor/build
diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx
index d086613f44..dd8e379a8a 100644
--- a/frontend/.storybook/preview.tsx
+++ b/frontend/.storybook/preview.tsx
@@ -2,7 +2,7 @@
// the decorators below transpiles to React.createElement and needs React in
// scope. (The app + story files use the automatic runtime via the portal vite
// config; this import is specifically for the preview config file.)
-import React, { useEffect } from "react";
+import React, { Suspense, useEffect } from "react";
import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
@@ -109,7 +109,9 @@ const withProviders: Decorator = (Story, context) => {
-
+
+
+
diff --git a/frontend/editor/playwright.config.ts b/frontend/editor/playwright.config.ts
index 7a049e2632..93e6572392 100644
--- a/frontend/editor/playwright.config.ts
+++ b/frontend/editor/playwright.config.ts
@@ -30,7 +30,18 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : "50%",
- reporter: [["html", { open: "never" }], ["list"]],
+ // In CI, add a JSON report alongside the HTML/list output so the workflow
+ // can flag flaky tests (passed only on retry) as warnings without failing
+ // the job. Path is pinned via PLAYWRIGHT_JSON_OUTPUT_FILE in the workflow;
+ // the outputFile here is just a sane default. Omitted locally to keep dev
+ // runs' terminal output clean.
+ reporter: process.env.CI
+ ? [
+ ["html", { open: "never" }],
+ ["list"],
+ ["json", { outputFile: "playwright-report/results.json" }],
+ ]
+ : [["html", { open: "never" }], ["list"]],
timeout: 60_000,
expect: { timeout: 10_000 },
diff --git a/frontend/editor/postcss.config.js b/frontend/editor/postcss.config.js
index 7b8895cce8..5bb7b5d6ef 100644
--- a/frontend/editor/postcss.config.js
+++ b/frontend/editor/postcss.config.js
@@ -1,3 +1,6 @@
-module.exports = {
- plugins: [require("@tailwindcss/postcss"), require("autoprefixer")],
+import tailwindcssPostcss from "@tailwindcss/postcss";
+import autoprefixer from "autoprefixer";
+
+export default {
+ plugins: [tailwindcssPostcss, autoprefixer],
};
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 346ece1523..241289d1db 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -7430,7 +7430,7 @@ upgrade = "Upgrade"
volumeSuffix = "PDFs processed · last 30 days"
[portal.procurement]
-enterpriseBadge = "Enterprise"
+reset = "Reset procurement (demo)"
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
title = "Procurement"
@@ -7441,6 +7441,63 @@ request = "Request"
sign = "Review & sign"
upload = "Upload"
+[portal.procurement.agreement]
+agreeCta = "Agree & subscribe"
+confirm = "I have read and agree to the Stirling Enterprise Agreement."
+eyebrow = "Agreement"
+intro = "One combined agreement covers your deal: Master Service Agreement, Order Form, EULA, and Data Processing Agreement. Review it, then agree to accept the quote into a committed subscription."
+title = "Review your enterprise agreement"
+
+[portal.procurement.builder]
+addons = "Add-ons"
+back = "Back"
+businessName = "Business name"
+businessNamePlaceholder = "Your company"
+continue = "Continue"
+country = "Country"
+countryEuro = "Eurozone (EUR €)"
+countryUK = "United Kingdom (GBP £)"
+countryUS = "United States (USD $)"
+eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
+generate = "Generate quote"
+included = "Included"
+indemnification = "IP indemnification"
+indemnificationSub = "We defend qualifying IP claims, per the EULA"
+qbr = "Quarterly business reviews"
+qbrSub = "Your SE reviews usage and roadmap each quarter"
+running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
+s1Sub = "Your team, and the PDFs you expect to run each year."
+# Step 1 — volume
+s1Title = "How much will you process?"
+s2Sub = "Longer terms discount the rate; your service level sets support."
+# Step 2 — commitment & service
+s2Title = "Commitment and service"
+s3Sub = "For the quote and the agreement it generates."
+# Step 3 — details
+s3Title = "Your details"
+serviceLevel = "Service level"
+slDedicated = "Dedicated"
+slDedicatedSub = "4 business hours · dedicated account manager · +30%"
+slPriority = "Priority"
+slPrioritySub = "Same business day · named CSM · +15%"
+slStandard = "Standard"
+slStandardSub = "Next business day · shared CSM · included"
+stepOf = "Step {{n}} of {{total}}"
+term = "Term"
+termDiscount = "{{pct}}% multi-year commitment discount applied"
+title = "Build your quote"
+training = "Onboarding & training"
+trainingSub = "Live sessions to get your team running"
+users = "Total users"
+usersPlaceholder = "e.g. 250"
+volEstimated = "Estimated from {{count}} users (~2,000 PDFs each, including automation). Edit if you know better."
+volManual = "Using your figure. Re-estimate from your team size any time."
+volNoUsers = "Not sure? Enter your team size and we'll estimate it."
+volume = "Annual PDF volume"
+volumePlaceholder = "e.g. 1,000,000"
+years_one = "{{count}} year"
+years_other = "{{count}} years"
+
[portal.procurement.docs]
count_one = "{{count}} doc"
count_other = "{{count}} docs"
@@ -7456,6 +7513,30 @@ supportingTitle = "Supporting your evaluation"
title = "Documents"
upcoming = "Upcoming"
+[portal.procurement.error]
+title = "Something went wrong"
+
+[portal.procurement.hero]
+company = "Your enterprise deal"
+ctaAgreement = "Review & sign agreement"
+ctaLive = "You're live"
+ctaPayment = "Add payment"
+ctaQuote = "Review your quote"
+ctaTrial = "Build your quote"
+eyebrow = "Enterprise procurement"
+inviteTeammates = "Invite teammates"
+keyDocs = "Key documents"
+nextStep = "Next step: {{action}}"
+notStarted = "Not started"
+open = "Open procurement"
+scheduleCall = "Schedule a call"
+setup1Sub = "Invite your teammates"
+setup1Title = "Deploy the PDF Editor"
+setup2Sub = "Turn on processing across editors and other sources"
+setup2Title = "Connect the PDF Processor"
+setup3Sub = "Turn on Security, Compliance, Routing, or Retention when you need them"
+setup3Title = "Add recommended policies"
+
[portal.procurement.journey]
daysLeft_one = "{{count}} day left"
daysLeft_other = "{{count}} days left"
@@ -7467,15 +7548,39 @@ subtitle = "Your solutions engineer is on every step. One next action at a time;
title = "From trial to live, one guided path"
trialTitle = "Enterprise trial"
+[portal.procurement.link]
+cta = "Link account"
+description = "Procurement runs on your linked Stirling account: it's how we provision the trial, price your quote, and start billing. Link an account to start."
+eyebrow = "Enterprise"
+title = "Link your account to begin"
+
+[portal.procurement.live]
+description = "Your subscription is active and your licence is issued. Your team is provisioned and billing has started."
+eyebrow = "Live"
+title = "You're live on Stirling Enterprise"
+
[portal.procurement.locked]
description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
eyebrow = "Enterprise only"
talkToSales = "Talk to sales"
title = "The procurement track opens with Enterprise"
+[portal.procurement.milestone]
+accept = "Accept & continue"
+description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes."
+download = "Download PDF"
+downloadError = "Could not download the quote PDF just yet — please try again in a moment."
+edit = "Edit quote"
+eyebrow = "Quote {{number}}"
+perYear = " / yr"
+preparedFor = "Prepared for {{company}}"
+tcv = "{{value}} total contract value"
+title = "Your quote is ready"
+
[portal.procurement.modal]
cancel = "Cancel"
chooseFile = "Choose file"
+close = "Close"
downloadBody = "Your download will begin shortly."
downloadCta = "Download"
downloadTitle = "Download"
@@ -7494,6 +7599,13 @@ uploadBody = "Send us your PO and we invoice against it on your terms. Drag in t
uploadCta = "Upload purchase order"
uploadTitle = "Upload your purchase order"
+[portal.procurement.payment]
+description = "Your quote is accepted and a committed annual subscription has been created. Pay the first invoice to go live — you can pay or download it right here, no email needed."
+downloadInvoice = "Download invoice"
+simulate = "Simulate payment received (demo)"
+title = "Subscription created"
+viewInvoice = "View & pay invoice"
+
[portal.procurement.status]
action = "Action needed"
available = "Available"
@@ -7501,6 +7613,21 @@ complete = "Complete"
pending = "Pending"
request = "On request"
+[portal.procurement.trial]
+body = "Extending adds 7 days and notifies your solutions engineer."
+bodyMaxed = "You have used all your extensions — talk to your solutions engineer if you need more time."
+cancel = "Cancel trial"
+extend = "Extend 7 days"
+maxed = "Maxed out"
+subtitle = "Your free trial runs through {{date}}. No card required."
+title = "Enterprise trial"
+
+[portal.procurement.upsell]
+homeBadge = "Enterprise"
+homeBody = "Committed volume pricing, org-wide SSO + SCIM + RBAC, 90-day immutable audit, and a dedicated SE."
+homeCta = "Start Trial →"
+homeHeadline = "Process millions of PDFs."
+
[portal.recentActivity]
title = "Recent activity"
viewAll = "View all"
diff --git a/frontend/editor/scripts/generate-icons.js b/frontend/editor/scripts/generate-icons.js
index 7e6da2740e..70b4a091d2 100644
--- a/frontend/editor/scripts/generate-icons.js
+++ b/frontend/editor/scripts/generate-icons.js
@@ -1,8 +1,8 @@
#!/usr/bin/env node
-const { icons } = require("@iconify-json/material-symbols");
-const fs = require("fs");
-const path = require("path");
+import { icons } from "@iconify-json/material-symbols";
+import fs from "node:fs";
+import path from "node:path";
// Check for verbose flag
const isVerbose =
@@ -19,7 +19,7 @@ const debug = (message) => {
// Function to scan codebase for LocalIcon usage
function scanForUsedIcons() {
const usedIcons = new Set();
- const srcDir = path.join(__dirname, "..", "src");
+ const srcDir = path.join(import.meta.dirname, "..", "src");
info("🔍 Scanning codebase for LocalIcon usage...");
@@ -140,7 +140,7 @@ async function main() {
// Check if we need to regenerate (compare with existing)
const outputPath = path.join(
- __dirname,
+ import.meta.dirname,
"..",
"src",
"assets",
@@ -200,7 +200,7 @@ async function main() {
}
// Create output directory
- const outputDir = path.join(__dirname, "..", "src", "assets");
+ const outputDir = path.join(import.meta.dirname, "..", "src", "assets");
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
diff --git a/frontend/editor/scripts/generate-licenses.js b/frontend/editor/scripts/generate-licenses.js
index a82ec9a2bd..72da16c6a9 100644
--- a/frontend/editor/scripts/generate-licenses.js
+++ b/frontend/editor/scripts/generate-licenses.js
@@ -1,28 +1,21 @@
#!/usr/bin/env node
-const { execSync } = require("node:child_process");
-const {
- existsSync,
- mkdirSync,
- writeFileSync,
- readFileSync,
-} = require("node:fs");
-const path = require("node:path");
+import { execSync } from "node:child_process";
+import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
+import path from "node:path";
+import { argv } from "node:process";
-const { argv } = require("node:process");
const inputIdx = argv.indexOf("--input");
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
-// __dirname is available in CommonJS by default
-
/**
* Generate 3rd party licenses for frontend dependencies
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
*/
const OUTPUT_FILE = path.join(
- __dirname,
+ import.meta.dirname,
"..",
"src",
"assets",
@@ -30,7 +23,7 @@ const OUTPUT_FILE = path.join(
);
// package.json lives at the workspace root (frontend/), not editor/. The
// script is at frontend/editor/scripts/, so walk up two levels.
-const PACKAGE_JSON = path.join(__dirname, "..", "..", "package.json");
+const PACKAGE_JSON = path.join(import.meta.dirname, "..", "..", "package.json");
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
@@ -192,7 +185,7 @@ try {
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(
- __dirname,
+ import.meta.dirname,
"..",
"src",
"assets",
diff --git a/frontend/editor/scripts/generate-og-image.mjs b/frontend/editor/scripts/generate-og-image.mjs
index 7cef32c9b0..cf617bf7e8 100644
--- a/frontend/editor/scripts/generate-og-image.mjs
+++ b/frontend/editor/scripts/generate-og-image.mjs
@@ -16,11 +16,10 @@
/* global document, getComputedStyle */ // used inside page.evaluate (browser context)
import fs from "node:fs/promises";
+import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
-import { createRequire } from "node:module";
-const require = createRequire(import.meta.url);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
@@ -69,11 +68,14 @@ export const THEME = {
};
// ---- icon resolution (material-symbols via iconify) ------------------------
-function resolveIcon(icon) {
+async function resolveIcon(icon) {
if (!icon) return "";
if (icon.trim().startsWith("
- {/* Destination wired when the enterprise/sales URL is confirmed. */}
-