mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9960b6e6 | ||
|
|
f8ce30ff37 | ||
|
|
f0a7f9af78 | ||
|
|
6309ca7234 | ||
|
|
f3f17d6381 | ||
|
|
4487f23ff7 | ||
|
|
2c0ebc28a7 | ||
|
|
d1486c7762 | ||
|
|
2ccff6f73f | ||
|
|
78da227eba | ||
|
|
30e782e29c | ||
|
|
2b0905887b | ||
|
|
28b81828b5 | ||
|
|
2c01f41142 | ||
|
|
4d5eeb103f | ||
|
|
83ea07ed6a | ||
|
|
61ebe977d3 | ||
|
|
763595a5a3 | ||
|
|
398617391b | ||
|
|
a0e0e88f07 |
@@ -24,7 +24,7 @@ runs:
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ inputs.app-id }}
|
||||
client-id: ${{ inputs.app-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -184,7 +184,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
if len(file_list) == 1:
|
||||
file_arr = file_list[0].split()
|
||||
|
||||
base_dir = Path.cwd() / "frontend" / "public" / "locales"
|
||||
base_dir = Path.cwd() / "frontend" / "editor" / "public" / "locales"
|
||||
|
||||
for file_path in file_arr:
|
||||
file_path = Path(file_path)
|
||||
@@ -372,6 +372,7 @@ if __name__ == "__main__":
|
||||
os.path.join(
|
||||
os.getcwd(),
|
||||
"frontend",
|
||||
"editor",
|
||||
"public",
|
||||
"locales",
|
||||
"*",
|
||||
|
||||
+124
-27
@@ -1,8 +1,9 @@
|
||||
name: AI Engine CI
|
||||
|
||||
# Validates the Python AI engine: regenerates tool models, runs fixers,
|
||||
# lint, type-check, and tests. Called from build.yml on PRs and merge_group;
|
||||
# also runs directly on push to main as a post-merge safety net.
|
||||
# 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.
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
@@ -51,27 +52,95 @@ jobs:
|
||||
run: task engine:tool-models
|
||||
|
||||
- name: Verify tool models are up to date
|
||||
id: tool-models-check
|
||||
continue-on-error: true
|
||||
run: git diff --exit-code engine/src/stirling/models/tool_models.py
|
||||
|
||||
- name: Comment on tool models check failure
|
||||
# Only post a comment on PRs. github-script's PR helpers need an
|
||||
# issue/PR number, which doesn't exist on merge_group runs.
|
||||
if: steps.tool-models-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Tool Models Check Failed',
|
||||
'',
|
||||
'The generated `engine/src/stirling/models/tool_models.py` is out of date with the Java OpenAPI spec and will need to be regenerated before it can be merged in.',
|
||||
'',
|
||||
'Run `task engine:tool-models` to regenerate, then commit the updated file.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if tool models check failed
|
||||
if: steps.tool-models-check.outcome == 'failure'
|
||||
run: |
|
||||
if ! git diff --exit-code engine/src/stirling/models/tool_models.py; then
|
||||
echo "tool_models.py is out of date."
|
||||
echo "Run 'task engine:tool-models' locally and commit the updated file."
|
||||
exit 1
|
||||
fi
|
||||
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: Run fixers
|
||||
run: task engine:fix
|
||||
- name: Remove tool models check comment on success
|
||||
if: steps.tool-models-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- tool-models-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Verify fixes are committed
|
||||
id: fixer_changes
|
||||
run: |
|
||||
if ! git diff --quiet; then
|
||||
git --no-pager diff --stat
|
||||
echo "::error::There are issues with your Python code that will need to be fixed before they can be merged in. Run 'task engine:fix' to auto-fix what can be fixed automatically, then run 'task engine:check' to see what still needs fixing manually."
|
||||
exit 1
|
||||
fi
|
||||
- name: Quality-check engine
|
||||
id: engine-check
|
||||
run: task engine:check
|
||||
continue-on-error: true
|
||||
|
||||
- name: Comment on fixer failures
|
||||
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
- name: Comment on engine 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.engine-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
@@ -107,11 +176,39 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
- name: Run linting
|
||||
run: task engine:lint
|
||||
- name: Fail if engine check failed
|
||||
if: steps.engine-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Engine Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "There are issues with your Python code that"
|
||||
echo "will need to be fixed before they can be merged in."
|
||||
echo ""
|
||||
echo "Run 'task engine:fix' to auto-fix what can be"
|
||||
echo "fixed automatically, then run 'task engine:check'"
|
||||
echo "to see what still needs fixing manually."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Run type checking
|
||||
run: task engine:typecheck
|
||||
|
||||
- name: Run tests
|
||||
run: task engine:test
|
||||
- name: Remove engine check comment on success
|
||||
if: steps.engine-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- engine-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
|
||||
|
||||
- name: Comment on Java formatting failure
|
||||
- name: Comment on backend format 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.spotless-check.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
@@ -78,15 +78,11 @@ jobs:
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
const body = [
|
||||
marker,
|
||||
'### Java Formatting Check Failed',
|
||||
'### Backend Format Check Failed',
|
||||
'',
|
||||
'Your code has formatting issues. Run the following command to fix them:',
|
||||
'There are formatting issues in your Java code that will need to be fixed before they can be merged in.',
|
||||
'',
|
||||
'```bash',
|
||||
'task backend:format',
|
||||
'```',
|
||||
'',
|
||||
'Then commit and push the changes.',
|
||||
'Run `task backend:format` to auto-fix, then commit and push the changes.',
|
||||
].join('\n');
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
@@ -110,22 +106,43 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
- name: Fail if Java formatting issues found
|
||||
- name: Fail if backend format check failed
|
||||
if: steps.spotless-check.outcome == 'failure'
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Java Formatting Check Failed"
|
||||
echo " Backend Format Check Failed"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Your code has formatting issues."
|
||||
echo "Run the following command to fix them:"
|
||||
echo "There are formatting issues in your Java code"
|
||||
echo "that will need to be fixed before they can be"
|
||||
echo "merged in."
|
||||
echo ""
|
||||
echo " task backend:format"
|
||||
echo ""
|
||||
echo "Then commit and push the changes."
|
||||
echo "Run 'task backend:format' to auto-fix, then"
|
||||
echo "commit and push the changes."
|
||||
echo "============================================"
|
||||
exit 1
|
||||
|
||||
- name: Remove backend format check comment on success
|
||||
if: steps.spotless-check.outcome == 'success' && github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- java-formatting-check -->';
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Build with Gradle and spring security ${{ matrix.spring-security }}
|
||||
run: task backend:build:ci
|
||||
env:
|
||||
|
||||
@@ -22,12 +22,13 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORT | default "8080"}}'
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
+22
-1
@@ -133,8 +133,29 @@ tasks:
|
||||
--no-header-files
|
||||
--no-man-pages
|
||||
--output runtime/jre
|
||||
# jlink emits its files mode 444 (read-only). Tauri's build-script
|
||||
# resource copier preserves source permissions when staging
|
||||
# `runtime/jre/**/*` into `target/<profile>/runtime/jre/...`, so the
|
||||
# staged copies are read-only too. On any subsequent incremental
|
||||
# build the copier tries to overwrite them and fails with a bare
|
||||
# `Permission denied (os error 13)` (Rust's io::Error Display drops
|
||||
# the path, so the failure is opaque). Make the source writable here
|
||||
# so the staged destinations are writable and can be overwritten.
|
||||
#
|
||||
# Trade-off: this task runs for both `task desktop:dev` and
|
||||
# `task desktop:build`, so production bundles also ship mode-644
|
||||
# JRE files instead of 444. Functionally harmless on POSIX (the
|
||||
# `other` bit is `r--` either way, and on macOS code signing is the
|
||||
# real integrity check) and on Windows the DOS read-only attribute
|
||||
# isn't load-bearing for the bundled JDK. If we ever need strict
|
||||
# 444 in production, split the chmod into a dev-only step and have
|
||||
# `desktop:build` run `jlink:clean` first to force a fresh build.
|
||||
- cmd: chmod -R u+w runtime/jre
|
||||
platforms: [linux, darwin]
|
||||
- cmd: powershell -NoProfile -Command "Get-ChildItem -Recurse runtime/jre | ForEach-Object { $_.IsReadOnly = $false }"
|
||||
platforms: [windows]
|
||||
status:
|
||||
- test -d editor/src-tauri/runtime/jre
|
||||
- test -f runtime/jre/release
|
||||
|
||||
jlink:clean:
|
||||
desc: "Remove JLink runtime and bundled JARs"
|
||||
|
||||
@@ -10,6 +10,10 @@ if that directory exists, is licensed under the license defined in "app/propriet
|
||||
if that directory exists, is licensed under the license defined in "app/saas/LICENSE".
|
||||
* All content that resides under the "engine/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "engine/LICENSE".
|
||||
* "scripts/pymupdf_convert.py", if that file exists, is licensed under the GNU Affero
|
||||
General Public License v3.0 (or later) as declared in its file header. It is a separate
|
||||
program invoked as an OS subprocess; its license does not extend to other content in
|
||||
this repository.
|
||||
* All content that resides under the "frontend/src/proprietary/" directory of this repository,
|
||||
if that directory exists, is licensed under the license defined in "frontend/src/proprietary/LICENSE".
|
||||
* All content that resides under the "frontend/src/desktop/" directory of this repository,
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
|
||||
- task: frontend:dev:prototypes
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.FRONTEND_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
+5
-1
@@ -77,6 +77,10 @@ public @interface AutoJobPostMapping {
|
||||
/**
|
||||
* Relative resource weight (1-100). See {@link
|
||||
* stirling.software.common.enumeration.ResourceWeight} for the standard tiers.
|
||||
*
|
||||
* <p>The default is a sentinel ({@link Integer#MIN_VALUE}); {@code
|
||||
* AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime
|
||||
* readers clamp the value into {@code [1, 100]}.
|
||||
*/
|
||||
int resourceWeight() default 1;
|
||||
int resourceWeight() default Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
+27
-5
@@ -80,6 +80,7 @@ public class ConfigInitializer {
|
||||
YamlHelper settingsFile = new YamlHelper(settingTempPath);
|
||||
|
||||
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
|
||||
migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile);
|
||||
|
||||
boolean changesMade =
|
||||
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
|
||||
@@ -116,31 +117,52 @@ public class ConfigInitializer {
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin") != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "SSOAutoLogin"),
|
||||
List.of("premium", "proFeatures", "ssoAutoLogin"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "SSOAutoLogin"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "autoUpdateMetadata")
|
||||
!= null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "autoUpdateMetadata"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "autoUpdateMetadata"),
|
||||
yaml.getValueByExactKeyPath(
|
||||
"enterpriseEdition", "CustomMetadata", "autoUpdateMetadata"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author") != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "author"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "author"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "author"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator") != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "creator"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "creator"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "creator"));
|
||||
}
|
||||
if (yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer")
|
||||
!= null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "CustomMetadata", "producer"),
|
||||
List.of("premium", "proFeatures", "customMetadata", "producer"),
|
||||
yaml.getValueByExactKeyPath("enterpriseEdition", "CustomMetadata", "producer"));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Remove post migration
|
||||
// settings.yml.template renamed the two non-camelCase proFeatures keys
|
||||
// ("SSOAutoLogin" -> "ssoAutoLogin", "CustomMetadata" -> "customMetadata") so the whole
|
||||
// settings pipeline is consistent camelCase. The save path (YamlHelper.updateValue) matches
|
||||
// keys case-sensitively, so without this carry-forward an existing install's values written
|
||||
// under the old PascalCase keys would be dropped on upgrade and reset to template defaults.
|
||||
void migrateProFeaturesKeyCasing(YamlHelper yaml, YamlHelper template) {
|
||||
Object ssoAutoLogin = yaml.getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin");
|
||||
if (ssoAutoLogin != null) {
|
||||
template.updateValue(List.of("premium", "proFeatures", "ssoAutoLogin"), ssoAutoLogin);
|
||||
}
|
||||
for (String field : List.of("autoUpdateMetadata", "author", "creator", "producer")) {
|
||||
Object value =
|
||||
yaml.getValueByExactKeyPath("premium", "proFeatures", "CustomMetadata", field);
|
||||
if (value != null) {
|
||||
template.updateValue(
|
||||
List.of("premium", "proFeatures", "customMetadata", field), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1350,6 +1350,10 @@ public class ApplicationProperties {
|
||||
public int getFfmpegSessionLimit() {
|
||||
return ffmpegSessionLimit > 0 ? ffmpegSessionLimit : 2;
|
||||
}
|
||||
|
||||
public int getPyMuPdfConvertSessionLimit() {
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -1427,6 +1431,10 @@ public class ApplicationProperties {
|
||||
public long getFfmpegTimeoutMinutes() {
|
||||
return ffmpegTimeoutMinutes > 0 ? ffmpegTimeoutMinutes : 30;
|
||||
}
|
||||
|
||||
public long getPyMuPdfConvertTimeoutMinutes() {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
|
||||
/**
|
||||
* Converts PDFs to Markdown by invoking the {@code pymupdf-convert} CLI tool as a separate
|
||||
* subprocess.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class PyMuPdfConverter {
|
||||
|
||||
private boolean available;
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
available = probe();
|
||||
if (available) {
|
||||
log.info("pymupdf-convert found — PyMuPDF Markdown conversion enabled.");
|
||||
} else {
|
||||
log.info("pymupdf-convert not found — PyMuPDF Markdown conversion disabled.");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAvailable() {
|
||||
return available;
|
||||
}
|
||||
|
||||
private boolean probe() {
|
||||
boolean isWindows =
|
||||
System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows");
|
||||
List<String> cmd =
|
||||
isWindows
|
||||
? List.of("where", "pymupdf-convert")
|
||||
: List.of("which", "pymupdf-convert");
|
||||
try {
|
||||
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
|
||||
boolean done = p.waitFor(5, TimeUnit.SECONDS);
|
||||
return done && p.exitValue() == 0;
|
||||
} catch (Exception e) {
|
||||
log.debug("pymupdf-convert availability check failed: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a PDF to Markdown by invoking {@code pymupdf-convert} as a subprocess.
|
||||
*
|
||||
* @throws IOException on process failure or if the tool is not installed
|
||||
*/
|
||||
public String convertToMarkdown(byte[] pdfBytes, String filename) throws IOException {
|
||||
String safeName =
|
||||
(filename == null || filename.isBlank())
|
||||
? "document.pdf"
|
||||
: filename.replace("\"", "");
|
||||
Path tempDir = Files.createTempDirectory("stirling-pymupdf-");
|
||||
Path inputPdf = tempDir.resolve(safeName);
|
||||
Path outputMd = tempDir.resolve("output.md");
|
||||
try {
|
||||
Files.write(inputPdf, pdfBytes);
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.PYMUPDF_CONVERT)
|
||||
.runCommandWithOutputHandling(
|
||||
List.of(
|
||||
"pymupdf-convert",
|
||||
inputPdf.toAbsolutePath().toString(),
|
||||
outputMd.toAbsolutePath().toString()));
|
||||
return Files.readString(outputMd, StandardCharsets.UTF_8);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("PyMuPDF conversion interrupted", e);
|
||||
} finally {
|
||||
Files.deleteIfExists(inputPdf);
|
||||
Files.deleteIfExists(outputMd);
|
||||
Files.deleteIfExists(tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
@@ -30,6 +31,7 @@ import io.github.pixee.security.Filenames;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.service.PyMuPdfConverter;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
@@ -159,6 +161,57 @@ public class PDFToFile {
|
||||
finalOut, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF->Markdown with optional PyMuPDF acceleration.
|
||||
*
|
||||
* <p>When {@code pymupdf-convert} is installed and on PATH, conversion is delegated to it as a
|
||||
* subprocess. On any failure — or when the tool is absent — this transparently falls back to
|
||||
* the bundled {@code pdftohtml}-based converter.
|
||||
*/
|
||||
public ResponseEntity<Resource> processPdfToMarkdown(
|
||||
MultipartFile inputFile, PyMuPdfConverter pyMuPdfConverter)
|
||||
throws IOException, InterruptedException {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (pyMuPdfConverter != null && pyMuPdfConverter.isAvailable()) {
|
||||
try {
|
||||
String originalName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
String baseName = originalName;
|
||||
if (originalName != null && originalName.contains(".")) {
|
||||
baseName = originalName.substring(0, originalName.lastIndexOf('.'));
|
||||
}
|
||||
String markdown =
|
||||
pyMuPdfConverter.convertToMarkdown(inputFile.getBytes(), originalName);
|
||||
return buildMarkdownZipResponse(markdown, baseName);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"PyMuPDF conversion failed; falling back to pdftohtml converter: {}",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return processPdfToMarkdown(inputFile);
|
||||
}
|
||||
|
||||
private ResponseEntity<Resource> buildMarkdownZipResponse(String markdown, String pdfBaseName)
|
||||
throws IOException {
|
||||
String fileName = pdfBaseName + "ToMarkdown.zip";
|
||||
TempFile finalOut = tempFileManager.createManagedTempFile(".zip");
|
||||
try (OutputStream fos = Files.newOutputStream(finalOut.getPath());
|
||||
ZipOutputStream zipOutputStream = new ZipOutputStream(fos)) {
|
||||
ZipEntry mdEntry = new ZipEntry(pdfBaseName + ".md");
|
||||
zipOutputStream.putNextEntry(mdEntry);
|
||||
zipOutputStream.write(markdown.getBytes(StandardCharsets.UTF_8));
|
||||
zipOutputStream.closeEntry();
|
||||
} catch (Exception e) {
|
||||
finalOut.close();
|
||||
throw e;
|
||||
}
|
||||
return WebResponseUtils.fileToWebResponse(
|
||||
finalOut, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates image references in markdown to point to the images/ folder. Matches patterns like
|
||||
*  and converts to 
|
||||
|
||||
@@ -115,6 +115,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getFfmpegSessionLimit();
|
||||
case PYMUPDF_CONVERT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getSessionLimit()
|
||||
.getPyMuPdfConvertSessionLimit();
|
||||
};
|
||||
|
||||
long timeoutMinutes =
|
||||
@@ -180,6 +185,11 @@ public class ProcessExecutor {
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getFfmpegTimeoutMinutes();
|
||||
case PYMUPDF_CONVERT ->
|
||||
applicationProperties
|
||||
.getProcessExecutor()
|
||||
.getTimeoutMinutes()
|
||||
.getPyMuPdfConvertTimeoutMinutes();
|
||||
};
|
||||
return new ProcessExecutor(
|
||||
processType, semaphoreLimit, liveUpdates, timeoutMinutes);
|
||||
@@ -550,7 +560,8 @@ public class ProcessExecutor {
|
||||
GHOSTSCRIPT,
|
||||
OCR_MY_PDF,
|
||||
CFF_CONVERTER,
|
||||
FFMPEG
|
||||
FFMPEG,
|
||||
PYMUPDF_CONVERT
|
||||
}
|
||||
|
||||
@Setter
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.snakeyaml.engine.v2.api.LoadSettings;
|
||||
|
||||
import stirling.software.common.util.YamlHelper;
|
||||
|
||||
class ConfigInitializerTest {
|
||||
|
||||
private static final LoadSettings LOAD_SETTINGS =
|
||||
LoadSettings.builder()
|
||||
.setUseMarks(true)
|
||||
.setMaxAliasesForCollections(Integer.MAX_VALUE)
|
||||
.setAllowRecursiveKeys(true)
|
||||
.setParseComments(true)
|
||||
.build();
|
||||
|
||||
// Mirrors the proFeatures block of settings.yml.template after the camelCase rename.
|
||||
private static final String CAMEL_CASE_TEMPLATE =
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
producer: Stirling-PDF
|
||||
""";
|
||||
|
||||
@Test
|
||||
void migrateProFeaturesKeyCasing_carriesForwardLegacyPascalCaseValues() {
|
||||
// An existing install whose settings.yml still uses the old PascalCase keys.
|
||||
String legacy =
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
SSOAutoLogin: true
|
||||
CustomMetadata:
|
||||
autoUpdateMetadata: true
|
||||
author: alice
|
||||
creator: bob
|
||||
producer: carol
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, legacy);
|
||||
|
||||
new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template);
|
||||
|
||||
assertEquals(
|
||||
"true", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"true",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "autoUpdateMetadata"));
|
||||
assertEquals(
|
||||
"alice",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"));
|
||||
assertEquals(
|
||||
"bob",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "creator"));
|
||||
assertEquals(
|
||||
"carol",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "producer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void migrateProFeaturesKeyCasing_withoutLegacyKeys_keepsTemplateDefaults() {
|
||||
// No PascalCase keys present -> this migration step must be a no-op.
|
||||
String alreadyCamel =
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
ssoAutoLogin: true
|
||||
customMetadata:
|
||||
author: dave
|
||||
""";
|
||||
YamlHelper template = new YamlHelper(LOAD_SETTINGS, CAMEL_CASE_TEMPLATE);
|
||||
YamlHelper existing = new YamlHelper(LOAD_SETTINGS, alreadyCamel);
|
||||
|
||||
new ConfigInitializer().migrateProFeaturesKeyCasing(existing, template);
|
||||
|
||||
assertEquals(
|
||||
"false", template.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"username",
|
||||
template.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package stirling.software.common.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -12,9 +14,47 @@ import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
public class GeneralUtilsTest {
|
||||
|
||||
// Regression guard for the SSO auto-login persistence bug: the admin UI writes camelCase
|
||||
// proFeatures keys, so saveKeyToSettings must match (and persist) them against the camelCase
|
||||
// settings.yml.template. A case mismatch makes YamlHelper.updateValue silently no-op.
|
||||
@Test
|
||||
void saveKeyToSettings_persistsCamelCaseProFeatureKeys(@TempDir Path tempDir) throws Exception {
|
||||
Path settings = tempDir.resolve("settings.yml");
|
||||
Files.writeString(
|
||||
settings,
|
||||
"""
|
||||
premium:
|
||||
proFeatures:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
author: username
|
||||
""");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> mocked =
|
||||
Mockito.mockStatic(InstallationPathConfig.class)) {
|
||||
mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
|
||||
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true);
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "alice");
|
||||
}
|
||||
|
||||
YamlHelper reloaded = new YamlHelper(settings);
|
||||
assertEquals(
|
||||
"true", reloaded.getValueByExactKeyPath("premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"alice",
|
||||
reloaded.getValueByExactKeyPath(
|
||||
"premium", "proFeatures", "customMetadata", "author"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParsePageListWithAll() {
|
||||
List<Integer> result = GeneralUtils.parsePageList(new String[] {"all"}, 5, false);
|
||||
|
||||
@@ -21,6 +21,13 @@ public class EndpointInterceptor implements HandlerInterceptor {
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler)
|
||||
throws Exception {
|
||||
String requestURI = request.getRequestURI();
|
||||
|
||||
// Prevent API responses from being stored by browsers or intermediary caches by default
|
||||
String servletPath = request.getServletPath();
|
||||
if (servletPath != null && servletPath.startsWith("/api/")) {
|
||||
response.setHeader("Cache-Control", "private, no-store");
|
||||
}
|
||||
|
||||
boolean isEnabled = endpointConfiguration.isEndpointEnabledForUri(requestURI);
|
||||
if (!isEnabled) {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, "This endpoint is disabled");
|
||||
|
||||
@@ -101,6 +101,9 @@ public class ExternalAppDepConfig {
|
||||
// Python / OpenCV special handling
|
||||
checkPythonAndOpenCV();
|
||||
|
||||
// PyMuPDF optional acceleration
|
||||
checkPyMuPdf();
|
||||
|
||||
dependenciesChecked = true;
|
||||
} finally {
|
||||
endpointConfiguration.logDisabledEndpointsSummary();
|
||||
@@ -236,6 +239,15 @@ public class ExternalAppDepConfig {
|
||||
}
|
||||
}
|
||||
|
||||
private void checkPyMuPdf() {
|
||||
if (isCommandAvailable("pymupdf-convert")) {
|
||||
log.warn("pymupdf-convert detected — PDF->Markdown will use PyMuPDF acceleration.");
|
||||
} else {
|
||||
log.info(
|
||||
"pymupdf-convert not found — PDF->Markdown will use the bundled pdftohtml converter.");
|
||||
}
|
||||
}
|
||||
|
||||
private void disablePythonAndOpenCV(String reason) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
@@ -24,6 +27,10 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebMvcConfig.class);
|
||||
|
||||
private static final CacheControl NO_CACHE = CacheControl.noCache();
|
||||
private static final CacheControl IMMUTABLE_ONE_YEAR =
|
||||
CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic().immutable();
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(endpointInterceptor);
|
||||
@@ -31,37 +38,95 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// Cache hashed assets (JS/CSS with content hashes) for 1 year
|
||||
// These files have names like index-ChAS4tCC.js that change when content changes
|
||||
// Check customFiles/static first, then fall back to classpath
|
||||
String staticPath =
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath();
|
||||
|
||||
// 1. Service worker and PWA metadata (never store)
|
||||
// Browsers revalidate SW bytes anyway; no-store is the safest for atomic updates.
|
||||
registry.addResourceHandler(
|
||||
"/sw.js", "/manifest.json", "/site.webmanifest", "/browserconfig.xml")
|
||||
.addResourceLocations(staticPath, "classpath:/static/")
|
||||
.setCacheControl(CacheControl.noStore())
|
||||
.resourceChain(true);
|
||||
|
||||
// 2. Vite fingerprinted assets (immutable)
|
||||
// These already have content hashes in filenames (e.g. index-ChAS4tCC.js)
|
||||
registry.addResourceHandler("/assets/**")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath()
|
||||
+ "assets/",
|
||||
"classpath:/static/assets/")
|
||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
|
||||
.addResourceLocations(staticPath + "assets/", "classpath:/static/assets/")
|
||||
.setCacheControl(IMMUTABLE_ONE_YEAR)
|
||||
.resourceChain(true);
|
||||
|
||||
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
|
||||
// Note: index.html is handled by ReactRoutingController for dynamic processing
|
||||
registry.addResourceHandler("/index.html")
|
||||
// 3. Media and fonts (immutable)
|
||||
registry.addResourceHandler("/images/**", "/fonts/**")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
staticPath + "images/",
|
||||
"classpath:/static/images/",
|
||||
staticPath + "fonts/",
|
||||
"classpath:/static/fonts/")
|
||||
.setCacheControl(IMMUTABLE_ONE_YEAR)
|
||||
.resourceChain(true);
|
||||
|
||||
// Handle all other static resources (js, css, images, fonts, etc.)
|
||||
// Check customFiles/static first for user overrides
|
||||
// 4. Branding and stable non-fingerprinted assets (1 day + SWR)
|
||||
// Use stale-while-revalidate to improve perceived performance.
|
||||
registry.addResourceHandler(
|
||||
"/favicon.*",
|
||||
"/apple-touch-icon.png",
|
||||
"/android-chrome-*.png",
|
||||
"/mstile-*.png",
|
||||
"/safari-pinned-tab.svg",
|
||||
"/icons/**",
|
||||
"/modern-logo/**",
|
||||
"/classic-logo/**",
|
||||
"/robots.txt",
|
||||
"/3rdPartyLicenses.json",
|
||||
"/pdfjs/**",
|
||||
"/pdfjs-legacy/**",
|
||||
"/pdfium/**",
|
||||
"/locales/**",
|
||||
"/css/**",
|
||||
"/js/**",
|
||||
"/vendor/**",
|
||||
"/samples/**",
|
||||
"/og_images/**",
|
||||
"/Login/**",
|
||||
"/manifest-classic.json")
|
||||
.addResourceLocations(
|
||||
staticPath,
|
||||
"classpath:/static/",
|
||||
staticPath + "pdfjs/",
|
||||
"classpath:/static/pdfjs/",
|
||||
staticPath + "pdfjs-legacy/",
|
||||
"classpath:/static/pdfjs-legacy/",
|
||||
staticPath + "pdfium/",
|
||||
"classpath:/static/pdfium/",
|
||||
staticPath + "locales/",
|
||||
"classpath:/static/locales/",
|
||||
staticPath + "css/",
|
||||
"classpath:/static/css/",
|
||||
staticPath + "js/",
|
||||
"classpath:/static/js/",
|
||||
staticPath + "vendor/",
|
||||
"classpath:/static/vendor/",
|
||||
staticPath + "samples/",
|
||||
"classpath:/static/samples/",
|
||||
staticPath + "og_images/",
|
||||
"classpath:/static/og_images/",
|
||||
staticPath + "Login/",
|
||||
"classpath:/static/Login/")
|
||||
.setCacheControl(
|
||||
CacheControl.maxAge(Duration.ofDays(1))
|
||||
.cachePublic()
|
||||
.staleWhileRevalidate(Duration.ofDays(7)))
|
||||
.resourceChain(true);
|
||||
|
||||
// 5. Catch-all (SPA fallback)
|
||||
// Must check with server to ensure index.html is always fresh.
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
|
||||
.addResourceLocations(staticPath, "classpath:/static/")
|
||||
.setCacheControl(NO_CACHE)
|
||||
.resourceChain(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -115,9 +180,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
applicationProperties.getSystem().getCorsAllowedOrigins());
|
||||
|
||||
// Combine user-configured origins with Tauri origins
|
||||
java.util.List<String> allOrigins =
|
||||
new java.util.ArrayList<>(
|
||||
applicationProperties.getSystem().getCorsAllowedOrigins());
|
||||
List<String> allOrigins =
|
||||
new ArrayList<>(applicationProperties.getSystem().getCorsAllowedOrigins());
|
||||
|
||||
// Always include Tauri origins for desktop app compatibility
|
||||
// Tauri v1 uses tauri://localhost, v2 uses http(s)://tauri.localhost
|
||||
@@ -158,7 +222,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
} else {
|
||||
// Default to allowing all origins when nothing is configured
|
||||
logger.debug(
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
|
||||
"No CORS allowed origins configured in settings.yml"
|
||||
+ " (system.corsAllowedOrigins); WebMvcConfig allowing all origins.");
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
|
||||
+5
-1
@@ -32,6 +32,7 @@ import stirling.software.SPDF.model.json.PdfJsonTextElement;
|
||||
import stirling.software.SPDF.service.PdfJsonConversionService;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.model.api.general.EditTextOperation;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
@@ -75,7 +76,10 @@ public class EditTextController {
|
||||
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text")
|
||||
@AutoJobPostMapping(
|
||||
consumes = "multipart/form-data",
|
||||
value = "/edit-text",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Edit text in a PDF via find and replace",
|
||||
|
||||
+16
-4
@@ -275,7 +275,10 @@ public class ConvertImgPDFController {
|
||||
GeneralUtils.generateFilename(file[0].getOriginalFilename(), "_converted.pdf"));
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/cbz/pdf",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert CBZ comic book archive to PDF",
|
||||
description =
|
||||
@@ -301,7 +304,10 @@ public class ConvertImgPDFController {
|
||||
return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/pdf/cbz",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert PDF to CBZ comic book archive",
|
||||
description =
|
||||
@@ -324,7 +330,10 @@ public class ConvertImgPDFController {
|
||||
return WebResponseUtils.zipFileToWebResponse(cbzFile, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/cbr/pdf",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert CBR comic book archive to PDF",
|
||||
description =
|
||||
@@ -350,7 +359,10 @@ public class ConvertImgPDFController {
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/pdf/cbr",
|
||||
resourceWeight = ResourceWeight.LARGE_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Convert PDF to CBR comic book archive",
|
||||
description =
|
||||
|
||||
+10
-4
@@ -141,7 +141,8 @@ public class AttachmentController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/extract-attachments")
|
||||
value = "/extract-attachments",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Extract attachments from PDF",
|
||||
description =
|
||||
@@ -176,7 +177,10 @@ public class AttachmentController {
|
||||
}
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/list-attachments",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "List attachments in PDF",
|
||||
description =
|
||||
@@ -193,7 +197,8 @@ public class AttachmentController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/rename-attachment")
|
||||
value = "/rename-attachment",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Rename attachment in PDF",
|
||||
@@ -228,7 +233,8 @@ public class AttachmentController {
|
||||
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/delete-attachment")
|
||||
value = "/delete-attachment",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@StandardPdfResponse
|
||||
@Operation(
|
||||
summary = "Delete attachment from PDF",
|
||||
|
||||
+24
-2
@@ -119,11 +119,30 @@ public class ConfigController {
|
||||
String localIp = GeneralUtils.getLocalNetworkIp();
|
||||
if (localIp != null) {
|
||||
String scheme = appConfig.getBackendUrl().startsWith("https") ? "https" : "http";
|
||||
return scheme + "://" + localIp + ":" + appConfig.getServerPort();
|
||||
return scheme + "://" + localIp + ":" + resolveEffectiveServerPort(appConfig);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The port the embedded server is actually listening on. With {@code server.port=0} (an
|
||||
* ephemeral port, which the desktop bundle uses to dodge port clashes) the configured value
|
||||
* stays {@code "0"} while Spring publishes the real bound port as {@code local.server.port}
|
||||
* once the server is up. Advertised URLs (the mobile-scanner QR, share links) must carry the
|
||||
* real port - a literal {@code :0} is unreachable and browsers reject it as ERR_UNSAFE_PORT.
|
||||
*/
|
||||
// visible for testing
|
||||
String resolveEffectiveServerPort(AppConfig appConfig) {
|
||||
String configured = appConfig.getServerPort();
|
||||
if (configured == null || "0".equals(configured.trim())) {
|
||||
String actual = applicationContext.getEnvironment().getProperty("local.server.port");
|
||||
if (actual != null && !actual.isBlank()) {
|
||||
return actual;
|
||||
}
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
private static boolean isLoopbackHost(String host) {
|
||||
return "localhost".equalsIgnoreCase(host)
|
||||
|| "127.0.0.1".equals(host)
|
||||
@@ -161,7 +180,7 @@ public class ConfigController {
|
||||
// Note: Frontend expects "baseUrl" field name for compatibility
|
||||
configData.put("baseUrl", appConfig.getBackendUrl());
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
configData.put("serverPort", resolveEffectiveServerPort(appConfig));
|
||||
|
||||
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
|
||||
configData.put("frontendUrl", resolveFrontendUrl(request, appConfig));
|
||||
@@ -307,6 +326,9 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// AI Engine settings
|
||||
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
|
||||
|
||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||
ApplicationProperties.Security.Timestamp tsConfig =
|
||||
applicationProperties.getSecurity().getTimestamp();
|
||||
|
||||
+13
-3
@@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
@@ -134,13 +135,22 @@ public class ReactRoutingController {
|
||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
|
||||
try {
|
||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache().mustRevalidate())
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(cachedIndexHtml);
|
||||
}
|
||||
// Fallback: process on each request (dev mode or cache failed)
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache().mustRevalidate())
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(processIndexHtml());
|
||||
} catch (Exception ex) {
|
||||
log.error("Failed to serve index.html, returning fallback", ex);
|
||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml());
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noCache().mustRevalidate())
|
||||
.contentType(MediaType.TEXT_HTML)
|
||||
.body(buildFallbackHtml());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -13,7 +13,9 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.config.swagger.MarkdownConversionResponse;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.PyMuPdfConverter;
|
||||
import stirling.software.common.util.PDFToFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@@ -22,8 +24,12 @@ import stirling.software.common.util.TempFileManager;
|
||||
public class ConvertPDFToMarkdown {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
private final PyMuPdfConverter pyMuPdfConverter;
|
||||
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/markdown")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/pdf/markdown",
|
||||
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
|
||||
@MarkdownConversionResponse
|
||||
@Operation(
|
||||
summary = "Convert PDF to Markdown",
|
||||
@@ -33,6 +39,6 @@ public class ConvertPDFToMarkdown {
|
||||
throws Exception {
|
||||
MultipartFile inputFile = file.getFileInput();
|
||||
PDFToFile pdfToFile = new PDFToFile(tempFileManager);
|
||||
return pdfToFile.processPdfToMarkdown(inputFile);
|
||||
return pdfToFile.processPdfToMarkdown(inputFile, pyMuPdfConverter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ spring.security.filter.dispatcher-types=REQUEST,ERROR
|
||||
# Response compression
|
||||
server.compression.enabled=true
|
||||
server.compression.min-response-size=1024
|
||||
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript
|
||||
server.compression.mime-types=application/json,application/xml,text/html,text/plain,text/css,application/javascript,image/svg+xml,application/x-font-ttf,font/opentype,application/vnd.ms-fontobject,font/woff,font/woff2,application/font-woff,application/font-woff2
|
||||
|
||||
spring.web.error.path=/error
|
||||
spring.web.error.whitelabel.enabled=false
|
||||
|
||||
@@ -94,8 +94,8 @@ premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
enabled: false # Enable license key checks for pro/enterprise features
|
||||
proFeatures:
|
||||
SSOAutoLogin: false
|
||||
CustomMetadata:
|
||||
ssoAutoLogin: false
|
||||
customMetadata:
|
||||
autoUpdateMetadata: false
|
||||
author: username
|
||||
creator: Stirling-PDF
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.core.io.support.ResourcePatternResolver;
|
||||
import org.springframework.core.type.classreading.CachingMetadataReaderFactory;
|
||||
import org.springframework.core.type.classreading.MetadataReader;
|
||||
import org.springframework.core.type.classreading.MetadataReaderFactory;
|
||||
import org.springframework.core.type.filter.TypeFilter;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
|
||||
/**
|
||||
* Build-time guardrail: every {@link AutoJobPostMapping} method must declare an explicit {@code
|
||||
* resourceWeight}.
|
||||
*
|
||||
* <p>The credits interceptor multiplies {@code resourceWeight} into the per-call charge. An
|
||||
* endpoint that falls through to the annotation default produces a charge derived from a value
|
||||
* nobody chose — silently under- or over-billing depending on the endpoint's true cost. Forcing
|
||||
* each method to pick a value from {@link stirling.software.common.enumeration.ResourceWeight}
|
||||
* keeps the choice deliberate.
|
||||
*
|
||||
* <p>The annotation's default is {@link Integer#MIN_VALUE} (a sentinel). Runtime readers clamp the
|
||||
* value into {@code [1, 100]}, so a missed declaration can't crash production — this test is the
|
||||
* contract, the clamp is the safety net.
|
||||
*
|
||||
* <p>Lives in {@code :stirling-pdf} (core) because that's the module whose compile classpath
|
||||
* transitively sees every other module's controllers ({@code :common}, {@code :proprietary}, and
|
||||
* {@code :saas} when enabled).
|
||||
*/
|
||||
class AutoJobPostMappingWeightTest {
|
||||
|
||||
private static final String SCAN_BASE_PACKAGE = "stirling.software";
|
||||
|
||||
@Test
|
||||
void everyAutoJobPostMappingDeclaresExplicitResourceWeight() throws Exception {
|
||||
List<String> offenders = findOffendingMethods();
|
||||
|
||||
assertTrue(
|
||||
offenders.isEmpty(),
|
||||
() ->
|
||||
"The following @AutoJobPostMapping methods do not declare an explicit"
|
||||
+ " resourceWeight. Pick a value from"
|
||||
+ " stirling.software.common.enumeration.ResourceWeight (SMALL,"
|
||||
+ " MEDIUM, LARGE, XLARGE) and add it to the annotation:\n - "
|
||||
+ String.join("\n - ", offenders));
|
||||
}
|
||||
|
||||
private List<String> findOffendingMethods() throws IOException, ClassNotFoundException {
|
||||
List<String> offenders = new ArrayList<>();
|
||||
for (Class<?> candidate : scanForCandidateClasses()) {
|
||||
for (Method method : candidate.getDeclaredMethods()) {
|
||||
AutoJobPostMapping annotation = method.getAnnotation(AutoJobPostMapping.class);
|
||||
if (annotation == null) {
|
||||
continue;
|
||||
}
|
||||
if (annotation.resourceWeight() == Integer.MIN_VALUE) {
|
||||
offenders.add(candidate.getName() + "#" + method.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every class under {@link #SCAN_BASE_PACKAGE} that has an @AutoJobPostMapping method.
|
||||
*/
|
||||
private List<Class<?>> scanForCandidateClasses() throws IOException, ClassNotFoundException {
|
||||
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
MetadataReaderFactory metadataReaderFactory = new CachingMetadataReaderFactory(resolver);
|
||||
|
||||
String pattern = "classpath*:" + SCAN_BASE_PACKAGE.replace('.', '/') + "/**/*.class";
|
||||
Resource[] resources = resolver.getResources(pattern);
|
||||
|
||||
// Pre-filter by reading annotation metadata from the class file so we don't have to load
|
||||
// every class on the test classpath just to find the few that are annotated.
|
||||
TypeFilter mentionsAutoJobPostMapping =
|
||||
(reader, factory) ->
|
||||
reader.getAnnotationMetadata()
|
||||
.getAnnotatedMethods(AutoJobPostMapping.class.getName())
|
||||
.size()
|
||||
> 0;
|
||||
|
||||
List<Class<?>> matches = new ArrayList<>();
|
||||
for (Resource resource : resources) {
|
||||
if (!resource.isReadable()) {
|
||||
continue;
|
||||
}
|
||||
MetadataReader reader = metadataReaderFactory.getMetadataReader(resource);
|
||||
if (!mentionsAutoJobPostMapping.match(reader, metadataReaderFactory)) {
|
||||
continue;
|
||||
}
|
||||
matches.add(Class.forName(reader.getClassMetadata().getClassName()));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check that the classpath scan returns non-empty; otherwise the main test passes
|
||||
* vacuously.
|
||||
*/
|
||||
@Test
|
||||
void scannerFindsAtLeastOneAutoJobPostMapping() throws Exception {
|
||||
long count =
|
||||
scanForCandidateClasses().stream()
|
||||
.flatMap(c -> java.util.Arrays.stream(c.getDeclaredMethods()))
|
||||
.filter(m -> m.isAnnotationPresent(AutoJobPostMapping.class))
|
||||
.count();
|
||||
|
||||
assertTrue(
|
||||
count > 10,
|
||||
() ->
|
||||
"Expected the classpath scan to find many @AutoJobPostMapping methods but"
|
||||
+ " found only "
|
||||
+ count
|
||||
+ ". Scanner regression?");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static String describeCandidates(List<Class<?>> candidates) {
|
||||
return candidates.stream().map(Class::getName).collect(Collectors.joining(", "));
|
||||
}
|
||||
}
|
||||
+48
@@ -244,4 +244,52 @@ class ConfigControllerTest {
|
||||
assertNotNull(result);
|
||||
assertFalse(result.contains("localhost"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveFrontendUrl_usesActualPortWhenServerPortIsEphemeral() {
|
||||
System sys = mock(System.class);
|
||||
when(applicationProperties.getSystem()).thenReturn(sys);
|
||||
when(sys.getFrontendUrl()).thenReturn(null);
|
||||
|
||||
// Loopback host forces the detected-LAN-IP branch, which is where an
|
||||
// ephemeral server.port=0 would otherwise leak through as ":0".
|
||||
HttpServletRequest req = mock(HttpServletRequest.class);
|
||||
when(req.getServerName()).thenReturn("localhost");
|
||||
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
when(appConfig.getBackendUrl()).thenReturn("http://localhost");
|
||||
when(appConfig.getServerPort()).thenReturn("0");
|
||||
|
||||
org.springframework.core.env.Environment environment =
|
||||
mock(org.springframework.core.env.Environment.class);
|
||||
when(applicationContext.getEnvironment()).thenReturn(environment);
|
||||
when(environment.getProperty("local.server.port")).thenReturn("54321");
|
||||
|
||||
String result = configController.resolveFrontendUrl(req, appConfig);
|
||||
assertNotNull(result);
|
||||
assertTrue(result.endsWith(":54321"));
|
||||
assertFalse(result.contains(":0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveEffectiveServerPort_prefersActualBoundPortWhenConfiguredZero() {
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
when(appConfig.getServerPort()).thenReturn("0");
|
||||
|
||||
org.springframework.core.env.Environment environment =
|
||||
mock(org.springframework.core.env.Environment.class);
|
||||
when(applicationContext.getEnvironment()).thenReturn(environment);
|
||||
when(environment.getProperty("local.server.port")).thenReturn("54321");
|
||||
|
||||
assertEquals("54321", configController.resolveEffectiveServerPort(appConfig));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveEffectiveServerPort_keepsConfiguredNonZeroPort() {
|
||||
AppConfig appConfig = mock(AppConfig.class);
|
||||
when(appConfig.getServerPort()).thenReturn("8080");
|
||||
|
||||
// Non-zero configured port is authoritative; the runtime env is never consulted.
|
||||
assertEquals("8080", configController.resolveEffectiveServerPort(appConfig));
|
||||
}
|
||||
}
|
||||
|
||||
+11
-4
@@ -2,6 +2,7 @@ package stirling.software.SPDF.model.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
@@ -23,12 +24,13 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.service.PyMuPdfConverter;
|
||||
import stirling.software.common.util.PDFToFile;
|
||||
|
||||
class ConvertPDFToMarkdownTest {
|
||||
|
||||
private MockMvc mockMvc() {
|
||||
return MockMvcBuilders.standaloneSetup(new ConvertPDFToMarkdown(null))
|
||||
return MockMvcBuilders.standaloneSetup(new ConvertPDFToMarkdown(null, null))
|
||||
.setControllerAdvice(new GlobalErrorHandler())
|
||||
.build();
|
||||
}
|
||||
@@ -52,7 +54,9 @@ class ConvertPDFToMarkdownTest {
|
||||
Mockito.mockConstruction(
|
||||
PDFToFile.class,
|
||||
(mock, ctx) -> {
|
||||
when(mock.processPdfToMarkdown(any(MultipartFile.class)))
|
||||
when(mock.processPdfToMarkdown(
|
||||
any(MultipartFile.class),
|
||||
nullable(PyMuPdfConverter.class)))
|
||||
.thenAnswer(
|
||||
inv ->
|
||||
ResponseEntity.ok()
|
||||
@@ -83,7 +87,8 @@ class ConvertPDFToMarkdownTest {
|
||||
// And that the uploaded file was passed to processPdfToMarkdown()
|
||||
PDFToFile created = construction.constructed().get(0);
|
||||
ArgumentCaptor<MultipartFile> captor = ArgumentCaptor.forClass(MultipartFile.class);
|
||||
verify(created, times(1)).processPdfToMarkdown(captor.capture());
|
||||
verify(created, times(1))
|
||||
.processPdfToMarkdown(captor.capture(), nullable(PyMuPdfConverter.class));
|
||||
MultipartFile passed = captor.getValue();
|
||||
|
||||
// Minimal plausibility checks
|
||||
@@ -98,7 +103,9 @@ class ConvertPDFToMarkdownTest {
|
||||
Mockito.mockConstruction(
|
||||
PDFToFile.class,
|
||||
(mock, ctx) -> {
|
||||
when(mock.processPdfToMarkdown(any(MultipartFile.class)))
|
||||
when(mock.processPdfToMarkdown(
|
||||
any(MultipartFile.class),
|
||||
nullable(PyMuPdfConverter.class)))
|
||||
.thenThrow(new RuntimeException("boom"));
|
||||
})) {
|
||||
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package stirling.software.common.configuration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.YamlHelper;
|
||||
|
||||
/**
|
||||
* End-to-end check of the container-restart path. {@link ConfigInitializer#ensureConfigExists()} is
|
||||
* what runs on every startup, merging the on-disk settings.yml with the bundled
|
||||
* settings.yml.template. These tests exercise it against the real template on the classpath to
|
||||
* prove admin-saved proFeatures values survive a restart - the bug behind "the SSO auto-login
|
||||
* button resets every time the container resets".
|
||||
*/
|
||||
class ConfigInitializerRestartTest {
|
||||
|
||||
private static String read(Path settings, String... keyPath) throws IOException {
|
||||
return String.valueOf(new YamlHelper(settings).getValueByExactKeyPath(keyPath));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ssoAutoLoginAndCustomMetadata_persistAcrossRestart(@TempDir Path tmp) throws Exception {
|
||||
Path settings = tmp.resolve("settings.yml");
|
||||
Path custom = tmp.resolve("custom_settings.yml");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> paths =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
|
||||
paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString());
|
||||
|
||||
ConfigInitializer init = new ConfigInitializer();
|
||||
|
||||
// First boot: settings.yml created from the bundled template (camelCase, default off).
|
||||
init.ensureConfigExists();
|
||||
assertEquals("false", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
|
||||
|
||||
// Admin enables SSO auto-login and edits custom metadata via the exact save path the
|
||||
// admin settings controller uses.
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.ssoAutoLogin", true);
|
||||
GeneralUtils.saveKeyToSettings("premium.proFeatures.customMetadata.author", "acme");
|
||||
|
||||
// Container restart: ensureConfigExists merges the saved file with the template again.
|
||||
init.ensureConfigExists();
|
||||
|
||||
assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertEquals(
|
||||
"acme", read(settings, "premium", "proFeatures", "customMetadata", "author"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyPascalCaseConfig_isMigratedAndPreservedOnRestart(@TempDir Path tmp)
|
||||
throws Exception {
|
||||
Path settings = tmp.resolve("settings.yml");
|
||||
Path custom = tmp.resolve("custom_settings.yml");
|
||||
|
||||
try (MockedStatic<InstallationPathConfig> paths =
|
||||
mockStatic(InstallationPathConfig.class)) {
|
||||
paths.when(InstallationPathConfig::getSettingsPath).thenReturn(settings.toString());
|
||||
paths.when(InstallationPathConfig::getCustomSettingsPath).thenReturn(custom.toString());
|
||||
|
||||
ConfigInitializer init = new ConfigInitializer();
|
||||
|
||||
// Seed a full settings.yml as an OLD install would have written it: PascalCase keys
|
||||
// with
|
||||
// SSO auto-login enabled.
|
||||
init.ensureConfigExists();
|
||||
String legacy =
|
||||
Files.readString(settings)
|
||||
.replace("ssoAutoLogin: false", "SSOAutoLogin: true")
|
||||
.replace("customMetadata:", "CustomMetadata:");
|
||||
Files.writeString(settings, legacy);
|
||||
|
||||
// Upgrade restart.
|
||||
init.ensureConfigExists();
|
||||
|
||||
// Value carried forward onto the new camelCase key; the legacy PascalCase key is gone.
|
||||
assertEquals("true", read(settings, "premium", "proFeatures", "ssoAutoLogin"));
|
||||
assertNull(
|
||||
new YamlHelper(settings)
|
||||
.getValueByExactKeyPath("premium", "proFeatures", "SSOAutoLogin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -32,7 +32,10 @@ public enum AiPdfContentType {
|
||||
|
||||
// Heavy content
|
||||
COMPLIANCE("compliance"),
|
||||
IMAGES("images");
|
||||
IMAGES("images"),
|
||||
|
||||
// PyMuPDF worker — pre-rendered Markdown
|
||||
PYMUPDF_MARKDOWN("pymupdf_markdown");
|
||||
|
||||
private final String value;
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Run an AI workflow against one or more PDF files")
|
||||
@Schema(description = "Run an AI workflow")
|
||||
public class AiWorkflowRequest {
|
||||
|
||||
@NotNull
|
||||
|
||||
+5
-1
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.enumeration.ResourceWeight;
|
||||
import stirling.software.proprietary.security.model.api.Email;
|
||||
import stirling.software.proprietary.security.service.EmailService;
|
||||
|
||||
@@ -39,7 +40,10 @@ public class EmailController {
|
||||
* attachment.
|
||||
* @return ResponseEntity with success or error message.
|
||||
*/
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/send-email")
|
||||
@AutoJobPostMapping(
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
|
||||
value = "/send-email",
|
||||
resourceWeight = ResourceWeight.SMALL_WEIGHT)
|
||||
@Operation(
|
||||
summary = "Send an email with an attachment",
|
||||
description =
|
||||
|
||||
+38
@@ -31,6 +31,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.PyMuPdfConverter;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.TempFile;
|
||||
@@ -74,6 +75,7 @@ public class AiWorkflowService {
|
||||
private final TempFileManager tempFileManager;
|
||||
private final FileIdStrategy fileIdStrategy;
|
||||
private final AiEngineEndpointResolver endpointResolver;
|
||||
private final PyMuPdfConverter pyMuPdfConverter;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ProgressListener {
|
||||
@@ -137,6 +139,9 @@ public class AiWorkflowService {
|
||||
? new ArrayList<>()
|
||||
: new ArrayList<>(request.getConversationHistory()));
|
||||
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
|
||||
boolean workerAvailable = pyMuPdfConverter.isAvailable();
|
||||
initialRequest.setPymupdfWorkerAvailable(workerAvailable);
|
||||
log.info("[pymupdf-convert] available={}", workerAvailable);
|
||||
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
|
||||
|
||||
@@ -183,6 +188,13 @@ public class AiWorkflowService {
|
||||
cannotContinue("AI engine requested content extraction more than once."));
|
||||
}
|
||||
|
||||
// Fast path: when the engine identifies a pdf-to-markdown task and pymupdf-convert is
|
||||
// available, skip feeding content back to the engine and convert directly.
|
||||
if ("pdf_to_markdown".equals(response.getResumeWith())
|
||||
&& request.isPymupdfWorkerAvailable()) {
|
||||
return runPyMuPdfConversion(filesById, listener);
|
||||
}
|
||||
|
||||
List<AiWorkflowFileRequest> requestedFiles = response.getFiles();
|
||||
|
||||
// Validate requested file ids before loading anything
|
||||
@@ -365,6 +377,31 @@ public class AiWorkflowService {
|
||||
return new WorkflowState.Terminal(response);
|
||||
}
|
||||
|
||||
private WorkflowState runPyMuPdfConversion(
|
||||
Map<String, MultipartFile> filesById, ProgressListener listener) throws IOException {
|
||||
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.PROCESSING));
|
||||
List<Resource> outputs = new ArrayList<>();
|
||||
for (MultipartFile file : filesById.values()) {
|
||||
String baseName =
|
||||
file.getOriginalFilename() != null
|
||||
? file.getOriginalFilename().replaceFirst("\\.[^.]+$", "")
|
||||
: "document";
|
||||
String markdown =
|
||||
pyMuPdfConverter.convertToMarkdown(file.getBytes(), file.getOriginalFilename());
|
||||
String safeFilename = Filenames.toSimpleFileName(baseName + ".md");
|
||||
byte[] bytes = markdown.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
outputs.add(
|
||||
new org.springframework.core.io.ByteArrayResource(bytes) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return safeFilename;
|
||||
}
|
||||
});
|
||||
}
|
||||
return new WorkflowState.Terminal(
|
||||
buildCompletedResponse("Converted PDF to Markdown.", outputs, List.of(), null));
|
||||
}
|
||||
|
||||
private WorkflowState onGenerateFile(AiWorkflowResponse response, ProgressListener listener)
|
||||
throws IOException {
|
||||
String content = response.getGeneratedContent();
|
||||
@@ -745,5 +782,6 @@ public class AiWorkflowService {
|
||||
private List<WorkflowArtifact> artifacts = new ArrayList<>();
|
||||
private String resumeWith;
|
||||
private List<String> enabledEndpoints = new ArrayList<>();
|
||||
private boolean pymupdfWorkerAvailable;
|
||||
}
|
||||
}
|
||||
|
||||
+53
-1
@@ -30,6 +30,7 @@ import stirling.software.SPDF.pdf.parser.PdfModels.RawLine;
|
||||
import stirling.software.SPDF.pdf.parser.PdfModels.TableFragment;
|
||||
import stirling.software.SPDF.pdf.parser.PdfModels.TextFragment;
|
||||
import stirling.software.SPDF.pdf.parser.TabulaTableParser;
|
||||
import stirling.software.common.service.PyMuPdfConverter;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.proprietary.model.api.ai.AiPdfContentType;
|
||||
@@ -44,6 +45,7 @@ public class PdfContentExtractor {
|
||||
|
||||
private final TabulaTableParser tabulaTableParser;
|
||||
private final PdfIngester pdfIngester;
|
||||
private final PyMuPdfConverter pyMuPdfConverter;
|
||||
|
||||
private static final int MAX_CHARACTERS_PER_PAGE = 4_000;
|
||||
|
||||
@@ -190,6 +192,8 @@ public class PdfContentExtractor {
|
||||
extractText(lf, fileReq, remainingPages, remainingCharacters));
|
||||
case PAGE_LAYOUT ->
|
||||
Optional.<PdfContentResult>ofNullable(extractPageLayout(lf, remainingPages));
|
||||
case PYMUPDF_MARKDOWN ->
|
||||
Optional.<PdfContentResult>ofNullable(extractPyMuPdfMarkdown(lf));
|
||||
default -> {
|
||||
log.warn(
|
||||
"Content type {} not yet implemented, skipping for {}",
|
||||
@@ -255,6 +259,11 @@ public class PdfContentExtractor {
|
||||
artifact.setFiles(results.stream().map(PageLayoutFileResult.class::cast).toList());
|
||||
yield artifact;
|
||||
}
|
||||
case PYMUPDF_MARKDOWN -> {
|
||||
PyMuPdfMarkdownArtifact artifact = new PyMuPdfMarkdownArtifact();
|
||||
artifact.setFiles(results.stream().map(PyMuPdfMarkdownResult.class::cast).toList());
|
||||
yield artifact;
|
||||
}
|
||||
case TOOL_REPORT ->
|
||||
throw new IllegalArgumentException(
|
||||
"TOOL_REPORT artifacts are not produced by PdfContentExtractor");
|
||||
@@ -370,7 +379,8 @@ public class PdfContentExtractor {
|
||||
enum ArtifactKind {
|
||||
EXTRACTED_TEXT("extracted_text"),
|
||||
PAGE_LAYOUT("page_layout"),
|
||||
TOOL_REPORT("tool_report");
|
||||
TOOL_REPORT("tool_report"),
|
||||
PYMUPDF_MARKDOWN("pymupdf_markdown");
|
||||
|
||||
private final String value;
|
||||
|
||||
@@ -469,4 +479,46 @@ public class PdfContentExtractor {
|
||||
private final ArtifactKind kind = ArtifactKind.PAGE_LAYOUT;
|
||||
private List<PageLayoutFileResult> files = new ArrayList<>();
|
||||
}
|
||||
|
||||
private PyMuPdfMarkdownResult extractPyMuPdfMarkdown(LoadedFile lf) {
|
||||
try {
|
||||
log.info("[pymupdf-convert] converting file={}", lf.fileName());
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
lf.document().save(baos);
|
||||
String markdown = pyMuPdfConverter.convertToMarkdown(baos.toByteArray(), lf.fileName());
|
||||
log.info(
|
||||
"[pymupdf-convert] success file={} markdown-chars={}",
|
||||
lf.fileName(),
|
||||
markdown.length());
|
||||
PyMuPdfMarkdownResult result = new PyMuPdfMarkdownResult();
|
||||
result.setFileName(lf.fileName());
|
||||
result.setMarkdown(markdown);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"[pymupdf-convert] failed for file={}, falling back to page layout: {}",
|
||||
lf.fileName(),
|
||||
e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** PyMuPDF worker pre-rendered Markdown for one file. */
|
||||
@Data
|
||||
static final class PyMuPdfMarkdownResult implements PdfContentResult {
|
||||
private String fileName;
|
||||
private String markdown;
|
||||
|
||||
@Override
|
||||
public ArtifactKind getArtifactKind() {
|
||||
return ArtifactKind.PYMUPDF_MARKDOWN;
|
||||
}
|
||||
}
|
||||
|
||||
/** Artifact carrying PyMuPDF-rendered Markdown for all input files. */
|
||||
@Data
|
||||
static final class PyMuPdfMarkdownArtifact implements WorkflowArtifact {
|
||||
private final ArtifactKind kind = ArtifactKind.PYMUPDF_MARKDOWN;
|
||||
private List<PyMuPdfMarkdownResult> files = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -49,6 +49,7 @@ import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.FileStorage.StoredFile;
|
||||
import stirling.software.common.service.InternalApiClient;
|
||||
import stirling.software.common.service.InternalApiTimeoutException;
|
||||
import stirling.software.common.service.PyMuPdfConverter;
|
||||
import stirling.software.common.service.ToolMetadataService;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
@@ -85,6 +86,7 @@ class AiWorkflowServiceTest {
|
||||
@Mock private ToolMetadataService toolMetadataService;
|
||||
@Mock private FileIdStrategy fileIdStrategy;
|
||||
@Mock private AiEngineEndpointResolver endpointResolver;
|
||||
@Mock private PyMuPdfConverter pyMuPdfConverter;
|
||||
|
||||
@TempDir Path tempDir;
|
||||
|
||||
@@ -117,7 +119,8 @@ class AiWorkflowServiceTest {
|
||||
toolMetadataService,
|
||||
tempFileManager,
|
||||
fileIdStrategy,
|
||||
endpointResolver);
|
||||
endpointResolver,
|
||||
pyMuPdfConverter);
|
||||
when(endpointResolver.getEnabledEndpointUrls()).thenReturn(List.of());
|
||||
}
|
||||
|
||||
|
||||
@@ -5,18 +5,24 @@ import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/** Registers the {@code :saas} module's entities and repositories with Spring Data JPA. */
|
||||
/**
|
||||
* Registers the {@code :saas} module's entities and repositories with Spring Data JPA. Any new
|
||||
* package holding {@code @Repository} or {@code @Entity} classes must be added here, or the beans
|
||||
* won't wire at startup.
|
||||
*/
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
@EnableJpaRepositories(
|
||||
basePackages = {
|
||||
"stirling.software.saas.repository",
|
||||
"stirling.software.saas.billing.repository",
|
||||
"stirling.software.saas.ai.repository"
|
||||
"stirling.software.saas.ai.repository",
|
||||
"stirling.software.saas.payg.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.saas.model",
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model"
|
||||
"stirling.software.saas.ai.model",
|
||||
"stirling.software.saas.payg"
|
||||
})
|
||||
public class SaasJpaConfig {}
|
||||
|
||||
@@ -73,6 +73,13 @@ public class TeamMembership implements Serializable {
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Optional per-member spend cap inside the team's wallet, in doc units. NULL means the member
|
||||
* is bounded only by the team-wide cap.
|
||||
*/
|
||||
@Column(name = "cap_units")
|
||||
private Long capUnits;
|
||||
|
||||
public boolean isLeader() {
|
||||
return role == TeamRole.LEADER;
|
||||
}
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.jpdfium.PdfDocument;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
/**
|
||||
* Reads pages via jpdfium for PDF inputs; treats every other content type as bytes-only.
|
||||
*
|
||||
* <p>For PDFs, units are the larger of {@code ceil(pages / docPagesPerUnit)} and {@code ceil(bytes
|
||||
* / docBytesPerUnit)}. For non-PDFs, only the bytes axis contributes. A single file is clamped to
|
||||
* {@code [1, policy.fileUnitCap]}; a multi-file group is clamped to {@code [1, policy.fileUnitCap *
|
||||
* file_count]} applied to the sum of raw per-file units. Malformed/encrypted PDFs fall back to
|
||||
* bytes-only.
|
||||
*
|
||||
* <p>{@code policy.minChargeUnits} is applied by the charge service, not here. The classifier only
|
||||
* enforces an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on
|
||||
* "non-empty input → at least 1 unit".
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultDocumentClassifier implements DocumentClassifier {
|
||||
|
||||
private static final String PDF_CONTENT_TYPE = "application/pdf";
|
||||
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
||||
|
||||
/** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */
|
||||
private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@Override
|
||||
public DocumentMetrics classify(MultipartFile file, PricingPolicy policy) {
|
||||
Objects.requireNonNull(file, "file");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
|
||||
FileFacts facts = inspect(file);
|
||||
long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy);
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
|
||||
int units =
|
||||
Math.toIntExact(
|
||||
Math.max(
|
||||
MIN_UNITS_PER_NONEMPTY_FILE,
|
||||
Math.min(policy.getFileUnitCap(), rawUnits)));
|
||||
return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DocumentMetrics classify(List<MultipartFile> files, PricingPolicy policy) {
|
||||
Objects.requireNonNull(files, "files");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
if (files.isEmpty()) {
|
||||
throw new IllegalArgumentException("files must not be empty");
|
||||
}
|
||||
|
||||
int totalPages = 0;
|
||||
long totalBytes = 0;
|
||||
long rawUnitsSum = 0;
|
||||
String firstContentType = null;
|
||||
|
||||
for (MultipartFile file : files) {
|
||||
FileFacts facts = inspect(file);
|
||||
// Sum the *raw* (unclamped) per-file units so the group cap below can actually bind.
|
||||
// Per-file clamping in this loop would make the group cap a no-op.
|
||||
rawUnitsSum =
|
||||
saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy));
|
||||
totalPages = saturatedAdd(totalPages, facts.pages);
|
||||
totalBytes = saturatedAdd(totalBytes, facts.bytes);
|
||||
if (firstContentType == null) {
|
||||
firstContentType = facts.contentType;
|
||||
}
|
||||
}
|
||||
|
||||
long groupCap = (long) policy.getFileUnitCap() * files.size();
|
||||
// toIntExact: fail loud on overflow rather than silently wrapping.
|
||||
int totalUnits =
|
||||
Math.toIntExact(
|
||||
Math.max(
|
||||
(long) MIN_UNITS_PER_NONEMPTY_FILE,
|
||||
Math.min(groupCap, rawUnitsSum)));
|
||||
|
||||
return new DocumentMetrics(
|
||||
totalPages,
|
||||
totalBytes,
|
||||
firstContentType != null ? firstContentType : DEFAULT_CONTENT_TYPE,
|
||||
totalUnits);
|
||||
}
|
||||
|
||||
private FileFacts inspect(MultipartFile file) {
|
||||
long bytes = file.getSize();
|
||||
String contentType =
|
||||
file.getContentType() != null ? file.getContentType() : DEFAULT_CONTENT_TYPE;
|
||||
int pages = isPdf(contentType, file.getOriginalFilename()) ? readPageCount(file) : 0;
|
||||
return new FileFacts(pages, bytes, contentType);
|
||||
}
|
||||
|
||||
private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) {
|
||||
long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L;
|
||||
long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit());
|
||||
return Math.max(pageUnits, byteUnits);
|
||||
}
|
||||
|
||||
private static long ceilDiv(long numerator, long divisor) {
|
||||
if (numerator <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (numerator + divisor - 1) / divisor;
|
||||
}
|
||||
|
||||
private static boolean isPdf(String contentType, String filename) {
|
||||
if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) {
|
||||
return true;
|
||||
}
|
||||
return filename != null && filename.toLowerCase().endsWith(".pdf");
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialises the upload to a managed temp file and asks jpdfium for the page count. Returns 0
|
||||
* if the file can't be parsed — the byte-derived axis still produces a charge.
|
||||
*/
|
||||
private int readPageCount(MultipartFile file) {
|
||||
try (TempFile temp = tempFileManager.createManagedTempFile(".pdf")) {
|
||||
try (InputStream in = file.getInputStream();
|
||||
OutputStream out = Files.newOutputStream(temp.getPath())) {
|
||||
in.transferTo(out);
|
||||
}
|
||||
try (PdfDocument doc = PdfDocument.open(temp.getPath())) {
|
||||
return doc.pageCount();
|
||||
}
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.debug(
|
||||
"Could not read PDF page count for {} ({}); falling back to bytes-only units",
|
||||
file.getOriginalFilename(),
|
||||
e.getClass().getSimpleName());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static int saturatedAdd(int a, int b) {
|
||||
long sum = (long) a + b;
|
||||
if (sum > Integer.MAX_VALUE) {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
return (int) sum;
|
||||
}
|
||||
|
||||
private static long saturatedAdd(long a, long b) {
|
||||
try {
|
||||
return Math.addExact(a, b);
|
||||
} catch (ArithmeticException e) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private record FileFacts(int pages, long bytes, String contentType) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
/**
|
||||
* Computes the doc-unit cost of an uploaded file (or multi-file input) under a given policy.
|
||||
*
|
||||
* <p>Returns {@code docUnits} with an absolute floor of 1 for non-empty input. {@code
|
||||
* policy.minChargeUnits} is applied at charge time, not here.
|
||||
*/
|
||||
public interface DocumentClassifier {
|
||||
|
||||
/** Classify a single uploaded file. Returns at least 1 unit, capped at {@code fileUnitCap}. */
|
||||
DocumentMetrics classify(MultipartFile file, PricingPolicy policy);
|
||||
|
||||
/**
|
||||
* Classify a multi-file input (e.g. a merge or overlay). Returns the sum of each file's raw
|
||||
* units, capped at {@code fileUnitCap × files.size()} and floored at 1.
|
||||
*/
|
||||
DocumentMetrics classify(List<MultipartFile> files, PricingPolicy policy);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
/**
|
||||
* Output of {@link DocumentClassifier#classify}. {@code pages} is {@code 0} for non-PDF inputs.
|
||||
*
|
||||
* @param pages page count (0 for non-PDFs and for files whose page count couldn't be read)
|
||||
* @param bytes raw byte length of the file
|
||||
* @param contentType MIME type as reported by the upload, or {@code "application/octet-stream"}
|
||||
* when unknown
|
||||
* @param docUnits computed unit cost, clamped to the policy's {@code fileUnitCap}
|
||||
*/
|
||||
public record DocumentMetrics(int pages, long bytes, String contentType, int docUnits) {}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package stirling.software.saas.payg.entitlement;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
import jakarta.persistence.EmbeddedId;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
|
||||
/**
|
||||
* Cached entitlement state for the team (one row with {@code user_id = 0}, the team-wide sentinel)
|
||||
* plus optional per-member rows when a member sub-cap is configured. Read on the hot path by the
|
||||
* entitlement guard.
|
||||
*
|
||||
* <p>Composite PK {@code (team_id, user_id)} uses 0 as the team-wide sentinel because Postgres
|
||||
* treats {@code NULL} as not-equal-to-NULL in unique constraints — 0 keeps the PK well-defined.
|
||||
*
|
||||
* <p>No {@code @Version} — rows are produced by full-row recompute, no read-modify-write race.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "wallet_entitlement_snapshot")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class WalletEntitlementSnapshot implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public static final long TEAM_WIDE_USER_ID = 0L;
|
||||
|
||||
@EmbeddedId private WalletEntitlementSnapshotId id;
|
||||
|
||||
@Column(name = "period_start", nullable = false)
|
||||
private LocalDateTime periodStart;
|
||||
|
||||
@Column(name = "period_end", nullable = false)
|
||||
private LocalDateTime periodEnd;
|
||||
|
||||
@Column(name = "period_spend_units", nullable = false)
|
||||
private Long periodSpendUnits = 0L;
|
||||
|
||||
@Column(name = "period_cap_units")
|
||||
private Long periodCapUnits;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "state", nullable = false, length = 16)
|
||||
private EntitlementState state = EntitlementState.FULL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "feature_set", nullable = false, length = 32)
|
||||
private FeatureSet featureSet = FeatureSet.FULL;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "enabled_gates", columnDefinition = "jsonb", nullable = false)
|
||||
private List<FeatureGate> enabledGates = new ArrayList<>();
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "computed_at", nullable = false, updatable = false)
|
||||
private LocalDateTime computedAt;
|
||||
|
||||
@Embeddable
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public static class WalletEntitlementSnapshotId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
/** Use {@link #TEAM_WIDE_USER_ID} for the team-wide row. */
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
public WalletEntitlementSnapshotId(Long teamId, Long userId) {
|
||||
this.teamId = teamId;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof WalletEntitlementSnapshotId other)) return false;
|
||||
return Objects.equals(teamId, other.teamId) && Objects.equals(userId, other.userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(teamId, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
import jakarta.persistence.EmbeddedId;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.ArtifactKind;
|
||||
|
||||
/**
|
||||
* Per-step input/output content hash. Used by the lineage detector to decide whether a tool call
|
||||
* joins an open process (matching an earlier input or output) or opens a new one.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "job_artifact_hash")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class JobArtifactHash implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@EmbeddedId private JobArtifactHashId id;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Embeddable
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public static class JobArtifactHashId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Column(name = "job_id", nullable = false)
|
||||
private UUID jobId;
|
||||
|
||||
/** {@code "type:value"} signature key; 128 chars fits SHA-256 plus future schemes. */
|
||||
@Column(name = "content_hash", nullable = false, length = 128)
|
||||
private String contentHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "kind", nullable = false, length = 8)
|
||||
private ArtifactKind kind;
|
||||
|
||||
public JobArtifactHashId(UUID jobId, String contentHash, ArtifactKind kind) {
|
||||
this.jobId = jobId;
|
||||
this.contentHash = contentHash;
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof JobArtifactHashId other)) return false;
|
||||
return Objects.equals(jobId, other.jobId)
|
||||
&& Objects.equals(contentHash, other.contentHash)
|
||||
&& kind == other.kind;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(jobId, contentHash, kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
import stirling.software.saas.payg.model.ProcessType;
|
||||
|
||||
/**
|
||||
* One process — a workflow that may comprise multiple lineage-linked tool calls but is billed once
|
||||
* at process open. Closed by an explicit caller, by the frontend, or by the stale-close scheduler.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "processing_job")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcessingJob implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "job_id")
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "owner_user_id", nullable = false)
|
||||
private Long ownerUserId;
|
||||
|
||||
@Column(name = "owner_team_id")
|
||||
private Long ownerTeamId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "process_type", nullable = false, length = 32)
|
||||
private ProcessType processType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source", nullable = false, length = 32)
|
||||
private JobSource source;
|
||||
|
||||
/** SHA-256 of the union of input file hashes; null if the input set is mixed or unknown. */
|
||||
@Column(name = "document_fingerprint", length = 64)
|
||||
private String documentFingerprint;
|
||||
|
||||
@Column(name = "doc_units", nullable = false)
|
||||
private Integer docUnits = 0;
|
||||
|
||||
@Column(name = "step_count", nullable = false)
|
||||
private Integer stepCount = 0;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "last_step_at", nullable = false)
|
||||
private LocalDateTime lastStepAt;
|
||||
|
||||
@Column(name = "closed_at")
|
||||
private LocalDateTime closedAt;
|
||||
|
||||
@Column(name = "policy_id", nullable = false)
|
||||
private Long policyId;
|
||||
|
||||
/** Filled at close-time; absent while the job is still OPEN. */
|
||||
@Column(name = "charged_units")
|
||||
private Integer chargedUnits;
|
||||
|
||||
/** Cached money equivalent for receipts; not used by cap evaluation. */
|
||||
@Column(name = "charged_cents")
|
||||
private Integer chargedCents;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 32)
|
||||
private JobStatus status;
|
||||
|
||||
/** Stable idempotency key for the open-process Stripe meter event. */
|
||||
@Column(name = "idempotency_key", unique = true, length = 128)
|
||||
private String idempotencyKey;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "metadata", columnDefinition = "jsonb")
|
||||
private Map<String, Object> metadata = new HashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.JobStepStatus;
|
||||
|
||||
/** One tool invocation inside a {@link ProcessingJob}. Free after the first; carries audit data. */
|
||||
@Entity
|
||||
@Table(name = "processing_job_step")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ProcessingJobStep implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "step_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "job_id", nullable = false)
|
||||
private UUID jobId;
|
||||
|
||||
/** Endpoint path, e.g. {@code /api/v1/general/split-pages}. */
|
||||
@Column(name = "tool_id", nullable = false, length = 128)
|
||||
private String toolId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 32)
|
||||
private JobStepStatus status;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "completed_at")
|
||||
private LocalDateTime completedAt;
|
||||
|
||||
@Column(name = "input_pages")
|
||||
private Integer inputPages;
|
||||
|
||||
@Column(name = "input_bytes")
|
||||
private Long inputBytes;
|
||||
|
||||
@Column(name = "error_code", length = 64)
|
||||
private String errorCode;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Whether a recorded content hash belongs to a job step's input or its output. */
|
||||
public enum ArtifactKind {
|
||||
INPUT,
|
||||
OUTPUT
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Whether a team's tool calls auto-group into multi-step processes via content-hash lineage. {@code
|
||||
* OFF} forces every call into its own single-step process.
|
||||
*/
|
||||
public enum AutoGroupStrategy {
|
||||
AUTO,
|
||||
OFF
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum CapPeriod {
|
||||
CALENDAR_MONTH,
|
||||
CALENDAR_QUARTER,
|
||||
CALENDAR_YEAR,
|
||||
BILLING_CYCLE
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum EntitlementState {
|
||||
FULL,
|
||||
WARNED,
|
||||
DEGRADED
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Coarse capability flags evaluated by the entitlement guard before letting a request proceed. */
|
||||
public enum FeatureGate {
|
||||
OFFSITE_PROCESSING,
|
||||
AUTOMATION,
|
||||
AI_SUPPORT,
|
||||
CLIENT_SIDE
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Bundles of {@link FeatureGate}s exposed at the team / member level. */
|
||||
public enum FeatureSet {
|
||||
FULL,
|
||||
MINIMAL,
|
||||
CLIENT_ONLY
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Where a tool invocation originated on the client side. <strong>Caller surface only</strong> —
|
||||
* this enum does not encode whether the request was served by SaaS or by a self-hosted instance.
|
||||
* That distinction lives at the team / policy level: self-hosted instances bind to their own team
|
||||
* (via {@code license_keys.team_id}) which carries its own {@code pricing_policy_id}.
|
||||
*
|
||||
* <p>Used as the key for per-source step limits on {@code pricing_policy.step_limits}.
|
||||
*/
|
||||
public enum JobSource {
|
||||
WEB,
|
||||
API,
|
||||
PIPELINE,
|
||||
/**
|
||||
* The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend.
|
||||
*/
|
||||
DESKTOP_APP
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum JobStatus {
|
||||
OPEN,
|
||||
CLOSED,
|
||||
REFUNDED,
|
||||
PARTIAL_REFUND,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum JobStepStatus {
|
||||
OK,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Which pool a ledger entry touches. Debits flow CYCLE → BOUGHT → OVERAGE in that order. */
|
||||
public enum LedgerBucket {
|
||||
CYCLE,
|
||||
BOUGHT,
|
||||
OVERAGE
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
public enum LedgerEntryType {
|
||||
CYCLE_GRANT,
|
||||
DEBIT,
|
||||
REFUND,
|
||||
EXPIRE,
|
||||
OVERAGE_REPORTED,
|
||||
ADJUSTMENT,
|
||||
LEGACY_BACKFILL
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Shape of the workflow the job represents. Recorded for analytics; per-process step limits live on
|
||||
* {@link JobSource} now.
|
||||
*/
|
||||
public enum ProcessType {
|
||||
SINGLE_TOOL,
|
||||
CHAIN,
|
||||
AUTOMATION
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** What a {@code wallet_ledger.reference_id} points at. */
|
||||
public enum ReferenceType {
|
||||
JOB,
|
||||
INVOICE,
|
||||
STRIPE_EVENT,
|
||||
ADMIN
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/** Which charging engine a wallet is running. Flipped per-team during cutover. */
|
||||
public enum WalletEngine {
|
||||
LEGACY,
|
||||
PAYG_SHADOW,
|
||||
PAYG
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.OnDelete;
|
||||
import org.hibernate.annotations.OnDeleteAction;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.MapsId;
|
||||
import jakarta.persistence.OneToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
|
||||
/**
|
||||
* Sidecar carrying PAYG-only team fields. 1:1 with {@link Team} via shared PK so OSS Hibernate
|
||||
* (which only sees the proprietary {@link Team} entity) never tries to add PAYG columns to the
|
||||
* shared {@code teams} table. Mirrors the existing {@code SaasTeamExtensions} pattern.
|
||||
*
|
||||
* <p>Created lazily on first PAYG access for a team.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payg_team_extensions")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PaygTeamExtensions implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "team_id")
|
||||
private Long teamId;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@MapsId
|
||||
@JoinColumn(name = "team_id")
|
||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||
private Team team;
|
||||
|
||||
/** Per-team policy override; NULL means use the default row in {@code pricing_policy}. */
|
||||
@Column(name = "pricing_policy_id")
|
||||
private Long pricingPolicyId;
|
||||
|
||||
/** Stripe customer id for this team. Eager-created so every team has billing identity. */
|
||||
@Column(name = "stripe_customer_id", unique = true, length = 128)
|
||||
private String stripeCustomerId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Version
|
||||
@Column(name = "version")
|
||||
private Long version;
|
||||
|
||||
public PaygTeamExtensions(Team team) {
|
||||
this.team = team;
|
||||
this.teamId = team.getId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* Fires after a successful admin write to a {@code pricing_policy*} or {@code
|
||||
* payg_team_extensions.pricing_policy_id} row. {@link PricingPolicyService} listens and invalidates
|
||||
* its in-process cache so the writer instance reflects the change immediately. Other instances pick
|
||||
* up the change on the next 30-second TTL expiry.
|
||||
*
|
||||
* <p>{@code payload} is informational only ({@code "create:42"}, {@code "setDefault:7"}, etc.) —
|
||||
* the invalidation strategy is "blow the whole cache" regardless of what changed.
|
||||
*/
|
||||
public class PolicyChangedEvent extends ApplicationEvent {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String payload;
|
||||
|
||||
public PolicyChangedEvent(Object source, String payload) {
|
||||
super(source);
|
||||
this.payload = payload;
|
||||
}
|
||||
|
||||
public String getPayload() {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.MapKeyColumn;
|
||||
import jakarta.persistence.MapKeyEnumerated;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
|
||||
/**
|
||||
* Versioned pricing policy. Unit-calculation knobs, per-source step limits, and the per-currency
|
||||
* Stripe price IDs that turn doc-units into invoice amounts. Money lives in Stripe; this row
|
||||
* carries everything else.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "pricing_policy")
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PricingPolicy implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "policy_id")
|
||||
private Long id;
|
||||
|
||||
/** Human-readable version label, e.g. {@code v1-2026-06}. Unique across all policies. */
|
||||
@Column(name = "version", nullable = false, unique = true, length = 32)
|
||||
private String version;
|
||||
|
||||
@Column(name = "effective_from", nullable = false)
|
||||
private LocalDateTime effectiveFrom;
|
||||
|
||||
/** Null while the policy is the current one in its lineage. */
|
||||
@Column(name = "effective_to")
|
||||
private LocalDateTime effectiveTo;
|
||||
|
||||
@Column(name = "doc_pages_per_unit", nullable = false)
|
||||
private Integer docPagesPerUnit;
|
||||
|
||||
@Column(name = "doc_bytes_per_unit", nullable = false)
|
||||
private Long docBytesPerUnit;
|
||||
|
||||
@Column(name = "min_charge_units", nullable = false)
|
||||
private Integer minChargeUnits = 1;
|
||||
|
||||
@Column(name = "file_unit_cap", nullable = false)
|
||||
private Integer fileUnitCap = 1000;
|
||||
|
||||
/**
|
||||
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
|
||||
* JobSource}. Self-hosted teams typically get a higher limit via a per-team policy override.
|
||||
*
|
||||
* <p>Persisted as a normalized child table {@code pricing_policy_step_limit (policy_id,
|
||||
* job_source, step_limit)} rather than JSONB — values are typed and queryable directly.
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(
|
||||
name = "pricing_policy_step_limit",
|
||||
joinColumns = @JoinColumn(name = "policy_id"))
|
||||
@MapKeyEnumerated(EnumType.STRING)
|
||||
@MapKeyColumn(name = "job_source", length = 32)
|
||||
@Column(name = "step_limit", nullable = false)
|
||||
private Map<JobSource, Integer> stepLimits = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Stripe Price IDs this policy resolves to — one per currency we support. Currency is not
|
||||
* stored here; it comes from {@code stripe.prices.currency} via Sync Engine when picking the
|
||||
* right Price for a customer's subscription. All prices must share the same Billing Meter and
|
||||
* the same free-tier upper bound in units (enforced by a deploy-time CI check).
|
||||
*
|
||||
* <p>Persisted as {@code pricing_policy_stripe_price (policy_id, stripe_price_id)}.
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(
|
||||
name = "pricing_policy_stripe_price",
|
||||
joinColumns = @JoinColumn(name = "policy_id"))
|
||||
@Column(name = "stripe_price_id", nullable = false, length = 128)
|
||||
private Set<String> stripePriceIds = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Exactly one row in the table has {@code is_default = true}; enforced by partial unique idx.
|
||||
*/
|
||||
@Column(name = "is_default", nullable = false)
|
||||
private Boolean isDefault = false;
|
||||
|
||||
@Column(name = "notes", columnDefinition = "text")
|
||||
private String notes;
|
||||
|
||||
@Column(name = "created_by", length = 255)
|
||||
private String createdBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* Convenience ctor for the unit-calc-only fields used by the document classifier and tests.
|
||||
* Other fields are filled with sensible defaults; persistence callers should set the rest
|
||||
* before saving.
|
||||
*/
|
||||
public PricingPolicy(
|
||||
int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) {
|
||||
if (docPagesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docPagesPerUnit must be > 0");
|
||||
}
|
||||
if (docBytesPerUnit <= 0) {
|
||||
throw new IllegalArgumentException("docBytesPerUnit must be > 0");
|
||||
}
|
||||
if (minChargeUnits < 1) {
|
||||
throw new IllegalArgumentException("minChargeUnits must be >= 1");
|
||||
}
|
||||
if (fileUnitCap < 1) {
|
||||
throw new IllegalArgumentException("fileUnitCap must be >= 1");
|
||||
}
|
||||
this.docPagesPerUnit = docPagesPerUnit;
|
||||
this.docBytesPerUnit = docBytesPerUnit;
|
||||
this.minChargeUnits = minChargeUnits;
|
||||
this.fileUnitCap = fileUnitCap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.PricingPolicyRepository;
|
||||
|
||||
/**
|
||||
* Read-side facade over {@link PricingPolicyRepository}. The hot-path question is "what pricing
|
||||
* policy applies to this team right now?" — answered by either the team's per-team override (via
|
||||
* {@link PaygTeamExtensions#getPricingPolicyId()}) or the row with {@code is_default = TRUE}.
|
||||
*
|
||||
* <p>Reads are cached per-{@code teamId} for {@value #CACHE_TTL_SECONDS} seconds. The TTL is the
|
||||
* correctness floor: a policy change is visible on every instance within that window without any
|
||||
* coordination. Admin writes additionally fire a {@link PolicyChangedEvent} after commit so the
|
||||
* instance handling the write sees its own change immediately; other instances pick it up on the
|
||||
* next TTL expiry.
|
||||
*
|
||||
* <p><b>Writes are transactional and publish a {@link PolicyChangedEvent} after commit.</b> The
|
||||
* after-commit timing matters: publishing inside the tx would clear caches on instances that
|
||||
* haven't yet seen the row change, racing them into re-reading stale state. After-commit (via
|
||||
* {@link TransactionSynchronizationManager}) guarantees the new state is visible before any
|
||||
* listener fires.
|
||||
*
|
||||
* <p><b>Cache value is a JPA entity.</b> Callers must not mutate the returned policy — treat as
|
||||
* read-only. We accept this rather than wrapping in a DTO to keep the PR small; if mutation becomes
|
||||
* a footgun, swap the cache value type for an immutable snapshot.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PricingPolicyService {
|
||||
|
||||
static final int CACHE_TTL_SECONDS = 30;
|
||||
private static final int CACHE_MAX_SIZE = 10_000;
|
||||
|
||||
private final PricingPolicyRepository policyRepository;
|
||||
private final PaygTeamExtensionsRepository teamExtensionsRepository;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
/**
|
||||
* Cache keyed by {@code teamId}. Null teamId not supported (caller's bug). Value is the
|
||||
* effective policy — either the team's override or the default row.
|
||||
*/
|
||||
private final Cache<Long, PricingPolicy> byTeamCache;
|
||||
|
||||
public PricingPolicyService(
|
||||
PricingPolicyRepository policyRepository,
|
||||
PaygTeamExtensionsRepository teamExtensionsRepository,
|
||||
ApplicationEventPublisher eventPublisher) {
|
||||
this.policyRepository = Objects.requireNonNull(policyRepository, "policyRepository");
|
||||
this.teamExtensionsRepository =
|
||||
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
|
||||
this.eventPublisher = Objects.requireNonNull(eventPublisher, "eventPublisher");
|
||||
this.byTeamCache =
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(CACHE_MAX_SIZE)
|
||||
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
|
||||
.recordStats()
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective policy for {@code teamId}: per-team override if set, else the row with
|
||||
* {@code is_default = TRUE}. Throws {@link IllegalStateException} if no default exists — the
|
||||
* seed migration is expected to put one there.
|
||||
*
|
||||
* <p>{@link Transactional}({@code readOnly = true}) so the eager-loaded {@code stepLimits} and
|
||||
* {@code stripePriceIds} collections initialize inside the same session.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PricingPolicy getEffectivePolicy(Long teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
return byTeamCache.get(teamId, this::loadEffectivePolicy);
|
||||
}
|
||||
|
||||
/** Bypasses the cache. Useful for admin endpoints that want a fresh read after a mutation. */
|
||||
@Transactional(readOnly = true)
|
||||
public PricingPolicy getEffectivePolicyUncached(Long teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
return loadEffectivePolicy(teamId);
|
||||
}
|
||||
|
||||
/** Lists every policy (admin read). Not cached — admin pages should always see fresh state. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<PricingPolicy> listAll() {
|
||||
return policyRepository.findAll();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<PricingPolicy> findByVersion(String version) {
|
||||
return policyRepository.findByVersion(version);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<PricingPolicy> findById(Long policyId) {
|
||||
return policyRepository.findById(policyId);
|
||||
}
|
||||
|
||||
/** Creates a new policy row. Publishes {@link PolicyChangedEvent} after commit. */
|
||||
@Transactional
|
||||
public PricingPolicy create(PricingPolicy draft) {
|
||||
Objects.requireNonNull(draft, "draft");
|
||||
if (draft.getId() != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"Create draft must not carry a policy_id; use update() to modify an existing"
|
||||
+ " row.");
|
||||
}
|
||||
if (Boolean.TRUE.equals(draft.getIsDefault())) {
|
||||
// Promotion to default must go through setDefault() so the existing default is
|
||||
// atomically cleared first; otherwise the partial unique index rejects the insert.
|
||||
throw new IllegalArgumentException(
|
||||
"Create with is_default=true is not allowed; create the row then call"
|
||||
+ " setDefault(id).");
|
||||
}
|
||||
PricingPolicy saved = policyRepository.save(draft);
|
||||
publishOnCommit("create:" + saved.getId());
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes {@code newDefaultId} to be the default policy, atomically clearing the existing
|
||||
* default first. Idempotent — calling with a row already flagged default is a silent no-op (no
|
||||
* event fired; no state actually changed).
|
||||
*/
|
||||
@Transactional
|
||||
public PricingPolicy setDefault(Long newDefaultId) {
|
||||
Objects.requireNonNull(newDefaultId, "newDefaultId");
|
||||
PricingPolicy target =
|
||||
policyRepository
|
||||
.findById(newDefaultId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalArgumentException(
|
||||
"No pricing_policy with id " + newDefaultId));
|
||||
if (Boolean.TRUE.equals(target.getIsDefault())) {
|
||||
return target;
|
||||
}
|
||||
policyRepository.clearDefaultFlag();
|
||||
target.setIsDefault(true);
|
||||
PricingPolicy saved = policyRepository.save(target);
|
||||
publishOnCommit("setDefault:" + saved.getId());
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets {@code teamId}'s per-team policy override. {@code policyId = null} clears the override
|
||||
* (team falls back to default). Validates the policy exists.
|
||||
*/
|
||||
@Transactional
|
||||
public void setTeamOverride(Long teamId, Long policyId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
if (policyId != null && !policyRepository.existsById(policyId)) {
|
||||
throw new IllegalArgumentException("No pricing_policy with id " + policyId);
|
||||
}
|
||||
PaygTeamExtensions extensions =
|
||||
teamExtensionsRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No payg_team_extensions row for team "
|
||||
+ teamId
|
||||
+ " — should have been created on first"
|
||||
+ " PAYG access."));
|
||||
extensions.setPricingPolicyId(policyId);
|
||||
teamExtensionsRepository.save(extensions);
|
||||
publishOnCommit("teamOverride:" + teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates the cache. Called on every {@link PolicyChangedEvent} regardless of which row
|
||||
* changed — cache hit rate is already team-scoped so the cost of a clear is bounded by how many
|
||||
* active teams there are.
|
||||
*/
|
||||
@EventListener
|
||||
public void onPolicyChanged(PolicyChangedEvent event) {
|
||||
long evicted = byTeamCache.estimatedSize();
|
||||
byTeamCache.invalidateAll();
|
||||
log.debug(
|
||||
"PricingPolicyService cache invalidated (payload='{}', approx {} entries dropped)",
|
||||
event.getPayload(),
|
||||
evicted);
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
long cacheSize() {
|
||||
return byTeamCache.estimatedSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a {@link PolicyChangedEvent} to fire after the current transaction commits, or
|
||||
* fires immediately if no transaction is active (e.g. test paths calling write methods without
|
||||
* a tx). Inside-transaction firing would have listeners clearing caches before the row change
|
||||
* is visible to other connections — racing them into re-reading stale state.
|
||||
*/
|
||||
private void publishOnCommit(String payload) {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
eventPublisher.publishEvent(
|
||||
new PolicyChangedEvent(PricingPolicyService.this, payload));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
eventPublisher.publishEvent(new PolicyChangedEvent(this, payload));
|
||||
}
|
||||
}
|
||||
|
||||
private PricingPolicy loadEffectivePolicy(Long teamId) {
|
||||
Optional<Long> overrideId =
|
||||
teamExtensionsRepository
|
||||
.findById(teamId)
|
||||
.map(PaygTeamExtensions::getPricingPolicyId);
|
||||
if (overrideId.isPresent()) {
|
||||
Long id = overrideId.get();
|
||||
Optional<PricingPolicy> override = policyRepository.findById(id);
|
||||
if (override.isPresent()) {
|
||||
return override.get();
|
||||
}
|
||||
// Override points at a missing policy — log and fall through to default rather than
|
||||
// failing hard. The admin path that sets the override should validate up front; this
|
||||
// is a safety net for racing deletes.
|
||||
log.warn(
|
||||
"Team {} has pricing_policy_id={} set as override but that row is missing;"
|
||||
+ " falling back to default.",
|
||||
teamId,
|
||||
id);
|
||||
}
|
||||
return policyRepository
|
||||
.findFirstByIsDefaultTrue()
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new IllegalStateException(
|
||||
"No default pricing_policy row found — the V11 seed"
|
||||
+ " migration must run before"
|
||||
+ " PricingPolicyService is reachable."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package stirling.software.saas.payg.policy.admin;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
/**
|
||||
* Request/response DTOs for the pricing-policy admin endpoints. Records rather than the JPA entity
|
||||
* directly so the admin API surface is decoupled from internal columns (e.g. {@code @Version}
|
||||
* optimistic-lock fields, audit timestamps).
|
||||
*/
|
||||
final class PolicyDtos {
|
||||
|
||||
private PolicyDtos() {}
|
||||
|
||||
/** Outbound representation of a {@link PricingPolicy}. */
|
||||
record PolicyResponse(
|
||||
Long policyId,
|
||||
String version,
|
||||
LocalDateTime effectiveFrom,
|
||||
LocalDateTime effectiveTo,
|
||||
Integer docPagesPerUnit,
|
||||
Long docBytesPerUnit,
|
||||
Integer minChargeUnits,
|
||||
Integer fileUnitCap,
|
||||
Map<JobSource, Integer> stepLimits,
|
||||
Set<String> stripePriceIds,
|
||||
Boolean isDefault,
|
||||
String notes,
|
||||
String createdBy,
|
||||
LocalDateTime createdAt) {
|
||||
|
||||
static PolicyResponse from(PricingPolicy p) {
|
||||
return new PolicyResponse(
|
||||
p.getId(),
|
||||
p.getVersion(),
|
||||
p.getEffectiveFrom(),
|
||||
p.getEffectiveTo(),
|
||||
p.getDocPagesPerUnit(),
|
||||
p.getDocBytesPerUnit(),
|
||||
p.getMinChargeUnits(),
|
||||
p.getFileUnitCap(),
|
||||
// Copy the outer collections so a caller's mutation can't leak back into the
|
||||
// cached entity. Values (Integer, String) are immutable, so a shallow copy is
|
||||
// sufficient here.
|
||||
new HashMap<>(p.getStepLimits()),
|
||||
new HashSet<>(p.getStripePriceIds()),
|
||||
p.getIsDefault(),
|
||||
p.getNotes(),
|
||||
p.getCreatedBy(),
|
||||
p.getCreatedAt());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound payload for {@code POST /policies}. {@code stepLimits} and {@code stripePriceIds}
|
||||
* default to empty collections if omitted. {@code effectiveFrom} defaults to {@code now()}.
|
||||
*/
|
||||
record CreatePolicyRequest(
|
||||
String version,
|
||||
LocalDateTime effectiveFrom,
|
||||
LocalDateTime effectiveTo,
|
||||
Integer docPagesPerUnit,
|
||||
Long docBytesPerUnit,
|
||||
Integer minChargeUnits,
|
||||
Integer fileUnitCap,
|
||||
Map<JobSource, Integer> stepLimits,
|
||||
Set<String> stripePriceIds,
|
||||
String notes,
|
||||
String createdBy) {}
|
||||
|
||||
/**
|
||||
* Inbound payload for {@code PUT /teams/{teamId}/policy-override}. {@code policyId = null}
|
||||
* clears the override (team falls back to default).
|
||||
*/
|
||||
record TeamOverrideRequest(Long policyId) {}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package stirling.software.saas.payg.policy.admin;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
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.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
|
||||
|
||||
/**
|
||||
* Admin-only CRUD for {@link PricingPolicy} rows + per-team override + default-promotion. Every
|
||||
* mutation routes through {@link PricingPolicyService} so the cache invalidation event is published
|
||||
* exactly once per mutation, after commit. Reads return live data (no cache) so admins always see
|
||||
* their own write.
|
||||
*
|
||||
* <p>Path namespace {@code /api/v1/admin/payg/...} matches the design's other admin endpoints
|
||||
* (cap-setting, cohort migration). Every endpoint requires {@code ROLE_ADMIN}.
|
||||
*/
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/payg")
|
||||
@Profile("saas")
|
||||
@Tag(name = "PAYG Admin — Pricing Policy", description = "Admin CRUD for pricing policies")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PricingPolicyAdminController {
|
||||
|
||||
private final PricingPolicyService policyService;
|
||||
|
||||
@GetMapping("/policies")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "List all pricing policies (admin)")
|
||||
public ResponseEntity<List<PolicyResponse>> listPolicies() {
|
||||
return ResponseEntity.ok(
|
||||
policyService.listAll().stream().map(PolicyResponse::from).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/policies/{policyId}")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(summary = "Get a single pricing policy by id (admin)")
|
||||
public ResponseEntity<PolicyResponse> getPolicy(@PathVariable Long policyId) {
|
||||
return policyService
|
||||
.findById(policyId)
|
||||
.map(p -> ResponseEntity.ok(PolicyResponse.from(p)))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping("/policies")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Create a new pricing policy (admin)",
|
||||
description =
|
||||
"Creates a non-default policy. To promote to default, call set-default after"
|
||||
+ " creation.")
|
||||
public ResponseEntity<?> createPolicy(@RequestBody CreatePolicyRequest req) {
|
||||
try {
|
||||
PricingPolicy draft = mapCreateRequest(req);
|
||||
PricingPolicy saved = policyService.create(draft);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(PolicyResponse.from(saved));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/policies/{policyId}/set-default")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Promote a policy to default (admin)",
|
||||
description =
|
||||
"Atomically clears the existing default flag and sets this row's flag."
|
||||
+ " Teams without an override use the default.")
|
||||
public ResponseEntity<?> setDefault(@PathVariable Long policyId) {
|
||||
try {
|
||||
PricingPolicy promoted = policyService.setDefault(policyId);
|
||||
return ResponseEntity.ok(PolicyResponse.from(promoted));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PutMapping("/teams/{teamId}/policy-override")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Set or clear a team's per-team pricing-policy override (admin)",
|
||||
description =
|
||||
"Payload {policyId: <id>} sets the override; {policyId: null} clears it"
|
||||
+ " (team falls back to default).")
|
||||
public ResponseEntity<?> setTeamOverride(
|
||||
@PathVariable Long teamId, @RequestBody TeamOverrideRequest req) {
|
||||
try {
|
||||
policyService.setTeamOverride(teamId, req == null ? null : req.policyId());
|
||||
return ResponseEntity.noContent().build();
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
HttpStatus status =
|
||||
e instanceof IllegalStateException
|
||||
? HttpStatus.NOT_FOUND
|
||||
: HttpStatus.BAD_REQUEST;
|
||||
return ResponseEntity.status(status).body(error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/teams/{teamId}/effective-policy")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Operation(
|
||||
summary = "Read the effective policy for a team (admin)",
|
||||
description =
|
||||
"Returns the override if set, else the default. Bypasses the read cache so"
|
||||
+ " admins always see the latest state.")
|
||||
public ResponseEntity<PolicyResponse> getEffectivePolicy(@PathVariable Long teamId) {
|
||||
return ResponseEntity.ok(
|
||||
PolicyResponse.from(policyService.getEffectivePolicyUncached(teamId)));
|
||||
}
|
||||
|
||||
private static PricingPolicy mapCreateRequest(CreatePolicyRequest req) {
|
||||
if (req == null) {
|
||||
throw new IllegalArgumentException("Request body required.");
|
||||
}
|
||||
if (req.version() == null || req.version().isBlank()) {
|
||||
throw new IllegalArgumentException("version is required.");
|
||||
}
|
||||
if (req.docPagesPerUnit() == null || req.docBytesPerUnit() == null) {
|
||||
throw new IllegalArgumentException("docPagesPerUnit and docBytesPerUnit are required.");
|
||||
}
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setVersion(req.version());
|
||||
p.setEffectiveFrom(req.effectiveFrom() != null ? req.effectiveFrom() : LocalDateTime.now());
|
||||
p.setEffectiveTo(req.effectiveTo());
|
||||
p.setDocPagesPerUnit(req.docPagesPerUnit());
|
||||
p.setDocBytesPerUnit(req.docBytesPerUnit());
|
||||
p.setMinChargeUnits(req.minChargeUnits() != null ? req.minChargeUnits() : 1);
|
||||
p.setFileUnitCap(req.fileUnitCap() != null ? req.fileUnitCap() : 1000);
|
||||
p.setStepLimits(
|
||||
req.stepLimits() != null ? new HashMap<>(req.stepLimits()) : new HashMap<>());
|
||||
p.setStripePriceIds(
|
||||
req.stripePriceIds() != null
|
||||
? new HashSet<>(req.stripePriceIds())
|
||||
: new HashSet<>());
|
||||
p.setIsDefault(false);
|
||||
p.setNotes(req.notes());
|
||||
p.setCreatedBy(req.createdBy());
|
||||
return p;
|
||||
}
|
||||
|
||||
private static java.util.Map<String, String> error(String message) {
|
||||
return java.util.Map.of("error", message == null ? "unknown" : message);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.job.JobArtifactHash;
|
||||
import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
|
||||
@Repository
|
||||
public interface JobArtifactHashRepository
|
||||
extends JpaRepository<JobArtifactHash, JobArtifactHashId> {
|
||||
|
||||
/**
|
||||
* Lineage lookup: find the open job (if any) whose recorded input/output hashes include the
|
||||
* supplied content hash, scoped to one user and the workflow window.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT j.ownerUserId, h.id.jobId FROM JobArtifactHash h"
|
||||
+ " JOIN ProcessingJob j ON j.id = h.id.jobId"
|
||||
+ " WHERE j.ownerUserId = :userId"
|
||||
+ " AND j.status = :openStatus"
|
||||
+ " AND j.lastStepAt > :since"
|
||||
+ " AND h.id.contentHash = :contentHash")
|
||||
List<Object[]> findLineageMatches(
|
||||
@Param("userId") Long userId,
|
||||
@Param("openStatus") JobStatus openStatus,
|
||||
@Param("since") LocalDateTime since,
|
||||
@Param("contentHash") String contentHash);
|
||||
|
||||
/** Prunes rows older than {@code cutoff}; run from a scheduled task. */
|
||||
@Modifying
|
||||
@Query("DELETE FROM JobArtifactHash h WHERE h.createdAt < :cutoff")
|
||||
int deleteOlderThan(@Param("cutoff") LocalDateTime cutoff);
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.shadow.PaygShadowCharge;
|
||||
|
||||
@Repository
|
||||
public interface PaygShadowChargeRepository extends JpaRepository<PaygShadowCharge, Long> {
|
||||
|
||||
@Query(
|
||||
"SELECT s FROM PaygShadowCharge s"
|
||||
+ " WHERE s.occurredAt >= :from AND s.occurredAt < :to"
|
||||
+ " ORDER BY s.occurredAt DESC")
|
||||
List<PaygShadowCharge> findInWindow(
|
||||
@Param("from") LocalDateTime from, @Param("to") LocalDateTime to);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
|
||||
@Repository
|
||||
public interface PaygTeamExtensionsRepository extends JpaRepository<PaygTeamExtensions, Long> {
|
||||
|
||||
Optional<PaygTeamExtensions> findByStripeCustomerId(String stripeCustomerId);
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
@Repository
|
||||
public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Long> {
|
||||
|
||||
Optional<PricingPolicy> findByVersion(String version);
|
||||
|
||||
Optional<PricingPolicy> findFirstByIsDefaultTrue();
|
||||
|
||||
/**
|
||||
* Atomically clears the {@code is_default} flag on whichever row currently carries it. Used by
|
||||
* {@code setDefault(newId)} to free the slot before flipping the new row's flag — the {@code
|
||||
* uq_pricing_policy_default} partial unique index would otherwise reject the second row.
|
||||
*
|
||||
* <p>Returns the count of rows updated (0 if no default existed yet, 1 normally).
|
||||
*/
|
||||
@Modifying
|
||||
@Query("UPDATE PricingPolicy p SET p.isDefault = false WHERE p.isDefault = true")
|
||||
int clearDefaultFlag();
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.job.ProcessingJob;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
|
||||
@Repository
|
||||
public interface ProcessingJobRepository extends JpaRepository<ProcessingJob, UUID> {
|
||||
|
||||
List<ProcessingJob> findByOwnerUserIdAndStatus(Long ownerUserId, JobStatus status);
|
||||
|
||||
/**
|
||||
* Jobs left {@code OPEN} past the workflow window; the stale-close scheduler picks these up.
|
||||
*/
|
||||
@Query("SELECT j FROM ProcessingJob j WHERE j.status = :status AND j.lastStepAt < :cutoff")
|
||||
List<ProcessingJob> findStale(
|
||||
@Param("status") JobStatus status, @Param("cutoff") LocalDateTime cutoff);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.job.ProcessingJobStep;
|
||||
|
||||
@Repository
|
||||
public interface ProcessingJobStepRepository extends JpaRepository<ProcessingJobStep, Long> {
|
||||
|
||||
List<ProcessingJobStep> findByJobIdOrderByStartedAtAsc(UUID jobId);
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot;
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId;
|
||||
|
||||
@Repository
|
||||
public interface WalletEntitlementSnapshotRepository
|
||||
extends JpaRepository<WalletEntitlementSnapshot, WalletEntitlementSnapshotId> {
|
||||
|
||||
/** Team-wide snapshot lookup. */
|
||||
default Optional<WalletEntitlementSnapshot> findTeamWide(Long teamId) {
|
||||
return findById(
|
||||
new WalletEntitlementSnapshotId(
|
||||
teamId, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID));
|
||||
}
|
||||
|
||||
/** Per-member snapshot lookup. */
|
||||
default Optional<WalletEntitlementSnapshot> findForMember(Long teamId, Long userId) {
|
||||
return findById(new WalletEntitlementSnapshotId(teamId, userId));
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
|
||||
@Repository
|
||||
public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry, Long> {
|
||||
|
||||
List<WalletLedgerEntry> findByTeamIdOrderByOccurredAtDesc(Long teamId);
|
||||
|
||||
/** Sum of signed amounts over a team's entries — the wallet's current balance in units. */
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e WHERE e.teamId = :teamId")
|
||||
long sumBalanceForTeam(@Param("teamId") Long teamId);
|
||||
|
||||
/** Period-bounded spend for one team in units (debits only). */
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
|
||||
+ " WHERE e.teamId = :teamId"
|
||||
+ " AND e.entryType = :entryType"
|
||||
+ " AND e.occurredAt >= :periodStart"
|
||||
+ " AND e.occurredAt < :periodEnd")
|
||||
long sumPeriodAmount(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("entryType") LedgerEntryType entryType,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("periodEnd") LocalDateTime periodEnd);
|
||||
|
||||
/** Per-member period spend (only when the member has a sub-cap configured). */
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
|
||||
+ " WHERE e.teamId = :teamId AND e.actorUserId = :actorUserId"
|
||||
+ " AND e.entryType = :entryType"
|
||||
+ " AND e.occurredAt >= :periodStart"
|
||||
+ " AND e.occurredAt < :periodEnd")
|
||||
long sumPeriodAmountForMember(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("actorUserId") Long actorUserId,
|
||||
@Param("entryType") LedgerEntryType entryType,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("periodEnd") LocalDateTime periodEnd);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
|
||||
@Repository
|
||||
public interface WalletPolicyRepository extends JpaRepository<WalletPolicy, Long> {
|
||||
|
||||
Optional<WalletPolicy> findByTeamId(Long teamId);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package stirling.software.saas.payg.shadow;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Per-job comparison row written while a team is in {@code PAYG_SHADOW} mode: what the legacy
|
||||
* engine actually charged vs. what the PAYG engine would have charged. Aggregated daily by the
|
||||
* shadow-reconciliation report; deletable after promotion.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payg_shadow_charge")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PaygShadowCharge implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "shadow_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "job_id", nullable = false)
|
||||
private UUID jobId;
|
||||
|
||||
@Column(name = "policy_id", nullable = false)
|
||||
private Long policyId;
|
||||
|
||||
@Column(name = "payg_units", nullable = false)
|
||||
private Integer paygUnits;
|
||||
|
||||
@Column(name = "legacy_credits_charged", nullable = false)
|
||||
private Integer legacyCreditsCharged;
|
||||
|
||||
/** Signed percent difference: {@code 100 * (payg - legacy) / max(1, legacy)}. */
|
||||
@Column(name = "diff_pct", nullable = false)
|
||||
private Integer diffPct;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "occurred_at", nullable = false, updatable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package stirling.software.saas.payg.wallet;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.LedgerBucket;
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.model.ReferenceType;
|
||||
|
||||
/**
|
||||
* Append-only ledger keyed on {@code team_id}. {@code amount_units} is signed (positive = credit,
|
||||
* negative = debit). Two unique indexes (reference triple, stripe event id) prevent double-posting.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "wallet_ledger")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class WalletLedgerEntry implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "entry_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
/** Which team member triggered this entry; null for system grants. */
|
||||
@Column(name = "actor_user_id")
|
||||
private Long actorUserId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "entry_type", nullable = false, length = 32)
|
||||
private LedgerEntryType entryType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "bucket", nullable = false, length = 16)
|
||||
private LedgerBucket bucket;
|
||||
|
||||
/** Signed: positive = credit, negative = debit. The only quantity the app tracks. */
|
||||
@Column(name = "amount_units", nullable = false)
|
||||
private Integer amountUnits;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reference_type", nullable = false, length = 32)
|
||||
private ReferenceType referenceType;
|
||||
|
||||
@Column(name = "reference_id", nullable = false, length = 128)
|
||||
private String referenceId;
|
||||
|
||||
@Column(name = "policy_id")
|
||||
private Long policyId;
|
||||
|
||||
@Column(name = "stripe_event_id", length = 128)
|
||||
private String stripeEventId;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "occurred_at", nullable = false, updatable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "metadata", columnDefinition = "jsonb")
|
||||
private Map<String, Object> metadata = new HashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package stirling.software.saas.payg.wallet;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.hibernate.annotations.JdbcTypeCode;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.hibernate.type.SqlTypes;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.AutoGroupStrategy;
|
||||
import stirling.software.saas.payg.model.CapPeriod;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
import stirling.software.saas.payg.model.WalletEngine;
|
||||
|
||||
/**
|
||||
* Per-team wallet configuration: charging engine, period spend cap, warn/degrade thresholds, the
|
||||
* degraded feature set, and the lineage-detection strategy.
|
||||
*
|
||||
* <p>No {@code @Version} — admin-only writes, no concurrent writers on a single row.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "wallet_policy")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class WalletPolicy implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "policy_id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "team_id", nullable = false, unique = true)
|
||||
private Long teamId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "engine", nullable = false, length = 16)
|
||||
private WalletEngine engine = WalletEngine.LEGACY;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "cap_period", nullable = false, length = 16)
|
||||
private CapPeriod capPeriod = CapPeriod.CALENDAR_MONTH;
|
||||
|
||||
/** Null = unlimited. Doc-units per period. */
|
||||
@Column(name = "cap_units")
|
||||
private Long capUnits;
|
||||
|
||||
/**
|
||||
* Original money cap input ("$50/month") in smallest currency unit; null if set as units. The
|
||||
* currency comes from {@code stripe.customers.currency} at recompute time — we don't duplicate
|
||||
* it here.
|
||||
*/
|
||||
@Column(name = "cap_source_money")
|
||||
private Long capSourceMoney;
|
||||
|
||||
@Column(name = "warn_at_pct", nullable = false)
|
||||
private Integer warnAtPct = 80;
|
||||
|
||||
@Column(name = "degrade_at_pct", nullable = false)
|
||||
private Integer degradeAtPct = 100;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "degraded_feature_set", nullable = false, length = 32)
|
||||
private FeatureSet degradedFeatureSet = FeatureSet.MINIMAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "auto_group_strategy", nullable = false, length = 16)
|
||||
private AutoGroupStrategy autoGroupStrategy = AutoGroupStrategy.AUTO;
|
||||
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
@Column(name = "notification_emails", columnDefinition = "jsonb", nullable = false)
|
||||
private List<String> notificationEmails = new ArrayList<>();
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
@@ -55,6 +57,7 @@ public class CreditService {
|
||||
private final Counter creditsConsumedCounter;
|
||||
private final Counter creditConsumptionFailuresCounter;
|
||||
private final Counter cycleResetCounter;
|
||||
private final Counter stripeReportFailuresCounter;
|
||||
|
||||
public CreditService(
|
||||
UserCreditRepository userCreditRepository,
|
||||
@@ -90,6 +93,10 @@ public class CreditService {
|
||||
Counter.builder("credits.cycle_reset")
|
||||
.description("Number of credit cycle resets performed")
|
||||
.register(meterRegistry);
|
||||
this.stripeReportFailuresCounter =
|
||||
Counter.builder("credits.stripe_report.failures")
|
||||
.description("Stripe meter post failed after the DB debit committed")
|
||||
.register(meterRegistry);
|
||||
|
||||
// Active gauges for current credit levels
|
||||
Gauge.builder("credits.total_available", this, CreditService::getTotalAvailableCredits)
|
||||
@@ -296,7 +303,8 @@ public class CreditService {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Partial or full overage: consume free credits and report overage to Stripe
|
||||
// Partial or full overage: consume free credits in this tx, report the overage
|
||||
// to Stripe after commit (see scheduleStripeReportAfterCommit).
|
||||
int freeCreditsUsed =
|
||||
userCredits.getCycleCreditsRemaining() != null
|
||||
? userCredits.getCycleCreditsRemaining()
|
||||
@@ -328,55 +336,27 @@ public class CreditService {
|
||||
}
|
||||
}
|
||||
|
||||
// Stable idempotency key per (user, amount, operation) so retries dedupe.
|
||||
String operationId = MDC.get("requestId");
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
operationId = UUID.randomUUID().toString();
|
||||
}
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId, overageCredits, operationId);
|
||||
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Calling Stripe reporting service - User: {}, Overage credits: {}, Idempotency key: {}",
|
||||
scheduleStripeReportAfterCommit(
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
|
||||
boolean reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId, overageCredits, idempotencyKey);
|
||||
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Stripe reporting result: {} for user: {}",
|
||||
reported ? "SUCCESS" : "FAILED",
|
||||
supabaseId);
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
log.info(
|
||||
"[USAGE-BASED] User {} consumed {} free + {} overage credits (total: {})",
|
||||
supabaseId,
|
||||
freeCreditsUsed,
|
||||
overageCredits,
|
||||
creditAmount);
|
||||
return true;
|
||||
} else {
|
||||
log.error(
|
||||
"[USAGE-BASED] Failed to report {} overage credits to Stripe for user: {}",
|
||||
overageCredits,
|
||||
supabaseId);
|
||||
log.error(
|
||||
"[USAGE-BASED] Throwing exception to fail the operation; metering must succeed");
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering. Please try again or contact support if the issue persists.");
|
||||
}
|
||||
idempotencyKey,
|
||||
creditAmount,
|
||||
freeCreditsUsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Free credits were sufficient; already consumed and returned above
|
||||
// If we reach here, there's a logic error
|
||||
log.error("[USAGE-BASED] Unexpected code path reached for user: {}", supabaseId);
|
||||
// Lost a concurrent-debit race: the in-memory balance check passed but the atomic
|
||||
// UPDATE found insufficient credits. Surface the failure so the caller can retry.
|
||||
log.warn(
|
||||
"[USAGE-BASED] Concurrent-debit race lost the free-tier consumption for"
|
||||
+ " user {}; caller should retry.",
|
||||
supabaseId);
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -411,17 +391,6 @@ public class CreditService {
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return false;
|
||||
} catch (RuntimeException e) {
|
||||
// Metering failures are critical and should fail the operation.
|
||||
// This ensures users aren't charged for operations that weren't metered.
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Unable to report usage to Stripe")) {
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Metering failure; rethrowing exception to fail operation");
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Other runtime exceptions are logged but don't fail the operation.
|
||||
// This prevents transient errors from blocking user operations.
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Unexpected runtime error consuming credits for user: {} - {}",
|
||||
supabaseId,
|
||||
@@ -451,6 +420,87 @@ public class CreditService {
|
||||
return saasUserExtensionService.isMeteredBillingEnabled(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the Stripe meter event for an overage debit in a {@code TransactionSynchronization}
|
||||
* afterCommit hook, so the DB row lock is released before the HTTP call to Stripe.
|
||||
*
|
||||
* <p>If no transaction is active (e.g. a test calling consume directly) the report runs
|
||||
* synchronously instead, so the meter event still fires.
|
||||
*/
|
||||
private void scheduleStripeReportAfterCommit(
|
||||
String supabaseId,
|
||||
int overageCredits,
|
||||
String idempotencyKey,
|
||||
int creditAmount,
|
||||
int freeCreditsUsed) {
|
||||
|
||||
Runnable reportToStripe =
|
||||
() -> {
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Posting Stripe meter event - User: {}, Overage: {},"
|
||||
+ " Idempotency: {}",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
|
||||
boolean reported;
|
||||
try {
|
||||
reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId, overageCredits, idempotencyKey);
|
||||
} catch (RuntimeException e) {
|
||||
// Don't let a Stripe exception unwind the afterCommit chain — the DB
|
||||
// debit has already committed.
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Stripe meter post threw for user {} (overage {});"
|
||||
+ " usage owed-but-unbilled until a retry succeeds",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
e);
|
||||
stripeReportFailuresCounter.increment();
|
||||
return;
|
||||
}
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
log.info(
|
||||
"[USAGE-BASED] User {} consumed {} free + {} overage credits"
|
||||
+ " (total: {}); Stripe meter posted.",
|
||||
supabaseId,
|
||||
freeCreditsUsed,
|
||||
overageCredits,
|
||||
creditAmount);
|
||||
} else {
|
||||
// DB has the debit, Stripe doesn't. The idempotency key is stable, so a
|
||||
// replay with the same key recovers the meter event without
|
||||
// double-charging.
|
||||
stripeReportFailuresCounter.increment();
|
||||
log.error(
|
||||
"[USAGE-BASED] Failed to post Stripe meter event for user {}"
|
||||
+ " (overage {}); usage owed-but-unbilled. Idempotency key"
|
||||
+ " is stable: replay with key '{}' to recover.",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
}
|
||||
};
|
||||
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
reportToStripe.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log.warn(
|
||||
"[CREDIT-CONSUME] No active transaction; reporting Stripe usage synchronously."
|
||||
+ " Expected only in tests.");
|
||||
reportToStripe.run();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a user has credits available by Supabase ID (unified approach). */
|
||||
public boolean hasCreditsAvailableBySupabaseId(String supabaseId) {
|
||||
Optional<UserCredit> credits = getUserCreditsBySupabaseId(supabaseId);
|
||||
@@ -1054,48 +1104,23 @@ public class CreditService {
|
||||
// STEP 4: Try metered billing (check flag, not role)
|
||||
if (saasUserExtensionService.isMeteredBillingEnabled(user)) {
|
||||
log.info(
|
||||
"[WATERFALL] User {} has metered billing enabled; reporting {} credits to Stripe",
|
||||
"[WATERFALL] User {} has metered billing enabled; scheduling {} credits for"
|
||||
+ " Stripe report (after commit)",
|
||||
user.getUsername(),
|
||||
creditAmount);
|
||||
|
||||
try {
|
||||
String operationId = MDC.get("requestId");
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
operationId = UUID.randomUUID().toString();
|
||||
}
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId.toString(), creditAmount, operationId);
|
||||
String operationId = MDC.get("requestId");
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId.toString(), creditAmount, operationId);
|
||||
|
||||
boolean reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId.toString(), creditAmount, idempotencyKey);
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
|
||||
log.info(
|
||||
"[WATERFALL] Reported {} overage credits to Stripe for user: {}",
|
||||
creditAmount,
|
||||
user.getUsername());
|
||||
return CreditConsumptionResult.success("METERED_SUBSCRIPTION");
|
||||
} else {
|
||||
log.error(
|
||||
"[WATERFALL] Failed to report usage to Stripe for user: {}",
|
||||
user.getUsername());
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return CreditConsumptionResult.failure("Failed to report usage to Stripe");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"[WATERFALL] Exception while reporting to Stripe for user {}: {}",
|
||||
user.getUsername(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return CreditConsumptionResult.failure(
|
||||
"Error reporting usage to Stripe: " + e.getMessage());
|
||||
}
|
||||
scheduleStripeReportAfterCommit(
|
||||
supabaseId.toString(),
|
||||
creditAmount,
|
||||
idempotencyKey,
|
||||
creditAmount,
|
||||
/* freeCreditsUsed= */ 0);
|
||||
return CreditConsumptionResult.success("METERED_SUBSCRIPTION");
|
||||
} else if (user.getRolesAsString().contains("ROLE_PRO_USER")) {
|
||||
// Pro user without metered billing enabled; reject with helpful message
|
||||
log.warn(
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
-- PAYG data model: pricing policy, processing jobs + lineage, wallet ledger, wallet policy,
|
||||
-- entitlement snapshots, shadow-mode comparison rows, plus a payg_team_extensions sidecar table
|
||||
-- carrying team-level PAYG fields, and a cap_units column on team_memberships.
|
||||
--
|
||||
-- Sidecar pattern (mirrors saas_team_extensions): PAYG-only team fields don't sit directly on
|
||||
-- `teams`, so OSS deployments running Hibernate ddl-auto=update against the proprietary Team
|
||||
-- entity never see PAYG columns they don't have entities for.
|
||||
--
|
||||
-- Everything is purely additive. No existing rows are modified, no columns are dropped.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. pricing_policy — versioned economic config (units, lifecycle metadata).
|
||||
-- step_limits and stripe_price_ids live on normalised child tables below — typed columns, no
|
||||
-- JSON parsing, queryable directly.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy (
|
||||
policy_id BIGSERIAL PRIMARY KEY,
|
||||
version VARCHAR(32) NOT NULL UNIQUE,
|
||||
effective_from TIMESTAMP NOT NULL,
|
||||
effective_to TIMESTAMP,
|
||||
doc_pages_per_unit INTEGER NOT NULL,
|
||||
doc_bytes_per_unit BIGINT NOT NULL,
|
||||
min_charge_units INTEGER NOT NULL DEFAULT 1,
|
||||
file_unit_cap INTEGER NOT NULL DEFAULT 1000,
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notes TEXT,
|
||||
created_by VARCHAR(255),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_pricing_policy_default
|
||||
ON pricing_policy (is_default) WHERE is_default = TRUE;
|
||||
|
||||
-- Max steps allowed per process for each caller surface (JobSource).
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy_step_limit (
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE,
|
||||
job_source VARCHAR(32) NOT NULL,
|
||||
step_limit INTEGER NOT NULL,
|
||||
PRIMARY KEY (policy_id, job_source)
|
||||
);
|
||||
|
||||
-- Stripe Price IDs this policy resolves to, one per supported currency. Currency itself isn't
|
||||
-- stored here — it lives on stripe.prices.currency and is looked up via Sync Engine when picking
|
||||
-- the right Price for a customer's subscription. All prices in one policy must share the same
|
||||
-- Billing Meter and the same first-tier upper bound in units (deploy-time CI check).
|
||||
CREATE TABLE IF NOT EXISTS pricing_policy_stripe_price (
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id) ON DELETE CASCADE,
|
||||
stripe_price_id VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (policy_id, stripe_price_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. payg_team_extensions — sidecar carrying PAYG-only team fields. 1:1 with teams via shared PK.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payg_team_extensions (
|
||||
team_id BIGINT PRIMARY KEY REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
pricing_policy_id BIGINT REFERENCES pricing_policy(policy_id),
|
||||
stripe_customer_id VARCHAR(128) UNIQUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
version BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN payg_team_extensions.pricing_policy_id IS
|
||||
'Override policy for this team. NULL means use the row in pricing_policy with is_default=TRUE.';
|
||||
COMMENT ON COLUMN payg_team_extensions.stripe_customer_id IS
|
||||
'Stripe customer id for this team. Eager-created so every team has billing identity on file.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. team_memberships column addition: optional per-member sub-cap. Lives directly on the table
|
||||
-- because team_memberships is already a SaaS-only table.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE team_memberships
|
||||
ADD COLUMN IF NOT EXISTS cap_units BIGINT;
|
||||
COMMENT ON COLUMN team_memberships.cap_units IS
|
||||
'Per-period spend cap for this member inside their team wallet, in doc units. NULL = no member-level cap.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. processing_job — one billable process; step_count and last_step_at track the workflow window.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS processing_job (
|
||||
job_id UUID PRIMARY KEY,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
owner_team_id BIGINT,
|
||||
process_type VARCHAR(32) NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
document_fingerprint VARCHAR(64),
|
||||
doc_units INTEGER NOT NULL DEFAULT 0,
|
||||
step_count INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_step_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_at TIMESTAMP,
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id),
|
||||
charged_units INTEGER,
|
||||
charged_cents INTEGER,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
idempotency_key VARCHAR(128) UNIQUE,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_owner_open
|
||||
ON processing_job (owner_user_id, status) WHERE status = 'OPEN';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_last_step
|
||||
ON processing_job (status, last_step_at) WHERE status = 'OPEN';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. processing_job_step — per-tool-call audit within a job.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS processing_job_step (
|
||||
step_id BIGSERIAL PRIMARY KEY,
|
||||
job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE,
|
||||
tool_id VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at TIMESTAMP,
|
||||
input_pages INTEGER,
|
||||
input_bytes BIGINT,
|
||||
error_code VARCHAR(64)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_job_step_job
|
||||
ON processing_job_step (job_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 6. job_artifact_hash — per-step input/output content hashes used by the lineage detector.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- content_hash holds "type:value" signature keys; VARCHAR(128) fits SHA-256 and future schemes.
|
||||
CREATE TABLE IF NOT EXISTS job_artifact_hash (
|
||||
job_id UUID NOT NULL REFERENCES processing_job(job_id) ON DELETE CASCADE,
|
||||
content_hash VARCHAR(128) NOT NULL,
|
||||
kind VARCHAR(8) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (job_id, content_hash, kind)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artifact_hash_lookup
|
||||
ON job_artifact_hash (content_hash, created_at);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 7. wallet_ledger — append-only signed-amount ledger keyed on team_id.
|
||||
-- amount_units is INTEGER (per-row delta, always small); cap and rollup columns are BIGINT
|
||||
-- because they accumulate across a billing period.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_ledger (
|
||||
entry_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
actor_user_id BIGINT,
|
||||
entry_type VARCHAR(32) NOT NULL,
|
||||
bucket VARCHAR(16) NOT NULL,
|
||||
amount_units INTEGER NOT NULL,
|
||||
reference_type VARCHAR(32) NOT NULL,
|
||||
reference_id VARCHAR(128) NOT NULL,
|
||||
policy_id BIGINT,
|
||||
stripe_event_id VARCHAR(128),
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata JSONB
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team
|
||||
ON wallet_ledger (team_id, occurred_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_actor
|
||||
ON wallet_ledger (team_id, actor_user_id, occurred_at) WHERE actor_user_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_ref
|
||||
ON wallet_ledger (reference_type, reference_id, entry_type, bucket);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_ledger_stripe_event
|
||||
ON wallet_ledger (stripe_event_id) WHERE stripe_event_id IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 8. wallet_policy — per-team charging engine, cap, degradation rules, lineage strategy.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_policy (
|
||||
policy_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL UNIQUE REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
engine VARCHAR(16) NOT NULL DEFAULT 'LEGACY',
|
||||
cap_period VARCHAR(16) NOT NULL DEFAULT 'CALENDAR_MONTH',
|
||||
cap_units BIGINT,
|
||||
-- Customer's money intent ("I want $50/month"); the currency comes from the team's Stripe
|
||||
-- customer at recompute time, not stored separately here.
|
||||
cap_source_money BIGINT,
|
||||
warn_at_pct INTEGER NOT NULL DEFAULT 80,
|
||||
degrade_at_pct INTEGER NOT NULL DEFAULT 100,
|
||||
degraded_feature_set VARCHAR(32) NOT NULL DEFAULT 'MINIMAL',
|
||||
auto_group_strategy VARCHAR(16) NOT NULL DEFAULT 'AUTO',
|
||||
notification_emails JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 9. wallet_entitlement_snapshot — hot-path state for the entitlement guard.
|
||||
-- user_id = 0 is the team-wide sentinel (Postgres treats NULL as not-equal-to-NULL in unique
|
||||
-- constraints, so 0 is the cleaner choice for a composite PK).
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wallet_entitlement_snapshot (
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
user_id BIGINT NOT NULL DEFAULT 0,
|
||||
period_start TIMESTAMP NOT NULL,
|
||||
period_end TIMESTAMP NOT NULL,
|
||||
period_spend_units BIGINT NOT NULL DEFAULT 0,
|
||||
period_cap_units BIGINT,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'FULL',
|
||||
feature_set VARCHAR(32) NOT NULL DEFAULT 'FULL',
|
||||
enabled_gates JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
computed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (team_id, user_id)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 10. payg_shadow_charge — per-job legacy-vs-PAYG diff during PAYG_SHADOW engine mode.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS payg_shadow_charge (
|
||||
shadow_id BIGSERIAL PRIMARY KEY,
|
||||
team_id BIGINT NOT NULL REFERENCES teams(team_id) ON DELETE CASCADE,
|
||||
job_id UUID NOT NULL,
|
||||
policy_id BIGINT NOT NULL REFERENCES pricing_policy(policy_id),
|
||||
payg_units INTEGER NOT NULL,
|
||||
legacy_credits_charged INTEGER NOT NULL,
|
||||
diff_pct INTEGER NOT NULL,
|
||||
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_payg_shadow_team_time
|
||||
ON payg_shadow_charge (team_id, occurred_at);
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Seed the V1 default pricing policy. Idempotent — only inserts when no default row exists.
|
||||
-- Units sized so a typical 25-page / 5 MiB document is 1 unit; tune via admin endpoints once
|
||||
-- Stripe Prices are wired in production.
|
||||
--
|
||||
-- This migration is separated from V11 because V11 has already shipped to main — adding rows to
|
||||
-- it would change its Flyway checksum and break existing deployments.
|
||||
|
||||
INSERT INTO pricing_policy (
|
||||
version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
|
||||
min_charge_units, file_unit_cap, is_default, notes, created_by
|
||||
)
|
||||
SELECT
|
||||
'v1-initial', CURRENT_TIMESTAMP, 25, 5242880,
|
||||
1, 1000, TRUE,
|
||||
'V1 default seeded by V12 migration. Tune via admin once Stripe Prices are configured.',
|
||||
'system'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM pricing_policy WHERE is_default = TRUE
|
||||
);
|
||||
|
||||
-- Step limits for the default policy across every JobSource. References the row inserted above
|
||||
-- via the partial unique index on is_default=TRUE.
|
||||
INSERT INTO pricing_policy_step_limit (policy_id, job_source, step_limit)
|
||||
SELECT p.policy_id, src.job_source, src.step_limit
|
||||
FROM pricing_policy p
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('WEB', 10),
|
||||
('API', 10),
|
||||
('PIPELINE', 20), -- automations get a longer chain
|
||||
('DESKTOP_APP', 10)
|
||||
) AS src(job_source, step_limit)
|
||||
WHERE p.is_default = TRUE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM pricing_policy_step_limit s
|
||||
WHERE s.policy_id = p.policy_id AND s.job_source = src.job_source
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
package stirling.software.saas.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.persistence.autoconfigure.EntityScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
|
||||
/**
|
||||
* Guards {@link SaasJpaConfig}'s scan paths from drifting out of sync with the actual entity and
|
||||
* repository packages — without this, a missing package goes undetected until a runtime "No
|
||||
* qualifying bean of type" startup failure that Mockito-based tests can't catch.
|
||||
*
|
||||
* <p>Reflection-based rather than a real Spring boot because the production schema uses
|
||||
* Postgres-specific features H2 doesn't fully support.
|
||||
*/
|
||||
class SaasJpaConfigScanTest {
|
||||
|
||||
private static final List<String> EXPECTED_REPO_PACKAGES =
|
||||
List.of(
|
||||
"stirling.software.saas.repository",
|
||||
"stirling.software.saas.billing.repository",
|
||||
"stirling.software.saas.ai.repository",
|
||||
"stirling.software.saas.payg.repository");
|
||||
|
||||
private static final List<String> EXPECTED_ENTITY_PACKAGES =
|
||||
List.of(
|
||||
"stirling.software.saas.model",
|
||||
"stirling.software.saas.billing.model",
|
||||
"stirling.software.saas.ai.model",
|
||||
// Recursive — covers all payg.* sub-packages.
|
||||
"stirling.software.saas.payg");
|
||||
|
||||
@Test
|
||||
void enableJpaRepositoriesIncludesAllExpectedPackages() {
|
||||
EnableJpaRepositories annotation =
|
||||
SaasJpaConfig.class.getAnnotation(EnableJpaRepositories.class);
|
||||
assertThat(annotation).as("SaasJpaConfig must carry @EnableJpaRepositories").isNotNull();
|
||||
|
||||
Set<String> actual = Set.copyOf(Arrays.asList(annotation.basePackages()));
|
||||
assertThat(actual)
|
||||
.as("Every package holding @Repository interfaces must be listed")
|
||||
.containsAll(EXPECTED_REPO_PACKAGES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void entityScanIncludesAllExpectedPackages() {
|
||||
EntityScan annotation = SaasJpaConfig.class.getAnnotation(EntityScan.class);
|
||||
assertThat(annotation).as("SaasJpaConfig must carry @EntityScan").isNotNull();
|
||||
|
||||
Set<String> actual = Set.copyOf(Arrays.asList(annotation.value()));
|
||||
assertThat(actual)
|
||||
.as("Every package holding @Entity classes must be listed")
|
||||
.containsAll(EXPECTED_ENTITY_PACKAGES);
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package stirling.software.saas.payg.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
|
||||
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
|
||||
class DefaultDocumentClassifierTest {
|
||||
|
||||
/** Same shape as the V1 default we'd seed in pricing_policy. */
|
||||
private static final PricingPolicy DEFAULT_POLICY =
|
||||
new PricingPolicy(
|
||||
/* docPagesPerUnit= */ 25,
|
||||
/* docBytesPerUnit= */ 10L * 1024 * 1024,
|
||||
/* minChargeUnits= */ 1,
|
||||
/* fileUnitCap= */ 1000);
|
||||
|
||||
private final DefaultDocumentClassifier classifier =
|
||||
new DefaultDocumentClassifier(buildTempFileManager());
|
||||
|
||||
@Test
|
||||
void singlePagePdf_isOneUnit() throws Exception {
|
||||
MultipartFile pdf = pdf("one.pdf", 1);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isEqualTo(1);
|
||||
assertThat(metrics.docUnits()).isEqualTo(1);
|
||||
assertThat(metrics.contentType()).isEqualTo("application/pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiPagePdf_chargesByPageAxisWhenBytesAreTiny() throws Exception {
|
||||
// 100 pages, well under 10 MiB → page axis dominates. ceil(100 / 25) = 4 units.
|
||||
MultipartFile pdf = pdf("hundred.pdf", 100);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isEqualTo(100);
|
||||
assertThat(metrics.docUnits()).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bytesAxisDominatesWhenFileIsLargeButFewPages() {
|
||||
// Use a KiB-scale unit so the test allocation stays small.
|
||||
PricingPolicy bytesy = new PricingPolicy(25, 10L * 1024, 1, 1000); // 10 KiB per unit
|
||||
// 30 KiB / 10 KiB = 3 units.
|
||||
byte[] payload = new byte[30 * 1024];
|
||||
MultipartFile blob = new MockMultipartFile("file", "scan.tiff", "image/tiff", payload);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(blob, bytesy);
|
||||
|
||||
assertThat(metrics.pages()).isZero();
|
||||
assertThat(metrics.docUnits()).isEqualTo(3);
|
||||
assertThat(metrics.contentType()).isEqualTo("image/tiff");
|
||||
}
|
||||
|
||||
@Test
|
||||
void singleFileFileUnitCap_clampsExtremelyLargeInputs() {
|
||||
PricingPolicy tightCap = new PricingPolicy(25, 10L * 1024, 1, /* fileUnitCap= */ 10);
|
||||
// 200 KiB → 20 raw units; per-file cap pins to 10.
|
||||
byte[] payload = new byte[200 * 1024];
|
||||
MultipartFile blob =
|
||||
new MockMultipartFile("file", "huge.bin", "application/octet-stream", payload);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(blob, tightCap);
|
||||
|
||||
assertThat(metrics.docUnits()).isEqualTo(10);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyFile_chargesTheOneUnitFloor() {
|
||||
MultipartFile empty =
|
||||
new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(empty, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.bytes()).isZero();
|
||||
assertThat(metrics.docUnits()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedPdf_fallsBackToBytesOnlyClassification() {
|
||||
byte[] junk = "%PDF-not-really-a-pdf-but-claims-to-be".getBytes();
|
||||
MultipartFile bad = new MockMultipartFile("file", "broken.pdf", "application/pdf", junk);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(bad, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isZero();
|
||||
assertThat(metrics.docUnits()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void encryptedPdf_isStillClassifiable() throws Exception {
|
||||
byte[] bytes = encryptedPdfBytes(5, "ownerpwd", "userpwd");
|
||||
MultipartFile encrypted =
|
||||
new MockMultipartFile("file", "secret.pdf", "application/pdf", bytes);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(encrypted, DEFAULT_POLICY);
|
||||
|
||||
// Page count behaviour on encrypted PDFs varies by reader; the stable property is that
|
||||
// the byte axis still produces a charge.
|
||||
assertThat(metrics.docUnits()).isGreaterThanOrEqualTo(1);
|
||||
assertThat(metrics.bytes()).isEqualTo(bytes.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullContentType_defaultsToOctetStream() {
|
||||
MultipartFile noType =
|
||||
new MockMultipartFile(
|
||||
"file", "unknown.dat", /* contentType= */ null, new byte[100]);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(noType, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.contentType()).isEqualTo("application/octet-stream");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdfDetectedByExtension_whenContentTypeIsGeneric() throws Exception {
|
||||
byte[] pdfBytes = pdfBytes(50);
|
||||
MultipartFile pdf =
|
||||
new MockMultipartFile("file", "report.pdf", "application/octet-stream", pdfBytes);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(pdf, DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.pages()).isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiFile_aggregatesUnits() throws Exception {
|
||||
// Two 50-page PDFs: each is ceil(50/25) = 2 raw units; total = 4. Group cap of 1000 × 2
|
||||
// doesn't bind.
|
||||
DocumentMetrics metrics =
|
||||
classifier.classify(List.of(pdf("a.pdf", 50), pdf("b.pdf", 50)), DEFAULT_POLICY);
|
||||
|
||||
assertThat(metrics.docUnits()).isEqualTo(4);
|
||||
assertThat(metrics.pages()).isEqualTo(100);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiFile_groupCapBindsOnSumOfRawUnits() {
|
||||
// Asymmetric file sizes are required to actually exercise the group cap:
|
||||
// File A: 50 raw units (well over fileUnitCap)
|
||||
// File B: 1 raw unit
|
||||
// Raw sum: 51
|
||||
// Group cap = fileUnitCap (25) × file_count (2) = 50
|
||||
//
|
||||
// With a buggy per-file clamp inside the loop: (25, 1) → sum 26.
|
||||
// With the fixed group cap on the raw sum: min(50, 51) = 50.
|
||||
PricingPolicy policy =
|
||||
new PricingPolicy(
|
||||
/* docPagesPerUnit= */ 25,
|
||||
/* docBytesPerUnit= */ 1L * 1024, // 1 KiB per unit
|
||||
/* minChargeUnits= */ 1,
|
||||
/* fileUnitCap= */ 25);
|
||||
|
||||
byte[] big = new byte[50 * 1024]; // 50 KiB → 50 raw units
|
||||
byte[] small = new byte[1 * 1024]; // 1 KiB → 1 raw unit
|
||||
MultipartFile a = new MockMultipartFile("file", "a.bin", "application/octet-stream", big);
|
||||
MultipartFile b = new MockMultipartFile("file", "b.bin", "application/octet-stream", small);
|
||||
|
||||
DocumentMetrics metrics = classifier.classify(List.of(a, b), policy);
|
||||
|
||||
assertThat(metrics.docUnits())
|
||||
.as(
|
||||
"Group cap should clamp the raw sum (51) to fileUnitCap × fileCount (50)."
|
||||
+ " A result of 26 here means per-file clamping has snuck back in"
|
||||
+ " and the group cap is dead.")
|
||||
.isEqualTo(50);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiFile_emptyListRejected() {
|
||||
assertThatThrownBy(() -> classifier.classify(List.of(), DEFAULT_POLICY))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
// --- Fixture helpers ------------------------------------------------------------------------
|
||||
|
||||
private static MultipartFile pdf(String name, int pages) throws IOException {
|
||||
return new MockMultipartFile("file", name, "application/pdf", pdfBytes(pages));
|
||||
}
|
||||
|
||||
private static byte[] pdfBytes(int pages) throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encryptedPdfBytes(int pages, String ownerPwd, String userPwd)
|
||||
throws IOException {
|
||||
try (PDDocument doc = new PDDocument();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
for (int i = 0; i < pages; i++) {
|
||||
doc.addPage(new PDPage());
|
||||
}
|
||||
doc.protect(new StandardProtectionPolicy(ownerPwd, userPwd, new AccessPermission()));
|
||||
doc.save(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a real {@link TempFileManager} backed by the OS temp dir. Cheaper and more
|
||||
* faithful than mocking — the classifier exercises the actual write+read+delete path the way it
|
||||
* would in production.
|
||||
*/
|
||||
private static TempFileManager buildTempFileManager() {
|
||||
return new TempFileManager(new TempFileRegistry(), new ApplicationProperties());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot;
|
||||
import stirling.software.saas.payg.entitlement.WalletEntitlementSnapshot.WalletEntitlementSnapshotId;
|
||||
import stirling.software.saas.payg.job.JobArtifactHash;
|
||||
import stirling.software.saas.payg.job.JobArtifactHash.JobArtifactHashId;
|
||||
import stirling.software.saas.payg.job.ProcessingJob;
|
||||
import stirling.software.saas.payg.job.ProcessingJobStep;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.shadow.PaygShadowCharge;
|
||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
|
||||
/**
|
||||
* Boots each PAYG entity via the no-arg constructor that JPA requires, exercises a few getter /
|
||||
* setter pairs, and confirms composite-key equality where applicable. Catches Lombok / annotation
|
||||
* regressions without needing a database.
|
||||
*/
|
||||
class PaygEntitiesSmokeTest {
|
||||
|
||||
@Test
|
||||
void pricingPolicy_instantiatesAndRoundTripsFields() {
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setVersion("v1-2026-06");
|
||||
p.setDocPagesPerUnit(25);
|
||||
p.setDocBytesPerUnit(10L * 1024 * 1024);
|
||||
p.setStepLimits(Map.of(JobSource.WEB, 10, JobSource.API, 20));
|
||||
p.setStripePriceIds(Set.of("price_abc", "price_def"));
|
||||
|
||||
assertThat(p.getVersion()).isEqualTo("v1-2026-06");
|
||||
assertThat(p.getStepLimits())
|
||||
.containsEntry(JobSource.WEB, 10)
|
||||
.containsEntry(JobSource.API, 20)
|
||||
.hasSize(2);
|
||||
assertThat(p.getStripePriceIds()).containsExactlyInAnyOrder("price_abc", "price_def");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pricingPolicy_convenienceCtorValidates() {
|
||||
// Existing classifier callsite uses this ctor — verify the validation it carries from the
|
||||
// previous record stays in place.
|
||||
PricingPolicy p = new PricingPolicy(25, 10L * 1024 * 1024, 1, 1000);
|
||||
assertThat(p.getDocPagesPerUnit()).isEqualTo(25);
|
||||
assertThat(p.getFileUnitCap()).isEqualTo(1000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processingJob_acceptsAllStatuses() {
|
||||
ProcessingJob job = new ProcessingJob();
|
||||
job.setId(UUID.randomUUID());
|
||||
job.setOwnerUserId(42L);
|
||||
job.setProcessType(ProcessType.CHAIN);
|
||||
job.setSource(JobSource.WEB);
|
||||
job.setStatus(JobStatus.OPEN);
|
||||
job.setStartedAt(LocalDateTime.now());
|
||||
job.setLastStepAt(LocalDateTime.now());
|
||||
|
||||
assertThat(job.getProcessType()).isEqualTo(ProcessType.CHAIN);
|
||||
assertThat(job.getStatus()).isEqualTo(JobStatus.OPEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processingJobStep_isInstantiable() {
|
||||
ProcessingJobStep step = new ProcessingJobStep();
|
||||
step.setJobId(UUID.randomUUID());
|
||||
step.setToolId("/api/v1/general/compress");
|
||||
step.setStatus(JobStepStatus.OK);
|
||||
|
||||
assertThat(step.getStatus()).isEqualTo(JobStepStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jobArtifactHash_compositeIdEqualityHolds() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
JobArtifactHashId a = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT);
|
||||
JobArtifactHashId b = new JobArtifactHashId(jobId, "abc123", ArtifactKind.INPUT);
|
||||
JobArtifactHashId different = new JobArtifactHashId(jobId, "abc123", ArtifactKind.OUTPUT);
|
||||
|
||||
assertThat(a).isEqualTo(b).hasSameHashCodeAs(b);
|
||||
assertThat(a).isNotEqualTo(different);
|
||||
|
||||
JobArtifactHash row = new JobArtifactHash();
|
||||
row.setId(a);
|
||||
assertThat(row.getId().getKind()).isEqualTo(ArtifactKind.INPUT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletLedgerEntry_signedAmountAllowed() {
|
||||
WalletLedgerEntry entry = new WalletLedgerEntry();
|
||||
entry.setTeamId(7L);
|
||||
entry.setEntryType(LedgerEntryType.DEBIT);
|
||||
entry.setBucket(LedgerBucket.CYCLE);
|
||||
entry.setAmountUnits(-4);
|
||||
entry.setReferenceType(ReferenceType.JOB);
|
||||
entry.setReferenceId("job:abc");
|
||||
|
||||
assertThat(entry.getAmountUnits()).isEqualTo(-4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletPolicy_carriesSensibleDefaults() {
|
||||
WalletPolicy policy = new WalletPolicy();
|
||||
|
||||
assertThat(policy.getEngine()).isEqualTo(WalletEngine.LEGACY);
|
||||
assertThat(policy.getCapPeriod()).isEqualTo(CapPeriod.CALENDAR_MONTH);
|
||||
assertThat(policy.getWarnAtPct()).isEqualTo(80);
|
||||
assertThat(policy.getDegradeAtPct()).isEqualTo(100);
|
||||
assertThat(policy.getDegradedFeatureSet()).isEqualTo(FeatureSet.MINIMAL);
|
||||
assertThat(policy.getAutoGroupStrategy()).isEqualTo(AutoGroupStrategy.AUTO);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletEntitlementSnapshot_compositeIdHandlesTeamWideSentinel() {
|
||||
WalletEntitlementSnapshotId teamWide =
|
||||
new WalletEntitlementSnapshotId(7L, WalletEntitlementSnapshot.TEAM_WIDE_USER_ID);
|
||||
WalletEntitlementSnapshotId memberA = new WalletEntitlementSnapshotId(7L, 42L);
|
||||
|
||||
assertThat(teamWide).isNotEqualTo(memberA);
|
||||
assertThat(teamWide.getUserId()).isZero();
|
||||
|
||||
WalletEntitlementSnapshot snap = new WalletEntitlementSnapshot();
|
||||
snap.setId(teamWide);
|
||||
snap.setEnabledGates(List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.AUTOMATION));
|
||||
|
||||
assertThat(snap.getState()).isEqualTo(EntitlementState.FULL);
|
||||
assertThat(snap.getEnabledGates()).hasSize(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void paygShadowCharge_isInstantiable() {
|
||||
PaygShadowCharge row = new PaygShadowCharge();
|
||||
row.setTeamId(7L);
|
||||
row.setJobId(UUID.randomUUID());
|
||||
row.setPolicyId(1L);
|
||||
row.setPaygUnits(4);
|
||||
row.setLegacyCreditsCharged(20);
|
||||
row.setDiffPct(-80);
|
||||
|
||||
assertThat(row.getDiffPct()).isNegative();
|
||||
}
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
package stirling.software.saas.payg.policy;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.PricingPolicyRepository;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PricingPolicyService}: lookup precedence (override → default), cache
|
||||
* hit/miss, invalidation on {@link PolicyChangedEvent}, mutation paths publishing the event.
|
||||
*/
|
||||
class PricingPolicyServiceTest {
|
||||
|
||||
private PricingPolicyRepository policyRepo;
|
||||
private PaygTeamExtensionsRepository extensionsRepo;
|
||||
private ApplicationEventPublisher events;
|
||||
private PricingPolicyService service;
|
||||
|
||||
private PricingPolicy defaultPolicy;
|
||||
private PricingPolicy overridePolicy;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
policyRepo = Mockito.mock(PricingPolicyRepository.class);
|
||||
extensionsRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
|
||||
events = Mockito.mock(ApplicationEventPublisher.class);
|
||||
service = new PricingPolicyService(policyRepo, extensionsRepo, events);
|
||||
|
||||
defaultPolicy = policy(1L, "v1-default", true);
|
||||
overridePolicy = policy(2L, "v1-enterprise", false);
|
||||
|
||||
when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.of(defaultPolicy));
|
||||
when(policyRepo.findById(1L)).thenReturn(Optional.of(defaultPolicy));
|
||||
when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
|
||||
when(policyRepo.existsById(2L)).thenReturn(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noOverride_returnsDefault() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
PricingPolicy result = service.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(result).isEqualTo(defaultPolicy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overrideSet_returnsOverride() {
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
ext.setPricingPolicyId(2L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
|
||||
PricingPolicy result = service.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(result).isEqualTo(overridePolicy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overridePointsAtMissingPolicy_fallsBackToDefault() {
|
||||
// Race condition: team's override row references a policy that has since been deleted.
|
||||
// Service should log + fall back rather than throw, so the team still gets billed
|
||||
// correctly under the default.
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
ext.setPricingPolicyId(999L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
when(policyRepo.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
PricingPolicy result = service.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(result).isEqualTo(defaultPolicy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDefaultExists_throws() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
when(policyRepo.findFirstByIsDefaultTrue()).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.getEffectivePolicy(42L))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("No default pricing_policy row");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondCallHitsCache_noRepoLookup() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
service.getEffectivePolicy(42L);
|
||||
service.getEffectivePolicy(42L);
|
||||
service.getEffectivePolicy(42L);
|
||||
|
||||
// Three calls, one DB lookup — the cache holds the result.
|
||||
verify(policyRepo, times(1)).findFirstByIsDefaultTrue();
|
||||
verify(extensionsRepo, times(1)).findById(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uncachedRead_alwaysHitsRepo() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
service.getEffectivePolicyUncached(42L);
|
||||
service.getEffectivePolicyUncached(42L);
|
||||
|
||||
verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void policyChangedEvent_invalidatesCache() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
service.getEffectivePolicy(42L);
|
||||
assertThat(service.cacheSize()).isEqualTo(1);
|
||||
|
||||
service.onPolicyChanged(new PolicyChangedEvent(this, "test"));
|
||||
|
||||
assertThat(service.cacheSize()).isZero();
|
||||
// Next call repopulates from DB.
|
||||
service.getEffectivePolicy(42L);
|
||||
verify(policyRepo, times(2)).findFirstByIsDefaultTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejectsDraftWithId() {
|
||||
PricingPolicy draft = policy(99L, "v2", false);
|
||||
assertThatThrownBy(() -> service.create(draft))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("must not carry a policy_id");
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_rejectsDefaultFlagPreSet() {
|
||||
PricingPolicy draft = policy(null, "v2", true);
|
||||
assertThatThrownBy(() -> service.create(draft))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("setDefault");
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void create_savesAndPublishesEvent() {
|
||||
PricingPolicy draft = policy(null, "v2-fresh", false);
|
||||
PricingPolicy saved = policy(3L, "v2-fresh", false);
|
||||
when(policyRepo.save(draft)).thenReturn(saved);
|
||||
|
||||
PricingPolicy result = service.create(draft);
|
||||
|
||||
assertThat(result).isEqualTo(saved);
|
||||
ArgumentCaptor<PolicyChangedEvent> evt = ArgumentCaptor.forClass(PolicyChangedEvent.class);
|
||||
verify(events).publishEvent(evt.capture());
|
||||
assertThat(evt.getValue().getPayload()).contains("create:3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_promotesAndClearsExisting() {
|
||||
// newDefaultId = 2, current default is 1
|
||||
PricingPolicy promoted = policy(2L, "v1-enterprise", true);
|
||||
when(policyRepo.findById(2L)).thenReturn(Optional.of(overridePolicy));
|
||||
when(policyRepo.save(any(PricingPolicy.class))).thenReturn(promoted);
|
||||
|
||||
PricingPolicy result = service.setDefault(2L);
|
||||
|
||||
verify(policyRepo).clearDefaultFlag();
|
||||
assertThat(result.getIsDefault()).isTrue();
|
||||
verify(events, atLeastOnce()).publishEvent(any(PolicyChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_alreadyDefault_isNoop() {
|
||||
// Calling setDefault on the row that's already default → return it, don't re-flag, but
|
||||
// still don't fire an event (no state change). Keeps callers idempotent without spamming
|
||||
// listeners.
|
||||
PricingPolicy result = service.setDefault(1L);
|
||||
|
||||
assertThat(result).isEqualTo(defaultPolicy);
|
||||
verify(policyRepo, never()).clearDefaultFlag();
|
||||
verify(policyRepo, never()).save(any(PricingPolicy.class));
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_unknownId_throws() {
|
||||
when(policyRepo.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.setDefault(999L))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("No pricing_policy with id 999");
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_setsAndPublishes() {
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
|
||||
|
||||
service.setTeamOverride(42L, 2L);
|
||||
|
||||
assertThat(ext.getPricingPolicyId()).isEqualTo(2L);
|
||||
verify(extensionsRepo).save(ext);
|
||||
verify(events).publishEvent(any(PolicyChangedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_clearsWithNullPolicyId() {
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(42L);
|
||||
ext.setPricingPolicyId(2L);
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
|
||||
when(extensionsRepo.save(any(PaygTeamExtensions.class))).thenReturn(ext);
|
||||
|
||||
service.setTeamOverride(42L, null);
|
||||
|
||||
assertThat(ext.getPricingPolicyId()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_unknownPolicyId_throwsBeforeSave() {
|
||||
when(policyRepo.existsById(999L)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.setTeamOverride(42L, 999L))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("No pricing_policy with id 999");
|
||||
|
||||
verify(extensionsRepo, never()).save(any(PaygTeamExtensions.class));
|
||||
verify(events, never()).publishEvent(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_missingExtensionsRow_throws() {
|
||||
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> service.setTeamOverride(42L, 2L))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("payg_team_extensions row");
|
||||
}
|
||||
|
||||
private static PricingPolicy policy(Long id, String version, boolean isDefault) {
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setId(id);
|
||||
p.setVersion(version);
|
||||
p.setEffectiveFrom(LocalDateTime.now());
|
||||
p.setDocPagesPerUnit(25);
|
||||
p.setDocBytesPerUnit(5L * 1024 * 1024);
|
||||
p.setMinChargeUnits(1);
|
||||
p.setFileUnitCap(1000);
|
||||
p.setIsDefault(isDefault);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package stirling.software.saas.payg.policy.admin;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.CreatePolicyRequest;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.PolicyResponse;
|
||||
import stirling.software.saas.payg.policy.admin.PolicyDtos.TeamOverrideRequest;
|
||||
|
||||
/**
|
||||
* Tests {@link PricingPolicyAdminController} as a plain Java unit (matching {@code
|
||||
* CreditControllerApiKeyTest}'s style — no MockMvc layer). Covers happy paths and the controller's
|
||||
* error mapping (4xx for validation, 404 for missing rows).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PricingPolicyAdminControllerTest {
|
||||
|
||||
@Mock private PricingPolicyService service;
|
||||
|
||||
private PricingPolicyAdminController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller = new PricingPolicyAdminController(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listPolicies_returnsAll() {
|
||||
when(service.listAll())
|
||||
.thenReturn(List.of(policy(1L, "v1", true), policy(2L, "v2", false)));
|
||||
|
||||
ResponseEntity<List<PolicyResponse>> resp = controller.listPolicies();
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody()).hasSize(2);
|
||||
assertThat(resp.getBody().get(0).version()).isEqualTo("v1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPolicy_returnsOk() {
|
||||
when(service.findById(1L)).thenReturn(Optional.of(policy(1L, "v1", true)));
|
||||
|
||||
ResponseEntity<PolicyResponse> resp = controller.getPolicy(1L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody().policyId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPolicy_missingReturns404() {
|
||||
when(service.findById(999L)).thenReturn(Optional.empty());
|
||||
|
||||
ResponseEntity<PolicyResponse> resp = controller.getPolicy(999L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPolicy_happyPath() {
|
||||
CreatePolicyRequest req =
|
||||
new CreatePolicyRequest(
|
||||
"v2",
|
||||
LocalDateTime.now(),
|
||||
null,
|
||||
25,
|
||||
5L * 1024 * 1024,
|
||||
1,
|
||||
1000,
|
||||
null,
|
||||
null,
|
||||
"notes",
|
||||
"admin@example.com");
|
||||
PricingPolicy saved = policy(99L, "v2", false);
|
||||
ArgumentCaptor<PricingPolicy> draft = ArgumentCaptor.forClass(PricingPolicy.class);
|
||||
when(service.create(draft.capture())).thenReturn(saved);
|
||||
|
||||
ResponseEntity<?> resp = controller.createPolicy(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
assertThat(((PolicyResponse) resp.getBody()).policyId()).isEqualTo(99L);
|
||||
assertThat(draft.getValue().getVersion()).isEqualTo("v2");
|
||||
// Controller must never let isDefault=true through to the service — setDefault is the
|
||||
// only path for promotion.
|
||||
assertThat(draft.getValue().getIsDefault()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPolicy_missingVersion_returns400() {
|
||||
CreatePolicyRequest req =
|
||||
new CreatePolicyRequest(
|
||||
null, null, null, 25, 5L * 1024 * 1024, 1, 1000, null, null, null, null);
|
||||
|
||||
ResponseEntity<?> resp = controller.createPolicy(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPolicy_missingDocFields_returns400() {
|
||||
CreatePolicyRequest req =
|
||||
new CreatePolicyRequest(
|
||||
"v2", null, null, null, null, 1, 1000, null, null, null, null);
|
||||
|
||||
ResponseEntity<?> resp = controller.createPolicy(req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_returnsOk() {
|
||||
when(service.setDefault(2L)).thenReturn(policy(2L, "v2-promoted", true));
|
||||
|
||||
ResponseEntity<?> resp = controller.setDefault(2L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(((PolicyResponse) resp.getBody()).isDefault()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void setDefault_unknownId_returns404() {
|
||||
when(service.setDefault(999L))
|
||||
.thenThrow(new IllegalArgumentException("No pricing_policy with id 999"));
|
||||
|
||||
ResponseEntity<?> resp = controller.setDefault(999L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_noContent() {
|
||||
TeamOverrideRequest req = new TeamOverrideRequest(2L);
|
||||
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
verify(service).setTeamOverride(42L, 2L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_nullBody_clearsOverride() {
|
||||
// Curl with no body, or {} → req == null is handled as "clear".
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, null);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
verify(service).setTeamOverride(42L, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_unknownPolicy_returns400() {
|
||||
TeamOverrideRequest req = new TeamOverrideRequest(999L);
|
||||
org.mockito.Mockito.doThrow(new IllegalArgumentException("No pricing_policy with id 999"))
|
||||
.when(service)
|
||||
.setTeamOverride(42L, 999L);
|
||||
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void setTeamOverride_missingTeamExtensions_returns404() {
|
||||
TeamOverrideRequest req = new TeamOverrideRequest(2L);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("No payg_team_extensions row"))
|
||||
.when(service)
|
||||
.setTeamOverride(42L, 2L);
|
||||
|
||||
ResponseEntity<?> resp = controller.setTeamOverride(42L, req);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getEffectivePolicy_bypassesCache() {
|
||||
when(service.getEffectivePolicyUncached(42L)).thenReturn(policy(1L, "v1", true));
|
||||
|
||||
ResponseEntity<PolicyResponse> resp = controller.getEffectivePolicy(42L);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody().version()).isEqualTo("v1");
|
||||
verify(service).getEffectivePolicyUncached(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void policyResponse_collectionsAreDefensiveCopies() {
|
||||
PricingPolicy p = policy(1L, "v1", true);
|
||||
p.setStepLimits(new java.util.HashMap<>(Map.of()));
|
||||
p.setStripePriceIds(new java.util.HashSet<>());
|
||||
|
||||
PolicyResponse resp = PolicyResponse.from(p);
|
||||
|
||||
// Mutating the source after building the response should not affect the response.
|
||||
p.getStepLimits().put(stirling.software.saas.payg.model.JobSource.WEB, 99);
|
||||
p.getStripePriceIds().add("price_xyz");
|
||||
assertThat(resp.stepLimits()).isEmpty();
|
||||
assertThat(resp.stripePriceIds()).isEmpty();
|
||||
}
|
||||
|
||||
private static PricingPolicy policy(Long id, String version, boolean isDefault) {
|
||||
PricingPolicy p = new PricingPolicy();
|
||||
p.setId(id);
|
||||
p.setVersion(version);
|
||||
p.setEffectiveFrom(LocalDateTime.now());
|
||||
p.setDocPagesPerUnit(25);
|
||||
p.setDocBytesPerUnit(5L * 1024 * 1024);
|
||||
p.setMinChargeUnits(1);
|
||||
p.setFileUnitCap(1000);
|
||||
p.setIsDefault(isDefault);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link
|
||||
* TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on
|
||||
* rollback.
|
||||
*/
|
||||
class StripeAfterCommitOrderingTest {
|
||||
|
||||
@AfterEach
|
||||
void clearSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCommitRunsAfterCommit_notDuringTransaction() {
|
||||
List<String> order = new ArrayList<>();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
order.add("inside-tx-before-register");
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
order.add("after-commit-hook");
|
||||
}
|
||||
});
|
||||
order.add("inside-tx-after-register");
|
||||
|
||||
// Simulate commit by firing afterCommit on every registered synchronization.
|
||||
order.add("commit-triggered");
|
||||
for (TransactionSynchronization s :
|
||||
TransactionSynchronizationManager.getSynchronizations()) {
|
||||
s.afterCommit();
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(order)
|
||||
.containsExactly(
|
||||
"inside-tx-before-register",
|
||||
"inside-tx-after-register",
|
||||
"commit-triggered",
|
||||
"after-commit-hook");
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCommitDoesNotRun_onRollback() {
|
||||
List<String> order = new ArrayList<>();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
order.add("after-commit-hook-MUST-NOT-FIRE");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
|
||||
order.add("after-completion-rollback");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate rollback: afterCompletion fires, afterCommit must not.
|
||||
for (TransactionSynchronization s :
|
||||
TransactionSynchronizationManager.getSynchronizations()) {
|
||||
s.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(order)
|
||||
.containsExactly("after-completion-rollback")
|
||||
.doesNotContain("after-commit-hook-MUST-NOT-FIRE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSynchronizationActive_reflectsSpringTransactionalContext() {
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue();
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
|
||||
}
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionDefinition;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Verifies finding #5 (CreditService Stripe ordering / DB divergence) end-to-end.
|
||||
*
|
||||
* <p>Connor's claim: free credits are deducted before the Stripe overage call; if Stripe fails the
|
||||
* code throws but the deduction has already committed. Earlier analysis flagged this BOGUS because
|
||||
* the class is {@code @Transactional} and Spring rolls back on uncaught RuntimeException — but the
|
||||
* subtlety I missed last time (with {@code @PreAuthorize hasRole}) means I want a real test rather
|
||||
* than another argument-from-docs.
|
||||
*
|
||||
* <p>This test reproduces the exact Spring transaction wiring: a method annotated as transactional
|
||||
* does (1) an in-transaction "deduct credits" write, then (2) throws a RuntimeException. We assert
|
||||
* the transaction manager observes the throw and triggers {@code rollback()}, not {@code commit()}.
|
||||
*/
|
||||
class StripeRollbackOnFailureTest {
|
||||
|
||||
@Test
|
||||
void runtimeExceptionTriggersRollback_notCommit() {
|
||||
AtomicInteger commits = new AtomicInteger();
|
||||
AtomicInteger rollbacks = new AtomicInteger();
|
||||
|
||||
PlatformTransactionManager tm =
|
||||
new AbstractPlatformTransactionManager() {
|
||||
@Override
|
||||
protected Object doGetTransaction() {
|
||||
return new Object();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doBegin(
|
||||
Object transaction,
|
||||
org.springframework.transaction.TransactionDefinition def) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doCommit(DefaultTransactionStatus status) {
|
||||
commits.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doRollback(DefaultTransactionStatus status) {
|
||||
rollbacks.incrementAndGet();
|
||||
}
|
||||
};
|
||||
|
||||
TransactionTemplate template =
|
||||
new TransactionTemplate(tm, new DefaultTransactionDefinition());
|
||||
|
||||
// This is the exact shape of CreditService.consumeCreditBySupabaseId when Stripe fails:
|
||||
// 1. deduct free credits (already happened, line 318-320 in production)
|
||||
// 2. call Stripe → returns false (mocked)
|
||||
// 3. throw new RuntimeException("Unable to report usage to Stripe...")
|
||||
// The throw escapes through the catch at line 413-420 (which re-throws metering failures).
|
||||
RuntimeException thrown =
|
||||
assertThrows(
|
||||
RuntimeException.class,
|
||||
() ->
|
||||
template.executeWithoutResult(
|
||||
status -> {
|
||||
// Step 1: imaginary credit deduction happens here.
|
||||
// Step 2: Stripe returns false.
|
||||
// Step 3: throw — same wording as production line 372.
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering.");
|
||||
}));
|
||||
|
||||
assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe");
|
||||
assertThat(commits.get())
|
||||
.as("commit() must NOT be called when the method throws a RuntimeException")
|
||||
.isZero();
|
||||
assertThat(rollbacks.get())
|
||||
.as("rollback() must be called when the method throws a RuntimeException")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeExceptionIsRethrown_notSwallowed_throughCatchBlock() {
|
||||
// Sanity check that the actual catch logic at CreditService.java:413-420 re-throws the
|
||||
// Stripe-failure RuntimeException rather than swallowing it. If it didn't re-throw, the
|
||||
// transaction would commit. We rebuild the same try/catch shape here.
|
||||
RuntimeException thrown =
|
||||
assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> consumeCreditMimicry(/* stripeReports= */ false));
|
||||
assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeExceptionIsSwallowed_forNonMeteringErrors() {
|
||||
// Unrelated runtime exceptions are caught at CreditService.java:425-431 and swallowed
|
||||
// (return false). This is per the existing behaviour so we just lock it in.
|
||||
Boolean result = consumeCreditMimicry(/* stripeReports= */ true);
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
/** Tiny inline mock of the catch chain in CreditService.consumeCreditBySupabaseId. */
|
||||
private static Boolean consumeCreditMimicry(boolean stripeReports) {
|
||||
try {
|
||||
// Step 1: deduct free credits (would have been DB write).
|
||||
// Step 2: Stripe call.
|
||||
if (!stripeReports) {
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering.");
|
||||
}
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
} catch (RuntimeException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Unable to report usage to Stripe")) {
|
||||
throw e; // re-thrown so @Transactional rolls back
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.saas.billing.service.StripeUsageReportingService;
|
||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Pins the Stripe meter-event idempotency key as a deterministic function of (Supabase user,
|
||||
* overage amount, request id). Stripe collapses duplicates by this key, so a regression here means
|
||||
* customers get double-billed on a retry.
|
||||
*/
|
||||
class StripeUsageIdempotencyKeyTest {
|
||||
|
||||
private final StripeUsageReportingService service =
|
||||
new StripeUsageReportingService(Mockito.mock(SupabaseConfigurationProperties.class));
|
||||
|
||||
@Test
|
||||
void sameInputs_produceSameKey() {
|
||||
String first = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String second = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
|
||||
assertThat(first)
|
||||
.as("Idempotency key must be stable across calls with identical inputs.")
|
||||
.isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentRequestIds_produceDifferentKeys() {
|
||||
String reqA = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String reqB = service.generateIdempotencyKey("user-123", 10, "req-xyz");
|
||||
|
||||
assertThat(reqA).isNotEqualTo(reqB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOverageAmounts_produceDifferentKeys() {
|
||||
String tenCredits = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String elevenCredits = service.generateIdempotencyKey("user-123", 11, "req-abc");
|
||||
|
||||
assertThat(tenCredits).isNotEqualTo(elevenCredits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentUsers_produceDifferentKeys() {
|
||||
String alice = service.generateIdempotencyKey("user-alice", 10, "req-abc");
|
||||
String bob = service.generateIdempotencyKey("user-bob", 10, "req-abc");
|
||||
|
||||
assertThat(alice).isNotEqualTo(bob);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyShapeIncludesAllThreeDimensions() {
|
||||
// Format: usage_{supabaseId}_{credits}_{operationId}
|
||||
String key = service.generateIdempotencyKey("user-123", 42, "req-abc");
|
||||
|
||||
assertThat(key).contains("user-123").contains("42").contains("req-abc");
|
||||
}
|
||||
}
|
||||
+11
-4
@@ -348,12 +348,17 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
python3 python3-venv ca-certificates binutils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY scripts/pymupdf_convert.py /tmp/pymupdf_convert.py
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \
|
||||
python3 -m venv /opt/venv --system-site-packages && \
|
||||
/opt/venv/bin/pip install --no-cache-dir --prefer-binary --only-binary=:all: \
|
||||
weasyprint pdf2image opencv-python-headless ocrmypdf \
|
||||
cryptography \
|
||||
"unoserver==${UNOSERVER_VERSION}" && \
|
||||
"unoserver==${UNOSERVER_VERSION}" \
|
||||
pymupdf pymupdf4llm && \
|
||||
install -m 0755 /tmp/pymupdf_convert.py /usr/local/bin/pymupdf-convert && \
|
||||
rm /tmp/pymupdf_convert.py && \
|
||||
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \
|
||||
find /opt/venv \( -name '*.pyc' -o -name '*.pyi' \) -delete 2>/dev/null || true && \
|
||||
rm -rf /opt/venv/lib/python*/site-packages/pip \
|
||||
@@ -608,6 +613,8 @@ RUN ldconfig /usr/local/lib && \
|
||||
/opt/venv/bin/python -c "import cv2; print('OpenCV', cv2.__version__)" && \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/opt/venv/bin/python -c "import ocrmypdf; print('ocrmypdf OK')" && \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
/opt/venv/bin/python -c "import pymupdf4llm; print('pymupdf4llm OK')" && \
|
||||
find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Non-root user
|
||||
@@ -643,9 +650,9 @@ RUN set -eux; \
|
||||
ln -sf /opt/calibre/ebook-convert /usr/bin/ebook-convert; \
|
||||
ln -sf /opt/venv/bin/unoconvert /usr/local/bin/unoconvert; \
|
||||
ln -sf /opt/venv/bin/unoserver /usr/local/bin/unoserver; \
|
||||
ln -sf /opt/venv/bin/ocrmypdf /usr/local/bin/ocrmypdf; \
|
||||
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
|
||||
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
|
||||
ln -sf /opt/venv/bin/ocrmypdf /usr/local/bin/ocrmypdf; \
|
||||
ln -sf /opt/venv/bin/weasyprint /usr/local/bin/weasyprint; \
|
||||
ln -sf /opt/venv/bin/unoping /usr/local/bin/unoping; \
|
||||
fc-cache -f
|
||||
|
||||
# Metadata labels - base image
|
||||
|
||||
@@ -47,6 +47,7 @@ class OrchestratorRequest(ApiModel):
|
||||
enabled_endpoints: Annotated[list[ToolEndpoint], BeforeValidator(drop_unknown_tool_endpoints)] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
pymupdf_worker_available: bool = False
|
||||
|
||||
|
||||
class UnsupportedCapabilityResponse(ApiModel):
|
||||
|
||||
@@ -90,6 +90,7 @@ nothingToUndo = "Nothing to undo"
|
||||
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
||||
noValidFiles = "No valid files to process"
|
||||
oops = "Oops!"
|
||||
openInNewWindow = "Open in new window"
|
||||
openInViewer = "Open in Viewer"
|
||||
operationCancelled = "Operation cancelled"
|
||||
page = "Page"
|
||||
@@ -1505,6 +1506,32 @@ user = "User"
|
||||
usernameInfo = "Username can only contain letters, numbers and the following special characters @._+- or must be a valid email address."
|
||||
webOnlyUser = "Web Only User"
|
||||
|
||||
[agents]
|
||||
auto_redaction_description = "Redact PII automatically"
|
||||
auto_redaction_name = "Auto Redaction"
|
||||
back_to_tools = "Back to tools"
|
||||
coming_soon = "Coming soon"
|
||||
compliance_description = "Audit documents for compliance"
|
||||
compliance_name = "Compliance Check"
|
||||
data_extraction_description = "Extract tables & structured data"
|
||||
data_extraction_name = "Data Extraction"
|
||||
doc_summary_description = "Summarise long documents"
|
||||
doc_summary_name = "Summariser"
|
||||
form_filler_description = "Fill PDF forms intelligently"
|
||||
form_filler_name = "Form Filler"
|
||||
fullscreen_title = "Stirling Agents"
|
||||
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
|
||||
pdf_to_markdown_name = "PDF to Markdown"
|
||||
section_title = "Agents"
|
||||
show_less = "Show less"
|
||||
start_chat = "Start chatting"
|
||||
stirling_description = "Your general-purpose PDF assistant"
|
||||
stirling_full_name = "Stirling General Agent"
|
||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||
stirling_name = "Stirling"
|
||||
stirling_tooltip = "Stirling agent"
|
||||
view_all = "View all agents"
|
||||
|
||||
[analytics]
|
||||
disable = "Disable analytics"
|
||||
enable = "Enable analytics"
|
||||
@@ -1764,6 +1791,10 @@ insufficientPermissions = "You do not have permission to perform this action."
|
||||
pleaseLoginAgain = "Please login again."
|
||||
sessionExpired = "Session Expired"
|
||||
|
||||
[auth.displayName]
|
||||
guest = "Guest"
|
||||
user = "User"
|
||||
|
||||
[auto-rename]
|
||||
description = "Automatically finds the title from your PDF content and uses it as the filename."
|
||||
header = "Auto Rename PDF"
|
||||
@@ -2684,6 +2715,16 @@ title = "Change Permissions"
|
||||
[changePermissions.tooltip.warning]
|
||||
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
|
||||
|
||||
[chat.header]
|
||||
agentMenu = "Stirling agent options"
|
||||
clearChat = "Clear chat"
|
||||
settings = "Agent settings"
|
||||
|
||||
[chat.input]
|
||||
attach = "Attach files"
|
||||
placeholder = "What do you want to do?"
|
||||
send = "Send message"
|
||||
|
||||
[chat.progress]
|
||||
analyzing = "Analysing your request..."
|
||||
calling_engine = "AI is thinking..."
|
||||
@@ -2699,6 +2740,32 @@ whole_doc_read_done = "Finished reading the document..."
|
||||
whole_doc_read_started = "Reading the document..."
|
||||
whole_doc_slice_done = "Reading the document... ({{percent}}% complete)"
|
||||
|
||||
[chat.quickActions]
|
||||
browseYourFiles = "Browse your files"
|
||||
compressMany = "Compress these documents"
|
||||
compressOne = "Compress this document"
|
||||
convertMany = "Convert these documents to PDF"
|
||||
convertOne = "Convert this document to PDF"
|
||||
fileSummary_one = "1 file in workbench ({{types}})"
|
||||
fileSummary_other = "{{count}} files in workbench ({{types}})"
|
||||
heading = "Get started"
|
||||
mergeMany = "Merge these {{count}} documents into 1"
|
||||
moreFiles = "+{{count}} more"
|
||||
openFromComputer = "Open from computer"
|
||||
removeFile = "Remove {{name}}"
|
||||
rotateMany = "Rotate these documents"
|
||||
rotateOne = "Rotate this document"
|
||||
splitOne = "Split this document"
|
||||
|
||||
[chat.responses]
|
||||
cannot_continue = "Something went wrong and I can't continue."
|
||||
cannot_do = "I'm unable to do that."
|
||||
done = "Done."
|
||||
need_clarification = "Could you clarify your request?"
|
||||
not_found = "I couldn't find the requested information."
|
||||
processing = "Processing ({{outcome}})..."
|
||||
unsupported_capability = "Unsupported capability: {{capability}}"
|
||||
|
||||
[chat.toolsUsed]
|
||||
summary = "Ran {{count}} tools"
|
||||
summary_one = "Ran 1 tool"
|
||||
@@ -3848,6 +3915,7 @@ renameFolder = "Rename folder"
|
||||
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search this folder & subfolders"
|
||||
selectAll = "Select all"
|
||||
@@ -7892,6 +7960,7 @@ bulkTitle = "Upload checked files"
|
||||
description = "This uploads the current file to server storage for your own access."
|
||||
errorTitle = "Upload failed"
|
||||
failure = "Upload failed. Please check your login and storage settings."
|
||||
featureDisabled = "Saving to the server isn't enabled on this server."
|
||||
fileCount = "{{count}} files"
|
||||
fileLabel = "File"
|
||||
hint = "Public links and access modes are controlled by your server settings."
|
||||
@@ -8065,13 +8134,18 @@ viewerMode = "Switch to the file editor to add multiple files."
|
||||
[toolPanel]
|
||||
allTools = "All tools"
|
||||
alpha = "Alpha"
|
||||
backToAllTools = "Back to all tools"
|
||||
backToDefault = "Back"
|
||||
backToTools = "Back to tools"
|
||||
collapse = "Collapse panel"
|
||||
comingSoon = "Coming soon:"
|
||||
expand = "Expand panel"
|
||||
goBack = "Go back"
|
||||
placeholder = "Choose a tool to get started"
|
||||
premiumFeature = "Premium feature:"
|
||||
search = "Search tools"
|
||||
toolsHeader = "Tools"
|
||||
viewAllTools = "View all tools"
|
||||
|
||||
[toolPanel.fullscreen]
|
||||
comingSoon = "Coming soon:"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "enables the default permissions",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "main-*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
|
||||
@@ -5,9 +5,18 @@ pub mod auth;
|
||||
pub mod default_app;
|
||||
pub mod platform;
|
||||
pub mod print;
|
||||
pub mod window;
|
||||
|
||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
|
||||
pub use window::{
|
||||
forward_files_to_window,
|
||||
open_files_in_new_window,
|
||||
open_in_new_window,
|
||||
pop_window_file_ids,
|
||||
target_window_label,
|
||||
MAIN_WINDOW_LABEL,
|
||||
};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
is_first_launch,
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
use crate::commands::files::add_opened_file;
|
||||
use crate::utils::add_log;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
|
||||
// The primary window created from tauri.conf.json.
|
||||
pub const MAIN_WINDOW_LABEL: &str = "main";
|
||||
|
||||
static NEXT_WINDOW_ID: AtomicU32 = AtomicU32::new(2);
|
||||
|
||||
// Per-window queues of stored-file IDs waiting to be opened. Unlike disk paths
|
||||
// (which use the global OPENED_FILES queue), these reference files already in
|
||||
// the shared IndexedDB store, so a "new window" opened from the My Files page
|
||||
// loads the same file by reference. Keyed by the new window's label.
|
||||
static PENDING_FILE_IDS: Mutex<Option<HashMap<String, Vec<String>>>> = Mutex::new(None);
|
||||
|
||||
fn next_window_label() -> String {
|
||||
let id = NEXT_WINDOW_ID.fetch_add(1, Ordering::SeqCst);
|
||||
format!("main-{}", id)
|
||||
}
|
||||
|
||||
fn queue_file_ids(label: &str, ids: Vec<String>) {
|
||||
let mut guard = PENDING_FILE_IDS.lock().unwrap();
|
||||
let map = guard.get_or_insert_with(HashMap::new);
|
||||
map.entry(label.to_string()).or_default().extend(ids);
|
||||
}
|
||||
|
||||
// Shared window builder: every Stirling window must use identical WebView2
|
||||
// browser args so they can share one user-data folder (see the note below),
|
||||
// so all spawn paths funnel through here.
|
||||
fn build_window(app: &AppHandle, label: &str, url: &str) -> Result<WebviewWindow, String> {
|
||||
let builder = WebviewWindowBuilder::new(app, label, WebviewUrl::App(url.into()))
|
||||
.title("Stirling-PDF")
|
||||
.inner_size(1280.0, 800.0)
|
||||
// Below this width the file manager collapses to its mobile layout,
|
||||
// so keep new windows above the breakpoint.
|
||||
.min_inner_size(1030.0, 600.0)
|
||||
.resizable(true);
|
||||
|
||||
// WebView2 (Windows only) requires every webview sharing a user-data folder
|
||||
// to use identical additional_browser_args. wry's behaviour
|
||||
// (webview2/mod.rs:294): when the user provides args it uses them as-is and
|
||||
// does NOT prepend its own default `--disable-features=msWebOOUI,...`. So the
|
||||
// main window's actual args are EXACTLY what tauri.conf.json declares -
|
||||
// nothing more. We mirror that string byte-for-byte so windows share one data
|
||||
// dir (and thus IndexedDB / localStorage / cookies). macOS (WKWebView) and
|
||||
// Linux (WebKitGTK) don't have this constraint, so the arg is Windows-only.
|
||||
#[cfg(target_os = "windows")]
|
||||
let builder =
|
||||
builder.additional_browser_args("--enable-features=CertVerifierBuiltinFeature");
|
||||
|
||||
builder.build().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// Run `work` on the main thread and await its result. WebView2 on Windows
|
||||
// refuses to create a webview off the main thread (HRESULT 0x8007139F), but
|
||||
// Tauri command handlers run on a worker thread - so any window creation has to
|
||||
// hop over first. Centralised here so every command does it the same way.
|
||||
async fn run_on_main_thread_result<F, R>(app: &AppHandle, work: F) -> Result<R, String>
|
||||
where
|
||||
F: FnOnce() -> R + Send + 'static,
|
||||
R: Send + 'static,
|
||||
{
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let _ = tx.send(work());
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
rx.await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// Spawn a new webview window in the same Tauri process.
|
||||
// The backend stays single; only the frontend is duplicated.
|
||||
// If `paths` is non-empty, they're enqueued under the new window's label,
|
||||
// so the React app pops them on mount just like a fresh launch with a file.
|
||||
fn spawn_new_window(app: &AppHandle, paths: Vec<String>) -> Result<String, String> {
|
||||
let label = next_window_label();
|
||||
|
||||
for path in &paths {
|
||||
add_opened_file(path.clone());
|
||||
}
|
||||
|
||||
match build_window(app, &label, "/") {
|
||||
Ok(window) => {
|
||||
add_log(format!(
|
||||
"🪟 Spawned new window '{}' with {} initial file(s)",
|
||||
label,
|
||||
paths.len()
|
||||
));
|
||||
// The new window pops the shared queue on mount, so the files are
|
||||
// already waiting for it. We target the emit at this window only
|
||||
// (not a broadcast) so already-open windows don't race to pop them.
|
||||
if !paths.is_empty() {
|
||||
let _ = window.emit_to(label.as_str(), "files-changed", ());
|
||||
}
|
||||
Ok(label)
|
||||
}
|
||||
Err(err) => {
|
||||
add_log(format!(
|
||||
"❌ Failed to spawn new window '{}': {}",
|
||||
label, err
|
||||
));
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_in_new_window(app: AppHandle, paths: Vec<String>) -> Result<String, String> {
|
||||
let valid_paths: Vec<String> = paths
|
||||
.into_iter()
|
||||
.filter(|p| {
|
||||
let exists = std::path::Path::new(p).exists();
|
||||
if !exists {
|
||||
add_log(format!(
|
||||
"⚠️ Ignoring non-existent path for new window: {}",
|
||||
p
|
||||
));
|
||||
}
|
||||
exists
|
||||
})
|
||||
.collect();
|
||||
|
||||
let app_clone = app.clone();
|
||||
run_on_main_thread_result(&app, move || spawn_new_window(&app_clone, valid_paths)).await?
|
||||
}
|
||||
|
||||
// Open already-stored files (by IndexedDB id) in a fresh window. Used by the
|
||||
// "Open in new window" action on the My Files page. The ids are queued under
|
||||
// the new window's label; the new window pops them on mount and loads them from
|
||||
// the shared store into its workspace.
|
||||
#[tauri::command]
|
||||
pub async fn open_files_in_new_window(
|
||||
app: AppHandle,
|
||||
file_ids: Vec<String>,
|
||||
) -> Result<String, String> {
|
||||
let label = next_window_label();
|
||||
let app_clone = app.clone();
|
||||
run_on_main_thread_result(&app, move || {
|
||||
build_window(&app_clone, &label, "/").map(|window| {
|
||||
let count = file_ids.len();
|
||||
// Queue the ids only after the window is created, so a failed build
|
||||
// doesn't leave orphaned ids under a label no window will consume.
|
||||
queue_file_ids(&label, file_ids);
|
||||
add_log(format!(
|
||||
"🪟 Spawned new window '{}' for {} stored file(s)",
|
||||
label, count
|
||||
));
|
||||
// The new window also pops on mount; this emit is a nudge in case it
|
||||
// mounted before the ids were queued.
|
||||
let _ = window.emit_to(label.as_str(), "window-files-ready", ());
|
||||
label.clone()
|
||||
})
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
// Pop (return and clear) the stored-file ids queued for the calling window.
|
||||
#[tauri::command]
|
||||
pub async fn pop_window_file_ids(window: WebviewWindow) -> Result<Vec<String>, String> {
|
||||
let label = window.label().to_string();
|
||||
let ids = {
|
||||
let mut guard = PENDING_FILE_IDS.lock().unwrap();
|
||||
guard
|
||||
.as_mut()
|
||||
.and_then(|map| map.remove(&label))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if !ids.is_empty() {
|
||||
add_log(format!(
|
||||
"📂 Returning {} stored file id(s) for window '{}'",
|
||||
ids.len(),
|
||||
label
|
||||
));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
// Pick the best existing window to receive an opened file: the focused one,
|
||||
// else the main window, else any open window. Returns None only if there are
|
||||
// no windows at all. Used so file-opens (file association, "open with") land in
|
||||
// the window the user is actually looking at, and still work if the original
|
||||
// "main" window has been closed.
|
||||
pub fn target_window_label(app: &AppHandle) -> Option<String> {
|
||||
let windows = app.webview_windows();
|
||||
if let Some((label, _)) = windows
|
||||
.iter()
|
||||
.find(|(_, w)| w.is_focused().unwrap_or(false))
|
||||
{
|
||||
return Some(label.clone());
|
||||
}
|
||||
if windows.contains_key(MAIN_WINDOW_LABEL) {
|
||||
return Some(MAIN_WINDOW_LABEL.to_string());
|
||||
}
|
||||
windows.keys().next().cloned()
|
||||
}
|
||||
|
||||
// Add files to the shared queue and notify a specific window to consume them.
|
||||
// Used by drag-drop, the macOS open event, and the second-instance callback
|
||||
// (when --new-window is NOT set). The emit is targeted at `label` so only that
|
||||
// window pops the queue - other windows ignore it and keep their own files.
|
||||
pub fn forward_files_to_window(app: &AppHandle, label: &str, paths: Vec<String>) {
|
||||
for path in &paths {
|
||||
add_opened_file(path.clone());
|
||||
}
|
||||
if let Some(window) = app.get_webview_window(label) {
|
||||
let _ = app.emit_to(label, "files-changed", ());
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
} else {
|
||||
// Target window is gone; let any window pick the files up.
|
||||
let _ = app.emit("files-changed", ());
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,16 @@ use commands::{
|
||||
clear_opened_files,
|
||||
clear_refresh_token,
|
||||
clear_user_info,
|
||||
forward_files_to_window,
|
||||
is_default_pdf_handler,
|
||||
get_auth_token,
|
||||
get_backend_port,
|
||||
get_connection_config,
|
||||
get_opened_files,
|
||||
open_files_in_new_window,
|
||||
open_in_new_window,
|
||||
pop_opened_files,
|
||||
pop_window_file_ids,
|
||||
get_refresh_token,
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
@@ -31,6 +35,8 @@ use commands::{
|
||||
print_pdf_file_native,
|
||||
start_backend,
|
||||
start_oauth_login,
|
||||
target_window_label,
|
||||
MAIN_WINDOW_LABEL,
|
||||
};
|
||||
use commands::connection::apply_provisioning_if_present;
|
||||
use state::connection_state::AppConnectionState;
|
||||
@@ -47,6 +53,16 @@ fn dispatch_deep_link(app: &AppHandle, url: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract existing file paths from CLI args (skips the executable name).
|
||||
fn parse_launch_files(args: &[String]) -> Vec<String> {
|
||||
args
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|arg| std::path::Path::new(arg).exists())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
@@ -66,38 +82,33 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(AppConnectionState::default())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
// This callback runs when a second instance tries to start
|
||||
// Runs in the existing instance when a second launch is attempted
|
||||
// (e.g. "open with" / double-click while the app is running).
|
||||
add_log(format!("📂 Second instance detected with args: {:?}", args));
|
||||
|
||||
// Scan args for PDF files (skip first arg which is the executable)
|
||||
for arg in args.iter().skip(1) {
|
||||
if std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 Forwarding file to existing instance: {}", arg));
|
||||
let files = parse_launch_files(&args);
|
||||
// Route to the window the user is in (focused -> main -> any) so opens
|
||||
// consolidate into one window instead of spawning a new one.
|
||||
let label = target_window_label(app).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string());
|
||||
|
||||
// Store file for later retrieval (in case frontend isn't ready yet)
|
||||
add_opened_file(arg.clone());
|
||||
|
||||
// Bring the existing window to front
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
}
|
||||
if !files.is_empty() {
|
||||
add_log(format!("📂 Forwarding {} file(s) to existing window '{}'", files.len(), label));
|
||||
forward_files_to_window(app, &label, files);
|
||||
} else if let Some(window) = app.get_webview_window(&label) {
|
||||
// No files: just bring the app to the front.
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
let _ = app.emit("files-changed", ());
|
||||
}))
|
||||
.setup(|app| {
|
||||
add_log("🚀 Tauri app setup started".to_string());
|
||||
|
||||
// Process command line arguments on first launch
|
||||
// Files passed on the command line at first launch load into the main
|
||||
// window once the frontend mounts.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
for arg in args.iter().skip(1) {
|
||||
if std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 Initial file from command line: {}", arg));
|
||||
add_opened_file(arg.clone());
|
||||
}
|
||||
for path in parse_launch_files(&args) {
|
||||
add_log(format!("📂 Initial file from command line: {}", path));
|
||||
add_opened_file(path);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -147,6 +158,9 @@ pub fn run() {
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
clear_opened_files,
|
||||
open_in_new_window,
|
||||
open_files_in_new_window,
|
||||
pop_window_file_ids,
|
||||
get_tauri_logs,
|
||||
get_connection_config,
|
||||
set_connection_mode,
|
||||
@@ -183,26 +197,19 @@ pub fn run() {
|
||||
// Don't cleanup here - let JavaScript handler prevent close if needed
|
||||
// Backend cleanup happens in ExitRequested when window actually closes
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
|
||||
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), label, .. } => {
|
||||
use tauri::DragDropEvent;
|
||||
match drag_drop_event {
|
||||
DragDropEvent::Drop { paths, .. } => {
|
||||
add_log(format!("📂 Files dropped: {:?}", paths));
|
||||
let mut added_files = false;
|
||||
if let DragDropEvent::Drop { paths, .. } = drag_drop_event {
|
||||
add_log(format!("📂 Files dropped on window '{}': {:?}", label, paths));
|
||||
let file_paths: Vec<String> = paths
|
||||
.iter()
|
||||
.filter_map(|p| p.to_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
for path in paths {
|
||||
if let Some(path_str) = path.to_str() {
|
||||
add_log(format!("📂 Processing dropped file: {}", path_str));
|
||||
add_opened_file(path_str.to_string());
|
||||
added_files = true;
|
||||
}
|
||||
}
|
||||
|
||||
if added_files {
|
||||
let _ = app_handle.emit("files-changed", ());
|
||||
}
|
||||
// Route to the window the file was actually dropped on.
|
||||
if !file_paths.is_empty() {
|
||||
forward_files_to_window(app_handle, &label, file_paths);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -210,30 +217,29 @@ pub fn run() {
|
||||
use urlencoding::decode;
|
||||
|
||||
add_log(format!("📂 Tauri file opened event: {:?}", urls));
|
||||
let mut added_files = false;
|
||||
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
if url_str.starts_with("file://") {
|
||||
let encoded_path = url_str.strip_prefix("file://").unwrap_or(url_str);
|
||||
|
||||
let file_paths: Vec<String> = urls
|
||||
.iter()
|
||||
.filter_map(|url| {
|
||||
let url_str = url.as_str();
|
||||
if !url_str.starts_with("file://") {
|
||||
return None;
|
||||
}
|
||||
let encoded = url_str.strip_prefix("file://").unwrap_or(url_str);
|
||||
// Decode URL-encoded characters (%20 -> space, etc.)
|
||||
let file_path = match decode(encoded_path) {
|
||||
Ok(decoded) => decoded.into_owned(),
|
||||
match decode(encoded) {
|
||||
Ok(decoded) => Some(decoded.into_owned()),
|
||||
Err(e) => {
|
||||
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded_path, e));
|
||||
encoded_path.to_string() // Fallback to encoded path
|
||||
add_log(format!("⚠️ Failed to decode file path: {} - {}", encoded, e));
|
||||
Some(encoded.to_string())
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
add_log(format!("📂 Processing opened file: {}", file_path));
|
||||
add_opened_file(file_path);
|
||||
added_files = true;
|
||||
}
|
||||
}
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
if added_files {
|
||||
let _ = app_handle.emit("files-changed", ());
|
||||
if !file_paths.is_empty() {
|
||||
// Route to the window the user is in (focused -> main -> any).
|
||||
let label = target_window_label(app_handle).unwrap_or_else(|| MAIN_WINDOW_LABEL.to_string());
|
||||
forward_files_to_window(app_handle, &label, file_paths);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
export interface AuthContextType {
|
||||
session: null;
|
||||
user: { id?: string; email?: string; [key: string]: unknown } | null;
|
||||
/**
|
||||
* Human-readable name to show in the UI for the current session.
|
||||
* - A real identity (username/email/full_name) when the user is signed in.
|
||||
* - A layer-specific placeholder (e.g. "Guest" in SaaS, "User" in
|
||||
* proprietary) for anonymous sessions.
|
||||
* - null only when there is no user object at all (signed-out, or core
|
||||
* OSS with no auth context) - consumers can fall back to whatever
|
||||
* makes sense in their build.
|
||||
*
|
||||
* Each layer derives this from its own native user shape - consumers
|
||||
* should treat the resulting string as opaque display text.
|
||||
*/
|
||||
displayName: string | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
signOut: () => Promise<void>;
|
||||
@@ -15,6 +28,7 @@ export function useAuth(): AuthContextType {
|
||||
return {
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Agents UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail collapses to the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
/** Whether the right rail should reserve space for agents UI. False in core. */
|
||||
export function useAgentsEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent chat overlay is currently open. Core builds have no chat,
|
||||
* so this always returns false. Proprietary builds bridge to the ChatContext.
|
||||
* Used by {@code RightSidebar} so the fullscreen tool picker can yield to the
|
||||
* chat overlay just like it yields to a selected tool.
|
||||
*/
|
||||
export function useAgentChatOpen(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
|
||||
export function AgentsSection() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon-only agent button rendered in the collapsed (minimised) right rail.
|
||||
* Returns null in core; proprietary renders the Stirling agent shortcut.
|
||||
*/
|
||||
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-rail chat overlay rendered inside {@code ToolPanel}. Covers the panel
|
||||
* (including the search bar) when an agent conversation is active.
|
||||
*/
|
||||
export function AgentsChatOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents card rendered inside the fullscreen tool picker. Matches the visual
|
||||
* language of the fullscreen category cards (gradient border, title, items).
|
||||
* Returns null in core; proprietary renders the Stirling agent.
|
||||
*/
|
||||
export function AgentsFullscreenSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -39,6 +39,8 @@ interface FileDetailsPanelProps {
|
||||
onRemove: (fileIds: FileId[]) => void;
|
||||
/** Save to server; only shown when at least one selected file is local-only. */
|
||||
onSaveToServer?: (files: StirlingFileStub[]) => void;
|
||||
/** When set, Save to server renders disabled with this tooltip (storage off). */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
}
|
||||
|
||||
export function FileDetailsPanel({
|
||||
@@ -51,6 +53,7 @@ export function FileDetailsPanel({
|
||||
onMove,
|
||||
onRemove,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileDetailsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { sharingEnabled } = useSharingEnabled();
|
||||
@@ -319,15 +322,34 @@ export function FileDetailsPanel({
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Button>
|
||||
{/* Save to server; shown when any selected file is local-only. */}
|
||||
{/* Save to server; shown when any selected file is local-only. When
|
||||
storage is off it stays visible but disabled with a tooltip (same
|
||||
treatment as Manage sharing above). */}
|
||||
{onSaveToServer && localOnlyFiles.length > 0 && (
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
multiline
|
||||
w={260}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
variant="default"
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={() => onSaveToServer(localOnlyFiles)}
|
||||
styles={{
|
||||
root: {
|
||||
// Keep tooltip hoverable while button is disabled.
|
||||
pointerEvents: saveToServerDisabledReason
|
||||
? "auto"
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<DeleteIcon fontSize="small" />}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { findFolderIcon } from "@app/components/filesPage/folderIcons";
|
||||
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
|
||||
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
|
||||
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
|
||||
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
|
||||
|
||||
export type FilesPageViewMode = "grid" | "list";
|
||||
|
||||
@@ -77,6 +78,8 @@ interface FileGridProps {
|
||||
onPromptMoveFiles: (fileIds: FileId[]) => void;
|
||||
/** Per-file Save to server; hidden when file already has remoteStorageId. */
|
||||
onSaveToServer?: (file: StirlingFileStub) => void;
|
||||
/** When set, the Save to server item renders disabled with this tooltip. */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
/** When supplied the list-view column headers become sortable. */
|
||||
sortMode?: FilesPageSortMode;
|
||||
onChangeSortMode?: (mode: FilesPageSortMode) => void;
|
||||
@@ -333,6 +336,7 @@ function GridView({
|
||||
onRemoveFiles,
|
||||
onPromptMoveFiles,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileGridProps) {
|
||||
return (
|
||||
<div className="files-page-grid" role="list">
|
||||
@@ -385,6 +389,7 @@ function GridView({
|
||||
onSaveToServer={
|
||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||
}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -583,6 +588,8 @@ interface FileCardProps {
|
||||
onMove: () => void;
|
||||
/** Kebab Save to server; only fires when file is local-only. */
|
||||
onSaveToServer?: () => void;
|
||||
/** When set, the kebab Save to server is disabled with this tooltip. */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
}
|
||||
|
||||
function FileCard({
|
||||
@@ -598,6 +605,7 @@ function FileCard({
|
||||
onRemove,
|
||||
onMove,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
@@ -758,6 +766,7 @@ function FileCard({
|
||||
>
|
||||
{t("filesPage.quickView", "Quick view")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
@@ -767,17 +776,33 @@ function FileCard({
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; hidden when already on server. */}
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
@@ -811,6 +836,7 @@ function ListView({
|
||||
onRenameFolder,
|
||||
onDeleteFolder,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
onChangeFolderAppearance,
|
||||
onRemoveFiles,
|
||||
onPromptMoveFiles,
|
||||
@@ -944,6 +970,7 @@ function ListView({
|
||||
onSaveToServer={
|
||||
onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
|
||||
}
|
||||
saveToServerDisabledReason={saveToServerDisabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1146,6 +1173,8 @@ interface FileRowProps {
|
||||
onMove: () => void;
|
||||
/** Kebab Save to server; only fires when file is local-only. */
|
||||
onSaveToServer?: () => void;
|
||||
/** When set, the kebab Save to server is disabled with this tooltip. */
|
||||
saveToServerDisabledReason?: string | null;
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
@@ -1161,6 +1190,7 @@ function FileRow({
|
||||
onRemove,
|
||||
onMove,
|
||||
onSaveToServer,
|
||||
saveToServerDisabledReason,
|
||||
}: FileRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const kebabRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -1324,6 +1354,7 @@ function FileRow({
|
||||
>
|
||||
{t("filesPage.quickView", "Quick view")}
|
||||
</Menu.Item>
|
||||
<OpenInNewWindowMenuItem file={file} />
|
||||
<Menu.Item
|
||||
leftSection={<DriveFileMoveIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
@@ -1333,17 +1364,33 @@ function FileRow({
|
||||
>
|
||||
{t("filesPage.moveTo", "Move to…")}
|
||||
</Menu.Item>
|
||||
{/* Per-file Save to server; hidden when already on server. */}
|
||||
{/* Per-file Save to server; shown for local-only files. When
|
||||
storage is off it stays visible but disabled with a tooltip. */}
|
||||
{onSaveToServer && file.remoteStorageId == null && (
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
<Tooltip
|
||||
label={saveToServerDisabledReason}
|
||||
disabled={!saveToServerDisabledReason}
|
||||
withinPortal
|
||||
position="left"
|
||||
multiline
|
||||
w={240}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<CloudUploadIcon fontSize="small" />}
|
||||
disabled={Boolean(saveToServerDisabledReason)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSaveToServer();
|
||||
}}
|
||||
style={
|
||||
saveToServerDisabledReason
|
||||
? { pointerEvents: "auto" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("filesPage.saveToServer", "Save to server")}
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user