Compare commits

..
131 changed files with 1194 additions and 6628 deletions
+27 -124
View File
@@ -1,9 +1,8 @@
name: AI Engine CI
# Validates the Python AI engine: regenerates tool models and runs the
# engine quality gate (lint, type-check, format-check, tests). Called from
# build.yml on PRs and merge_group; also runs directly on push to main as
# a post-merge safety net.
# 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.
on:
workflow_call:
push:
@@ -52,95 +51,27 @@ 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: |
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
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
- 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: Run fixers
run: task engine:fix
- name: Quality-check engine
id: engine-check
run: task engine:check
continue-on-error: true
- 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: 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'
- name: Comment on fixer failures
if: steps.fixer_changes.outcome == 'failure' && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
@@ -176,39 +107,11 @@ jobs:
});
}
- 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 linting
run: task engine:lint
- 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,
});
}
- name: Run type checking
run: task engine:typecheck
- name: Run tests
run: task engine:test
+15 -32
View File
@@ -67,7 +67,7 @@ jobs:
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
- name: Comment on backend format check failure
- name: Comment on Java formatting 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,11 +78,15 @@ jobs:
const marker = '<!-- java-formatting-check -->';
const body = [
marker,
'### Backend Format Check Failed',
'### Java Formatting Check Failed',
'',
'There are formatting issues in your Java code that will need to be fixed before they can be merged in.',
'Your code has formatting issues. Run the following command to fix them:',
'',
'Run `task backend:format` to auto-fix, then commit and push the changes.',
'```bash',
'task backend:format',
'```',
'',
'Then commit and push the changes.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
@@ -106,43 +110,22 @@ jobs:
});
}
- name: Fail if backend format check failed
- name: Fail if Java formatting issues found
if: steps.spotless-check.outcome == 'failure'
run: |
echo "============================================"
echo " Backend Format Check Failed"
echo " Java Formatting Check Failed"
echo "============================================"
echo ""
echo "There are formatting issues in your Java code"
echo "that will need to be fixed before they can be"
echo "merged in."
echo "Your code has formatting issues."
echo "Run the following command to fix them:"
echo ""
echo "Run 'task backend:format' to auto-fix, then"
echo "commit and push the changes."
echo " task backend:format"
echo ""
echo "Then 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:
+2 -3
View File
@@ -22,13 +22,12 @@ 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 AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+1 -1
View File
@@ -91,7 +91,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: frontend:dev
- task: frontend:dev:prototypes
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
@@ -77,10 +77,6 @@ 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 Integer.MIN_VALUE;
int resourceWeight() default 1;
}
@@ -80,7 +80,6 @@ public class ConfigInitializer {
YamlHelper settingsFile = new YamlHelper(settingTempPath);
migrateEnterpriseEditionToPremium(settingsFile, settingsTemplateFile);
migrateProFeaturesKeyCasing(settingsFile, settingsTemplateFile);
boolean changesMade =
settingsTemplateFile.updateValuesFromYaml(settingsFile, settingsTemplateFile);
@@ -117,52 +116,31 @@ 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);
}
}
}
}
@@ -1,95 +0,0 @@
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,8 +2,6 @@ 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;
@@ -14,47 +12,9 @@ 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);
@@ -32,7 +32,6 @@ 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;
@@ -76,10 +75,7 @@ public class EditTextController {
new StringToArrayListPropertyEditor<>(EditTextOperation.class));
}
@AutoJobPostMapping(
consumes = "multipart/form-data",
value = "/edit-text",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text")
@StandardPdfResponse
@Operation(
summary = "Edit text in a PDF via find and replace",
@@ -275,10 +275,7 @@ public class ConvertImgPDFController {
GeneralUtils.generateFilename(file[0].getOriginalFilename(), "_converted.pdf"));
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/cbz/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf")
@Operation(
summary = "Convert CBZ comic book archive to PDF",
description =
@@ -304,10 +301,7 @@ public class ConvertImgPDFController {
return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/cbz",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz")
@Operation(
summary = "Convert PDF to CBZ comic book archive",
description =
@@ -330,10 +324,7 @@ public class ConvertImgPDFController {
return WebResponseUtils.zipFileToWebResponse(cbzFile, filename);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/cbr/pdf",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf")
@Operation(
summary = "Convert CBR comic book archive to PDF",
description =
@@ -359,10 +350,7 @@ public class ConvertImgPDFController {
return WebResponseUtils.bytesToWebResponse(pdfBytes, filename);
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/cbr",
resourceWeight = ResourceWeight.LARGE_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr")
@Operation(
summary = "Convert PDF to CBR comic book archive",
description =
@@ -141,8 +141,7 @@ public class AttachmentController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/extract-attachments",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/extract-attachments")
@Operation(
summary = "Extract attachments from PDF",
description =
@@ -177,10 +176,7 @@ public class AttachmentController {
}
}
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/list-attachments",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments")
@Operation(
summary = "List attachments in PDF",
description =
@@ -197,8 +193,7 @@ public class AttachmentController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/rename-attachment",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/rename-attachment")
@StandardPdfResponse
@Operation(
summary = "Rename attachment in PDF",
@@ -233,8 +228,7 @@ public class AttachmentController {
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/delete-attachment",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
value = "/delete-attachment")
@StandardPdfResponse
@Operation(
summary = "Delete attachment from PDF",
@@ -307,9 +307,6 @@ 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,7 +13,6 @@ 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.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
@@ -24,10 +23,7 @@ public class ConvertPDFToMarkdown {
private final TempFileManager tempFileManager;
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/pdf/markdown",
resourceWeight = ResourceWeight.MEDIUM_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/markdown")
@MarkdownConversionResponse
@Operation(
summary = "Convert PDF to Markdown",
@@ -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
@@ -1,132 +0,0 @@
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(", "));
}
}
@@ -1,93 +0,0 @@
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"));
}
}
}
@@ -11,7 +11,7 @@ import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@Schema(description = "Run an AI workflow")
@Schema(description = "Run an AI workflow against one or more PDF files")
public class AiWorkflowRequest {
@NotNull
@@ -17,7 +17,6 @@ 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;
@@ -40,10 +39,7 @@ public class EmailController {
* attachment.
* @return ResponseEntity with success or error message.
*/
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/send-email",
resourceWeight = ResourceWeight.SMALL_WEIGHT)
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/send-email")
@Operation(
summary = "Send an email with an attachment",
description =
@@ -5,24 +5,18 @@ 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. Any new
* package holding {@code @Repository} or {@code @Entity} classes must be added here, or the beans
* won't wire at startup.
*/
/** Registers the {@code :saas} module's entities and repositories with Spring Data JPA. */
@Configuration
@Profile("saas")
@EnableJpaRepositories(
basePackages = {
"stirling.software.saas.repository",
"stirling.software.saas.billing.repository",
"stirling.software.saas.ai.repository",
"stirling.software.saas.payg.repository"
"stirling.software.saas.ai.repository"
})
@EntityScan({
"stirling.software.saas.model",
"stirling.software.saas.billing.model",
"stirling.software.saas.ai.model",
"stirling.software.saas.payg"
"stirling.software.saas.ai.model"
})
public class SaasJpaConfig {}
@@ -73,13 +73,6 @@ 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;
}
@@ -1,173 +0,0 @@
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) {}
}
@@ -1,25 +0,0 @@
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);
}
@@ -1,12 +0,0 @@
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) {}
@@ -1,112 +0,0 @@
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);
}
}
}
@@ -1,82 +0,0 @@
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);
}
}
}
@@ -1,99 +0,0 @@
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<>();
}
@@ -1,62 +0,0 @@
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;
}
@@ -1,7 +0,0 @@
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
}
@@ -1,10 +0,0 @@
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
}
@@ -1,8 +0,0 @@
package stirling.software.saas.payg.model;
public enum CapPeriod {
CALENDAR_MONTH,
CALENDAR_QUARTER,
CALENDAR_YEAR,
BILLING_CYCLE
}
@@ -1,7 +0,0 @@
package stirling.software.saas.payg.model;
public enum EntitlementState {
FULL,
WARNED,
DEGRADED
}
@@ -1,9 +0,0 @@
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
}
@@ -1,8 +0,0 @@
package stirling.software.saas.payg.model;
/** Bundles of {@link FeatureGate}s exposed at the team / member level. */
public enum FeatureSet {
FULL,
MINIMAL,
CLIENT_ONLY
}
@@ -1,19 +0,0 @@
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
}
@@ -1,9 +0,0 @@
package stirling.software.saas.payg.model;
public enum JobStatus {
OPEN,
CLOSED,
REFUNDED,
PARTIAL_REFUND,
FAILED
}
@@ -1,7 +0,0 @@
package stirling.software.saas.payg.model;
public enum JobStepStatus {
OK,
FAILED,
SKIPPED
}
@@ -1,8 +0,0 @@
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
}
@@ -1,11 +0,0 @@
package stirling.software.saas.payg.model;
public enum LedgerEntryType {
CYCLE_GRANT,
DEBIT,
REFUND,
EXPIRE,
OVERAGE_REPORTED,
ADJUSTMENT,
LEGACY_BACKFILL
}
@@ -1,11 +0,0 @@
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
}
@@ -1,9 +0,0 @@
package stirling.software.saas.payg.model;
/** What a {@code wallet_ledger.reference_id} points at. */
public enum ReferenceType {
JOB,
INVOICE,
STRIPE_EVENT,
ADMIN
}
@@ -1,8 +0,0 @@
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
}
@@ -1,77 +0,0 @@
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();
}
}
@@ -1,147 +0,0 @@
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;
}
}
@@ -1,41 +0,0 @@
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);
}
@@ -1,22 +0,0 @@
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);
}
@@ -1,14 +0,0 @@
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);
}
@@ -1,16 +0,0 @@
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.PricingPolicy;
@Repository
public interface PricingPolicyRepository extends JpaRepository<PricingPolicy, Long> {
Optional<PricingPolicy> findByVersion(String version);
Optional<PricingPolicy> findFirstByIsDefaultTrue();
}
@@ -1,26 +0,0 @@
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);
}
@@ -1,15 +0,0 @@
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);
}
@@ -1,26 +0,0 @@
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));
}
}
@@ -1,50 +0,0 @@
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);
}
@@ -1,14 +0,0 @@
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);
}
@@ -1,61 +0,0 @@
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;
}
@@ -1,86 +0,0 @@
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<>();
}
@@ -1,94 +0,0 @@
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,8 +15,6 @@ 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;
@@ -57,7 +55,6 @@ public class CreditService {
private final Counter creditsConsumedCounter;
private final Counter creditConsumptionFailuresCounter;
private final Counter cycleResetCounter;
private final Counter stripeReportFailuresCounter;
public CreditService(
UserCreditRepository userCreditRepository,
@@ -93,10 +90,6 @@ 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)
@@ -303,8 +296,7 @@ public class CreditService {
return true;
}
} else {
// Partial or full overage: consume free credits in this tx, report the overage
// to Stripe after commit (see scheduleStripeReportAfterCommit).
// Partial or full overage: consume free credits and report overage to Stripe
int freeCreditsUsed =
userCredits.getCycleCreditsRemaining() != null
? userCredits.getCycleCreditsRemaining()
@@ -336,27 +328,55 @@ 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);
scheduleStripeReportAfterCommit(
log.info(
"[CREDIT-CONSUME] Calling Stripe reporting service - User: {}, Overage credits: {}, Idempotency key: {}",
supabaseId,
overageCredits,
idempotencyKey,
creditAmount,
freeCreditsUsed);
return true;
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.");
}
}
// 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();
// 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);
return false;
}
@@ -391,6 +411,17 @@ 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,
@@ -420,87 +451,6 @@ 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);
@@ -1104,23 +1054,48 @@ public class CreditService {
// STEP 4: Try metered billing (check flag, not role)
if (saasUserExtensionService.isMeteredBillingEnabled(user)) {
log.info(
"[WATERFALL] User {} has metered billing enabled; scheduling {} credits for"
+ " Stripe report (after commit)",
"[WATERFALL] User {} has metered billing enabled; reporting {} credits to Stripe",
user.getUsername(),
creditAmount);
String operationId = MDC.get("requestId");
String idempotencyKey =
stripeUsageReportingService.generateIdempotencyKey(
supabaseId.toString(), creditAmount, operationId);
try {
String operationId = MDC.get("requestId");
if (operationId == null || operationId.isBlank()) {
operationId = UUID.randomUUID().toString();
}
String idempotencyKey =
stripeUsageReportingService.generateIdempotencyKey(
supabaseId.toString(), creditAmount, operationId);
scheduleStripeReportAfterCommit(
supabaseId.toString(),
creditAmount,
idempotencyKey,
creditAmount,
/* freeCreditsUsed= */ 0);
return CreditConsumptionResult.success("METERED_SUBSCRIPTION");
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());
}
} else if (user.getRolesAsString().contains("ROLE_PRO_USER")) {
// Pro user without metered billing enabled; reject with helpful message
log.warn(
@@ -1,226 +0,0 @@
-- 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);
@@ -1,60 +0,0 @@
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);
}
}
@@ -1,231 +0,0 @@
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());
}
}
@@ -1,151 +0,0 @@
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();
}
}
@@ -1,109 +0,0 @@
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();
}
}
@@ -0,0 +1,132 @@
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;
}
}
}
@@ -1,62 +0,0 @@
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");
}
}
@@ -2684,59 +2684,6 @@ title = "Change Permissions"
[changePermissions.tooltip.warning]
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
[agents]
section_title = "Agents"
fullscreen_title = "Stirling Agents"
stirling_name = "Stirling"
stirling_full_name = "Stirling General Agent"
stirling_tooltip = "Stirling agent"
stirling_description = "Your general-purpose PDF assistant"
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
back_to_tools = "Back to tools"
coming_soon = "Coming soon"
view_all = "View all agents"
show_less = "Show less"
start_chat = "Start chatting"
data_extraction_name = "Data Extraction"
data_extraction_description = "Extract tables & structured data"
doc_summary_name = "Summariser"
doc_summary_description = "Summarise long documents"
auto_redaction_name = "Auto Redaction"
auto_redaction_description = "Redact PII automatically"
compliance_name = "Compliance Check"
compliance_description = "Audit documents for compliance"
form_filler_name = "Form Filler"
form_filler_description = "Fill PDF forms intelligently"
pdf_to_markdown_name = "PDF to Markdown"
pdf_to_markdown_description = "Convert PDFs to clean Markdown"
[chat.header]
settings = "Agent settings"
agentMenu = "Stirling agent options"
clearChat = "Clear chat"
[chat.input]
placeholder = "What do you want to do?"
send = "Send message"
attach = "Attach files"
[chat.quickActions]
heading = "Get started"
openFromComputer = "Open from computer"
browseYourFiles = "Browse your files"
rotateOne = "Rotate this document"
rotateMany = "Rotate these documents"
compressOne = "Compress this document"
compressMany = "Compress these documents"
mergeMany = "Merge these {{count}} documents into 1"
splitOne = "Split this document"
convertOne = "Convert this document to PDF"
convertMany = "Convert these documents to PDF"
fileSummary_one = "1 file in workbench ({{types}})"
fileSummary_other = "{{count}} files in workbench ({{types}})"
moreFiles = "+{{count}} more"
removeFile = "Remove {{name}}"
[chat.progress]
analyzing = "Analysing your request..."
calling_engine = "AI is thinking..."
@@ -2752,15 +2699,6 @@ 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.responses]
done = "Done."
need_clarification = "Could you clarify your request?"
cannot_do = "I'm unable to do that."
not_found = "I couldn't find the requested information."
unsupported_capability = "Unsupported capability: {{capability}}"
cannot_continue = "Something went wrong and I can't continue."
processing = "Processing ({{outcome}})..."
[chat.toolsUsed]
summary = "Ran {{count}} tools"
summary_one = "Ran 1 tool"
@@ -8134,11 +8072,6 @@ expand = "Expand panel"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
search = "Search tools"
toolsHeader = "Tools"
viewAllTools = "View all tools"
backToDefault = "Back"
backToAllTools = "Back to all tools"
goBack = "Go back"
[toolPanel.fullscreen]
comingSoon = "Coming soon:"
@@ -1,53 +0,0 @@
/**
* 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;
}
@@ -27,12 +27,14 @@ import DownloadIcon from "@mui/icons-material/Download";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
interface UpdateModalProps {
export interface UpdateModalProps {
opened: boolean;
onClose: () => void;
currentVersion: string;
updateSummary: UpdateSummary;
machineInfo: MachineInfo;
isDesktop?: boolean;
desktopVersion?: string | null;
}
const UpdateModal: React.FC<UpdateModalProps> = ({
@@ -41,6 +43,8 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
currentVersion,
updateSummary,
machineInfo,
isDesktop = false,
desktopVersion,
}) => {
const { t } = useTranslation();
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(
@@ -96,7 +100,19 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
return t(`update.priority.${key}`, priority || "Normal");
};
const downloadUrl = updateService.getDownloadUrl(machineInfo);
const desktopNeedsUpdate =
isDesktop && desktopVersion
? updateService.compareVersions(
updateSummary.latest_version || "0",
desktopVersion,
) > 0
: false;
const downloadUrl = isDesktop
? desktopNeedsUpdate
? updateService.getDownloadUrl(machineInfo, true)
: null
: updateService.getDownloadUrl(machineInfo);
return (
<Modal
@@ -504,7 +520,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
component="a"
href={downloadUrl}
target="_blank"
color="green"
variant="filled"
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
>
{t("update.downloadLatest", "Download Latest")}
@@ -39,12 +39,16 @@ interface GeneralSectionProps {
hideTitle?: boolean;
hideUpdateSection?: boolean;
hideAdminBanner?: boolean;
desktopVersion?: string | null;
isDesktop?: boolean;
}
const GeneralSection: React.FC<GeneralSectionProps> = ({
hideTitle = false,
hideUpdateSection = false,
hideAdminBanner = false,
desktopVersion,
isDesktop = false,
}) => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
@@ -272,11 +276,25 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
)}
<Group justify="space-between" align="center">
<div>
<Text size="sm" c="dimmed">
{t(
"settings.general.updates.currentBackendVersion",
"Current Backend Version",
)}
{desktopVersion && (
<Text size="sm" c="dimmed">
{t(
"settings.general.versionInfo.desktop",
"Desktop Version",
)}
:{" "}
<Text component="span" fw={500}>
{desktopVersion}
</Text>
</Text>
)}
<Text size="sm" c="dimmed" mt={desktopVersion ? 4 : 0}>
{desktopVersion
? t("settings.general.versionInfo.server", "Server Version")
: t(
"settings.general.updates.currentBackendVersion",
"Current Backend Version",
)}
:{" "}
<Text component="span" fw={500}>
{config.appVersion}
@@ -294,6 +312,33 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</Text>
)}
{isDesktop &&
desktopVersion &&
updateSummary &&
(() => {
const desktopNeedsUpdate =
updateService.compareVersions(
updateSummary.latest_version || "0",
desktopVersion,
) > 0;
const serverNeedsUpdate =
updateService.compareVersions(
updateSummary.latest_version || "0",
config.appVersion || "0",
) > 0;
if (!desktopNeedsUpdate && serverNeedsUpdate) {
return (
<Text size="sm" c="orange" mt={4} fw={500}>
{t(
"settings.general.updates.serverNeedsUpdate",
"Server needs to be updated by administrator",
)}
</Text>
);
}
return null;
})()}
</div>
<Group gap="sm">
<Button
@@ -315,22 +360,68 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
)}
</Button>
{updateSummary && (
<Button
size="sm"
color={
updateSummary.max_priority === "urgent" ? "red" : "blue"
}
onClick={() => setUpdateModalOpened(true)}
leftSection={
<LocalIcon
icon="system-update-alt-rounded"
width="1rem"
height="1rem"
/>
}
>
{t("settings.general.updates.viewDetails", "View Details")}
</Button>
<>
<Button
size="sm"
color={
updateSummary.max_priority === "urgent" ? "red" : "blue"
}
onClick={() => setUpdateModalOpened(true)}
leftSection={
<LocalIcon
icon="system-update-alt-rounded"
width="1rem"
height="1rem"
/>
}
>
{t(
"settings.general.updates.viewDetails",
"View Details",
)}
</Button>
{isDesktop &&
desktopVersion &&
(() => {
const desktopNeedsUpdate =
updateService.compareVersions(
updateSummary.latest_version || "0",
desktopVersion,
) > 0;
if (!desktopNeedsUpdate) {
return null;
}
const downloadUrl = updateService.getDownloadUrl(
{
machineType: config.machineType || "Unknown",
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? "NORMAL",
},
true,
);
return downloadUrl ? (
<Button
size="sm"
variant="filled"
component="a"
href={downloadUrl}
target="_blank"
leftSection={
<LocalIcon
icon="download-rounded"
width="1rem"
height="1rem"
/>
}
>
{t("update.downloadLatest", "Download Latest")}
</Button>
) : null;
})()}
</>
)}
</Group>
</Group>
@@ -1,102 +0,0 @@
import { useEffect, useMemo } from "react";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import {
AgentsFullscreenSection,
useAgentChatOpen,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolId } from "@app/types/toolId";
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
/** Derives whether the fullscreen tool picker is currently expanded. */
export function useIsFullscreenExpanded(): boolean {
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
const isMobile = useIsMobile();
const agentChatOpen = useAgentChatOpen();
return (
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen
);
}
interface FullscreenToolPanelProps {
geometry: ToolPanelGeometry | null;
}
/**
* Self-contained fullscreen tool picker. Renders null when inactive, and takes
* over the right rail (via FullscreenToolSurface) when fullscreen mode is on.
* Geometry is computed by the parent (RightSidebar) so its useLayoutEffect runs
* after the ref div is committed, ensuring toolPanelRef.current is always set.
*/
export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
const {
toolPanelMode,
setToolPanelMode,
leftPanelView,
readerMode,
searchQuery,
setSearchQuery,
filteredTools,
toolRegistry,
selectedToolKey,
handleToolSelect,
} = useToolWorkflow();
const isMobile = useIsMobile();
const agentsEnabled = useAgentsEnabled();
const agentChatOpen = useAgentChatOpen();
const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences();
const fullscreenExpanded =
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen;
useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllButtonsDisabled]);
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) map.set(id, matchedText);
});
return map;
}, [filteredTools]);
if (!fullscreenExpanded) return null;
return (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={preferences.showLegacyToolDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() =>
updatePreference(
"showLegacyToolDescriptions",
!preferences.showLegacyToolDescriptions,
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={geometry}
agentsSlot={
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
}
/>
);
}
@@ -1,5 +1,4 @@
import { useRef } from "react";
import { createPortal } from "react-dom";
import { ScrollArea, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
@@ -7,6 +6,8 @@ import FullscreenToolList from "@app/components/tools/FullscreenToolList";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { ToolId } from "@app/types/toolId";
import { useFocusTrap } from "@app/hooks/useFocusTrap";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import "@app/components/tools/ToolPanel.css";
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
@@ -25,8 +26,6 @@ interface FullscreenToolSurfaceProps {
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
geometry: ToolPanelGeometry | null;
/** Optional agents block rendered above the tool list. */
agentsSlot?: React.ReactNode;
}
const FullscreenToolSurface = ({
@@ -41,7 +40,6 @@ const FullscreenToolSurface = ({
onToggleDescriptions,
onExitFullscreenMode: _onExitFullscreenMode,
geometry,
agentsSlot,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const surfaceRef = useRef<HTMLDivElement>(null);
@@ -49,16 +47,18 @@ const FullscreenToolSurface = ({
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, true);
if (!geometry) return null;
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const style = {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
};
const style = geometry
? {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
}
: undefined;
const surface = (
return (
<div
className="tool-panel__fullscreen-surface"
style={style}
@@ -70,6 +70,16 @@ const FullscreenToolSurface = ({
data-tour="tool-panel"
>
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
<header className="tool-panel__fullscreen-header">
<div className="tool-panel__fullscreen-brand">
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
<Wordmark
alt={brandAltText}
className="tool-panel__fullscreen-brand-text"
/>
</div>
</header>
<div className="tool-panel__fullscreen-controls">
<ToolSearch
value={searchQuery}
@@ -92,9 +102,6 @@ const FullscreenToolSurface = ({
className="tool-panel__fullscreen-scroll"
offsetScrollbars
>
{agentsSlot && (
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
)}
<FullscreenToolList
filteredTools={filteredTools}
searchQuery={searchQuery}
@@ -108,9 +115,6 @@ const FullscreenToolSurface = ({
</div>
</div>
);
if (typeof document === "undefined") return surface;
return createPortal(surface, document.body);
};
export default FullscreenToolSurface;
@@ -1,333 +0,0 @@
import { useMemo, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import {
AgentsChatOverlay,
AgentsCollapsedButton,
AgentsSection,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolIcon } from "@app/components/shared/ToolIcon";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import CloseIcon from "@mui/icons-material/Close";
import { ToolId } from "@app/types/toolId";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
FullscreenToolPanel,
useIsFullscreenExpanded,
} from "@app/components/tools/FullscreenToolPanel";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import "@app/components/tools/ToolPanel.css";
/**
* Right-side rail wrapping the tool panel.
*
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
*/
export default function RightSidebar() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef } = sidebarRefs;
const isMobile = useIsMobile();
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
handleToolSelect,
handleBackToTools,
setLeftPanelView,
setReaderMode,
setSidebarsVisible,
sidebarsVisible,
readerMode,
favoriteTools,
} = useToolWorkflow();
const agentsEnabled = useAgentsEnabled();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
});
const handleExpand = () => {
withViewTransition(() => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
});
};
const handleCollapse = () => {
withViewTransition(() => setLeftPanelView("hidden"));
};
const [allToolsView, setAllToolsView] = useState(false);
const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true));
};
const handleBackToDefault = () => {
withViewTransition(() => {
setAllToolsView(false);
setSearchQuery("");
});
};
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Show search input whenever there's a close button, or when agents are off and
// we're in the default tool-picker view (search filters the full list inline).
const showHeaderSearch =
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
const handleHeaderBack = () => {
if (inToolView) {
withViewTransition(() => handleBackToTools());
} else {
handleBackToDefault();
}
};
const handleToolSelectWithTransition = (id: ToolId) => {
withViewTransition(() => handleToolSelect(id));
};
// Typing in the header search while inside a tool exits the tool and lifts the
// panel into the all-tools view so the user immediately sees search results.
const handleHeaderSearchChange = (value: string) => {
if (inToolView) {
withViewTransition(() => {
handleBackToTools();
setAllToolsView(true);
setSearchQuery(value);
});
return;
}
setSearchQuery(value);
};
const activeTool: ToolRegistryEntry | null =
inToolView && selectedToolKey
? (toolRegistry[selectedToolKey as ToolId] ?? null)
: null;
// Agents header + section is hidden when:
// - the AI engine is off, or
// - the user is in the all-tools view, or
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
const computedWidth = () => {
if (isMobile) return "100%";
if (!isPanelVisible) return "3.5rem";
return "18.5rem";
};
// Collapsed rail: show favourites + recommended tools as icons.
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const { sections: collapsedSections } = useToolSections(filteredTools);
const collapsedQuickSection = useMemo(
() => collapsedSections.find((s) => s.key === "quick"),
[collapsedSections],
);
const collapsedRecommendedItems = useMemo(() => {
if (!collapsedQuickSection) return [];
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) =>
sc.tools.forEach((entry) =>
items.push({ id: entry.id as ToolId, tool: entry.tool }),
),
);
return items;
}, [collapsedQuickSection]);
const collapsedRailItems = useMemo(() => {
const map = new Map<ToolId, ToolRegistryEntry>();
favoriteToolItems.forEach(({ id, tool }) => map.set(id, tool));
collapsedRecommendedItems.forEach(({ id, tool }) => {
if (!map.has(id)) map.set(id, tool);
});
return Array.from(map, ([id, tool]) => ({ id, tool }));
}, [favoriteToolItems, collapsedRecommendedItems]);
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
}}
>
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<div className="tool-panel__collapsed-top">
<ActionIcon
variant="outline"
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<AgentsCollapsedButton onExpand={handleExpand} />
</div>
<div className="tool-panel__collapsed-divider" />
<div className="tool-panel__collapsed-tools">
{collapsedRailItems.map(({ id, tool }) => (
<AppTooltip
key={id}
content={tool.name}
position="left"
arrow
delay={300}
>
<button
type="button"
className="tool-panel__collapsed-tool-btn"
data-selected={selectedToolKey === id}
onClick={() => {
handleExpand();
handleToolSelectWithTransition(id);
}}
aria-label={tool.name}
>
<ToolIcon icon={tool.icon} marginRight="0" />
</button>
</AppTooltip>
))}
</div>
</div>
)}
{!fullscreenExpanded && isPanelVisible && (
<div
/* Fixed width matches the expanded panel width so the inner content is
laid out at its final size from the moment it mounts. The outer
.tool-panel clips it (overflow-hidden) while it animates from the
collapsed 3.5rem width — text/icons stay put and just come into view
instead of jiggling as space becomes available. */
style={{
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
width: isMobile ? "100%" : "18.5rem",
flexShrink: 0,
display: "flex",
flexDirection: "column",
}}
>
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
{showAgents && <AgentsSection />}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
<AgentsChatOverlay />
</div>
)}
<FullscreenToolPanel geometry={fullscreenGeometry} />
</div>
);
}
@@ -129,70 +129,17 @@
transition:
width 0.3s ease,
max-width 0.3s ease;
view-transition-name: tool-rail;
}
.tool-panel__collapsed-strip {
display: flex;
flex-direction: column;
align-items: stretch;
align-items: center;
justify-content: flex-start;
padding-top: 10px;
gap: 8px;
height: 100%;
width: 100%;
min-height: 0;
}
.tool-panel__collapsed-top {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 0 0 10px;
}
.tool-panel__collapsed-divider {
height: 1px;
background: var(--border-subtle);
margin: 0 0.5rem 8px;
}
.tool-panel__collapsed-tools {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 0 12px;
overflow-y: auto;
overflow-x: hidden;
flex: 1 1 auto;
min-height: 0;
}
.tool-panel__collapsed-tool-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid transparent;
border-radius: 0.5rem;
background: transparent;
color: var(--tools-text-and-icon-color);
cursor: pointer;
transition:
background 120ms ease-out,
border-color 120ms ease-out;
padding: 0;
flex-shrink: 0;
}
.tool-panel__collapsed-tool-btn:hover {
background: var(--mantine-color-default-hover);
}
.tool-panel__collapsed-tool-btn[data-selected="true"] {
background: var(--mantine-color-default-hover);
border-color: var(--mantine-color-default-border);
}
.tool-panel__expand-btn {
@@ -227,11 +174,6 @@
flex-shrink: 0;
}
.tool-panel__back-bar {
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle) !important;
}
.tool-panel--fullscreen-active {
overflow: visible !important;
}
@@ -249,128 +191,6 @@
flex: 1 1 auto;
}
.tool-panel__compact-header {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 52px;
padding: 0.5rem 0.75rem;
box-sizing: border-box;
}
.tool-panel__compact-header .tool-panel__expand-btn {
margin-left: auto;
}
.tool-panel__compact-header-search {
flex: 1 1 auto;
min-width: 0;
}
.tool-panel__compact-header-search .search-input-container {
width: 100%;
}
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 auto;
min-width: 0;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--border-subtle);
border-radius: 9999px;
background: var(--mantine-color-body);
}
.tool-panel__active-tool-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill-icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
them so the icon glyph sits dead-centre in the circular background. */
.tool-panel__active-tool-pill-icon .tool-button-icon {
margin: 0 !important;
transform: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.tool-panel__active-tool-pill-icon svg {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
.tool-panel__active-tool-pill-label {
font-size: 0.9rem;
font-weight: 600;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
::view-transition-old(tool-rail) {
animation: vt-rail-fade-out 180ms ease-out forwards;
}
::view-transition-new(tool-rail) {
animation: vt-rail-fade-in 220ms ease-in forwards;
}
@keyframes vt-rail-fade-out {
to {
opacity: 0;
}
}
@keyframes vt-rail-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(tool-rail),
::view-transition-new(tool-rail) {
animation-duration: 1ms !important;
}
}
.tool-panel__section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
line-height: 1;
}
.tool-panel__mode-toggle {
transition: transform 0.2s ease;
}
@@ -832,33 +652,6 @@
gap: 0.75rem;
}
/* Agents card lives in its own column above the tool grid. It uses the same
.tool-panel__fullscreen-group base styles but with a colourful gradient
border so it stands out as a distinct surface (and doesn't blend into the
neighbouring category cards). */
.tool-panel__fullscreen-agents {
padding: 1.5rem 1.75rem 0;
}
.tool-panel__fullscreen-group--agents {
position: relative;
background:
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
border: 1.5px solid transparent;
}
.tool-panel__fullscreen-section-icon--agents {
color: var(--mantine-color-blue-6);
}
@keyframes tool-panel-fullscreen-slide-in {
from {
transform: translateX(6%) scaleX(0.85);
@@ -1,85 +1,303 @@
import { ScrollArea } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import {
useToolWorkflow,
useToolWorkflowActions,
} from "@app/contexts/ToolWorkflowContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
import ToolPicker from "@app/components/tools/ToolPicker";
import SearchResults from "@app/components/tools/SearchResults";
import ToolRenderer from "@app/components/tools/ToolRenderer";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
import { ToolId } from "@app/types/toolId";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SearchIcon from "@mui/icons-material/Search";
import "@app/components/tools/ToolPanel.css";
interface ToolPanelProps {
/** Whether to expand into the full categorised tools view (with search). */
allToolsView: boolean;
/** Trigger to enter the all-tools view (from the "View all tools" button). */
onShowAllTools: () => void;
/**
* Tool-selection handler injected by {@code RightSidebar} so the click can
* be wrapped in a View Transition (the tool card morphs into the header
* pill). Falls back to the workflow context's handler when not provided.
*/
onToolSelect?: (id: ToolId) => void;
/** Whether to render the compact (favourites + recommended only) view. */
compact?: boolean;
}
// No props needed - component uses context
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
export default function ToolPanel({
allToolsView,
onShowAllTools,
onToolSelect,
compact: compactProp,
}: ToolPanelProps) {
export default function ToolPanel() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef } = sidebarRefs;
const isMobile = useIsMobile();
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
handleToolSelect,
setPreviewFile,
toolPanelMode,
sidebarsVisible,
readerMode,
} = useToolWorkflow();
const selectTool = onToolSelect ?? handleToolSelect;
const {
handleToolSelect,
handleBackToTools,
setPreviewFile,
setToolPanelMode,
setLeftPanelView,
setReaderMode,
setSidebarsVisible,
} = useToolWorkflowActions();
const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences();
const isFullscreenMode = toolPanelMode === "fullscreen";
const toolPickerVisible = !readerMode;
const fullscreenExpanded =
isFullscreenMode &&
leftPanelView === "toolPicker" &&
!isMobile &&
toolPickerVisible;
// Disable workbench bar buttons when fullscreen mode is active
useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllButtonsDisabled]);
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
});
const handleExpand = () => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
};
const handleCollapse = () => {
setLeftPanelView("hidden");
};
const [focusSearch, setFocusSearch] = useState(false);
const focusSearchOnNextOpen = useRef(false);
const handleExpandAndSearch = () => {
focusSearchOnNextOpen.current = true;
handleExpand();
};
// Once the panel becomes visible, consume the focus-search request
useEffect(() => {
if (isPanelVisible && focusSearchOnNextOpen.current) {
focusSearchOnNextOpen.current = false;
setFocusSearch(true);
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
const id = setTimeout(() => setFocusSearch(false), 100);
return () => clearTimeout(id);
}
}, [isPanelVisible]);
const computedWidth = () => {
if (isMobile) {
return "100%";
}
if (!isPanelVisible) {
return "3.5rem";
}
return "18.5rem";
};
const handleSelect = useCallback(
(id: string) => handleToolSelect(id as ToolId),
[handleToolSelect],
);
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) {
map.set(id, matchedText);
}
});
return map;
}, [filteredTools]);
return (
<>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
{allToolsView && searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => selectTool(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === "toolPicker" ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => selectTool(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
compact={compactProp ?? !allToolsView}
onShowAllTools={onShowAllTools}
/>
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t("toolPanel.placeholder", "Choose a tool to get started")}
</div>
)}
</ScrollArea>
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
}}
>
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<ActionIcon
variant="outline"
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
size="md"
className="tool-panel__collapsed-search-btn"
onClick={handleExpandAndSearch}
aria-label={t("toolPanel.search", "Search tools")}
style={{ marginTop: "8px" }}
>
<SearchIcon sx={{ fontSize: "1.25rem" }} />
</ActionIcon>
</div>
)}
</>
{!fullscreenExpanded && isPanelVisible && (
<div
style={{
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
<div
className="tool-panel__search-row"
style={{
backgroundColor: "transparent",
borderBottom: "1px solid var(--border-subtle)",
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={focusSearch}
/>
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
style={{ flexShrink: 0 }}
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
</div>
{searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={handleSelect}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === "toolPicker" ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={handleSelect}
filteredTools={filteredTools}
isSearching={Boolean(
searchQuery && searchQuery.trim().length > 0,
)}
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div
style={{
borderBottom: "1px solid var(--border-subtle)",
flexShrink: 0,
}}
>
<Button
variant="light"
color="blue"
size="sm"
fullWidth
radius={0}
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
onClick={handleBackToTools}
aria-label={t("toolPanel.backToTools", "Back to tools")}
styles={{ root: { justifyContent: "flex-start" } }}
>
{t("toolPanel.backToTools", "Back to tools")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t(
"toolPanel.placeholder",
"Choose a tool to get started",
)}
</div>
)}
</ScrollArea>
</div>
</div>
)}
</div>
)}
{fullscreenExpanded && (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={preferences.showLegacyToolDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() =>
updatePreference(
"showLegacyToolDescriptions",
!preferences.showLegacyToolDescriptions,
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={fullscreenGeometry}
/>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import React, { useMemo, useRef } from "react";
import { Box, Button, Stack } from "@mantine/core";
import React, { memo, useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import "@app/components/tools/toolPicker/ToolPicker.css";
@@ -22,10 +22,6 @@ interface ToolPickerProps {
matchedText?: string;
}>;
isSearching?: boolean;
/** Compact "resting" view: favourites + recommended only, with a button to expand. */
compact?: boolean;
/** Called when the user clicks "View all tools" in compact mode. */
onShowAllTools?: () => void;
}
const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
@@ -61,8 +57,6 @@ const ToolPicker = ({
onSelect,
filteredTools,
isSearching = false,
compact = false,
onShowAllTools,
}: ToolPickerProps) => {
const { t } = useTranslation();
@@ -124,60 +118,9 @@ const ToolPicker = ({
)
)}
</Stack>
) : compact ? (
/* Resting state: flat list of pinned + recommended only. */
<Box className="tool-picker__compact">
<div style={HEADER_TEXT_STYLE}>
{t("toolPanel.toolsHeader", "Tools")}
</div>
{favoriteToolItems.length === 0 && recommendedItems.length === 0 ? (
<NoToolsFound />
) : (
<div className="tool-picker__compact-list">
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
showDescription
/>
))}
{recommendedItems
.filter(
({ id }) => !favoriteToolItems.some((fav) => fav.id === id),
)
.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
showDescription
/>
))}
</div>
)}
{onShowAllTools && (
<Button
variant="subtle"
size="sm"
fullWidth
onClick={onShowAllTools}
className="tool-picker__view-all"
aria-label={t("toolPanel.viewAllTools", "View all tools")}
>
{t("toolPanel.viewAllTools", "View all tools")}
</Button>
)}
</Box>
) : (
<>
{/* All-tools view: favourites + recommended + all subcategories. */}
{/* Flat list: favorites and recommended first, then all subcategories */}
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
@@ -239,6 +182,7 @@ const ToolPicker = ({
{!quickSection && !allSection && <NoToolsFound />}
{/* bottom spacer to allow scrolling past the last row */}
<div aria-hidden style={{ height: 200 }} />
</>
)}
@@ -248,4 +192,4 @@ const ToolPicker = ({
);
};
export default ToolPicker;
export default memo(ToolPicker);
@@ -32,7 +32,6 @@ interface ToolButtonProps {
disableNavigation?: boolean;
matchedSynonym?: string;
hasStars?: boolean;
showDescription?: boolean;
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
onUnavailableClick?: () => void;
}
@@ -45,7 +44,6 @@ const ToolButton: React.FC<ToolButtonProps> = ({
disableNavigation = false,
matchedSynonym,
hasStars = false,
showDescription = false,
onUnavailableClick,
}) => {
const { t } = useTranslation();
@@ -185,14 +183,6 @@ const ToolButton: React.FC<ToolButtonProps> = ({
)}
{usesCloud && !visuallyUnavailable && <CloudBadge />}
</div>
{showDescription && tool.description && (
<span
className="tool-button__description"
style={{ opacity: visuallyUnavailable ? 0.25 : 1 }}
>
{tool.description}
</span>
)}
{matchedSynonym && (
<span
style={{
@@ -215,8 +205,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({
handleUnlessSpecialClick(e, () => handleClick(id));
};
const selectedBg = isSelected
? { backgroundColor: "var(--tool-button-selected-bg)" }
const selectedStyles = isSelected
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
: {};
const buttonElement = navProps ? (
@@ -237,7 +227,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedBg,
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -264,7 +254,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedBg,
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -289,6 +279,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
color: "var(--tools-text-and-icon-color)",
cursor: visuallyUnavailable ? "not-allowed" : undefined,
overflow: "visible",
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -55,15 +55,6 @@
flex: 1 1 auto;
}
/* Selected tool highlight — theme-aware via CSS variable */
:root {
--tool-button-selected-bg: var(--mantine-color-gray-2);
}
[data-mantine-color-scheme="dark"] {
--tool-button-selected-bg: var(--mantine-color-dark-4);
}
/* Compact tool buttons */
.tool-button {
font-size: 0.875rem;
@@ -105,37 +96,3 @@
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tool-button__description {
font-size: 0.75rem;
color: var(--mantine-color-dimmed);
line-height: 1.35;
margin-top: 0.15rem;
white-space: normal;
text-align: left;
}
/* Resting / compact tool list — taller rows, room for description. */
.tool-picker__compact {
padding: 0.5rem var(--mantine-spacing-sm) var(--mantine-spacing-sm);
}
.tool-picker__compact .tool-button {
padding-top: 0.65rem;
padding-bottom: 0.65rem;
height: auto;
}
.tool-picker__compact .tool-button .mantine-Button-label {
align-items: center;
}
.tool-picker__compact-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tool-picker__view-all {
margin-top: 0.75rem;
}
@@ -1,8 +1,18 @@
// Centralized download URLs for Stirling PDF desktop installers
export const DOWNLOAD_URLS = {
WINDOWS: "https://files.stirlingpdf.com/win-installer.exe",
MAC: "https://files.stirlingpdf.com/mac-installer.dmg",
LINUX_DOCS: "https://docs.stirlingpdf.com/Installation/Unix%20Installation/",
// Centralized download URLs for Stirling PDF desktop installers.
// File names match the GitHub release artifacts so files.stirlingpdf.com can
// alias them 1:1.
export const DOWNLOAD_BASE_URL = "https://files.stirlingpdf.com/";
export const DESKTOP_INSTALLER_FILES = {
// Universal Mac binary - works on both Intel and Apple silicon, so no arch
// detection is needed on the client.
MAC: "Stirling-PDF-macos-universal.dmg",
WINDOWS: "Stirling-PDF-windows-x86_64.msi",
LINUX_DEB: "Stirling-PDF-linux-x86_64.deb",
} as const;
export const DOWNLOAD_BASE_URL = "https://files.stirlingpdf.com/";
export const DOWNLOAD_URLS = {
WINDOWS: DOWNLOAD_BASE_URL + DESKTOP_INSTALLER_FILES.WINDOWS,
MAC: DOWNLOAD_BASE_URL + DESKTOP_INSTALLER_FILES.MAC,
LINUX_DOCS: "https://docs.stirlingpdf.com/Installation/Unix%20Installation/",
} as const;
@@ -1,6 +1,8 @@
import { PageEditorFunctions } from "@app/types/pageEditor";
import { type ToolPanelMode } from "@app/constants/toolPanel";
import { preferencesService } from "@app/services/preferencesService";
import {
type ToolPanelMode,
DEFAULT_TOOL_PANEL_MODE,
} from "@app/constants/toolPanel";
export interface ToolWorkflowState {
// UI State
@@ -41,7 +43,7 @@ export const baseState: Omit<ToolWorkflowState, "toolPanelMode"> = {
export const createInitialState = (): ToolWorkflowState => ({
...baseState,
toolPanelMode: preferencesService.getPreference("defaultToolPanelMode"),
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
});
export function toolWorkflowReducer(
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { generateId } from "@app/utils/generateId";
import {
signatureStorageService,
type StorageType,
@@ -29,6 +28,16 @@ export type AddSignatureResult =
const isSupportedEnvironment = () =>
typeof window !== "undefined" && typeof window.localStorage !== "undefined";
const generateId = () => {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `sig_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
};
export const useSavedSignatures = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
const [storageType, setStorageType] = useState<StorageType | null>(null);
@@ -13,31 +13,6 @@ interface UseToolPanelGeometryOptions {
quickAccessRef: RefObject<HTMLDivElement | null>;
}
function computeGeometry(
panelEl: HTMLDivElement,
quickAccessRef: RefObject<HTMLDivElement | null>,
): ToolPanelGeometry {
const rect = panelEl.getBoundingClientRect();
const isRTL =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
let width: number;
let left: number;
if (isRTL) {
// RTL: panel is on the left, expands rightward
width = Math.max(360, window.innerWidth - rect.right);
left = rect.right;
} else {
// LTR: panel is on the right, expands leftward to the file sidebar
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
width = Math.max(360, rect.right - leftOffset);
left = leftOffset;
}
const height = Math.max(rect.height, window.innerHeight - rect.top);
return { left, top: rect.top, width, height };
}
export function useToolPanelGeometry({
enabled,
toolPanelRef,
@@ -61,7 +36,31 @@ export function useToolPanelGeometry({
let rafId: number | null = null;
const computeAndSetGeometry = () => {
setGeometry(computeGeometry(panelEl, quickAccessRef));
const rect = panelEl.getBoundingClientRect();
const isRTL =
typeof document !== "undefined" &&
document.documentElement.dir === "rtl";
let width: number;
let left: number;
if (isRTL) {
// RTL: panel is on the left, expands rightward
width = Math.max(360, window.innerWidth - rect.right);
left = rect.right;
} else {
// LTR: panel is on the right, expands leftward to the file sidebar
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
width = Math.max(360, rect.right - leftOffset);
left = leftOffset;
}
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left,
top: rect.top,
width,
height,
});
};
const scheduleUpdate = () => {
+3 -3
View File
@@ -20,7 +20,7 @@ import AppsIcon from "@mui/icons-material/AppsRounded";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import RightSidebar from "@app/components/tools/RightSidebar";
import ToolPanel from "@app/components/tools/ToolPanel";
import Workbench from "@app/components/layout/Workbench";
import FileSidebar from "@app/components/shared/FileSidebar";
import FileManager from "@app/components/FileManager";
@@ -380,7 +380,7 @@ export default function HomePage() {
)}
>
<div className="mobile-slide-content">
<RightSidebar />
<ToolPanel />
</div>
</div>
<div
@@ -510,7 +510,7 @@ export default function HomePage() {
/>
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
<Workbench />
{!hideToolPanel && <RightSidebar />}
{!hideToolPanel && <ToolPanel />}
<FileManager selectedTool={selectedTool} />
<AppConfigModal
opened={configModalOpen}
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { updateService } from "@app/services/updateService";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
type MachineInfo = Parameters<typeof updateService.getDownloadUrl>[0];
const stubMachineInfo: MachineInfo = {
machineType: "Server-jar",
activeSecurity: false,
licenseType: "FREE",
};
const setUserAgent = (value: string) => {
Object.defineProperty(window.navigator, "userAgent", {
value,
configurable: true,
});
};
describe("updateService.getDownloadUrl (desktop)", () => {
const original = window.navigator.userAgent;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
setUserAgent(original);
});
it("returns the universal Mac installer for Apple-silicon Macs", () => {
setUserAgent(
"Mozilla/5.0 (Macintosh; ARM Mac OS X 14_0) AppleWebKit/605.1.15",
);
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
DOWNLOAD_URLS.MAC,
);
});
it("returns the universal Mac installer for Intel Macs", () => {
setUserAgent(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15",
);
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
DOWNLOAD_URLS.MAC,
);
});
it("returns the universal Mac URL even when the UA misreports Apple-silicon as Intel", () => {
setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)");
const url = updateService.getDownloadUrl(stubMachineInfo, true);
expect(url).toBe(DOWNLOAD_URLS.MAC);
expect(url).toContain("Stirling-PDF-macos-universal.dmg");
});
it("returns the Windows installer for Windows UAs", () => {
setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)");
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
DOWNLOAD_URLS.WINDOWS,
);
});
it("returns a .deb URL for Linux UAs", () => {
setUserAgent("Mozilla/5.0 (X11; Linux x86_64)");
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toContain(
"Stirling-PDF-linux-x86_64.deb",
);
});
it("falls back to the GitHub releases page for unknown platforms", () => {
setUserAgent("Mozilla/5.0 (Unknown OS)");
expect(updateService.getDownloadUrl(stubMachineInfo, true)).toBe(
"https://github.com/Stirling-Tools/Stirling-PDF/releases/latest",
);
});
});
@@ -1,4 +1,8 @@
import { DOWNLOAD_BASE_URL } from "@app/constants/downloads";
import {
DESKTOP_INSTALLER_FILES,
DOWNLOAD_BASE_URL,
DOWNLOAD_URLS,
} from "@app/constants/downloads";
export interface UpdateSummary {
latest_version: string | null;
@@ -67,8 +71,27 @@ export class UpdateService {
/**
* Get download URL based on machine type and security settings
* If isDesktop is true, returns desktop installer URLs instead
*/
getDownloadUrl(machineInfo: MachineInfo): string | null {
getDownloadUrl(
machineInfo: MachineInfo,
isDesktop: boolean = false,
): string | null {
if (isDesktop) {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes("win")) {
return DOWNLOAD_URLS.WINDOWS;
} else if (userAgent.includes("mac")) {
// Universal Mac binary covers both Intel and Apple silicon, so we
// don't need to detect arch (which is unreliable on the web anyway -
// Apple-silicon Macs often report Intel in the UA).
return DOWNLOAD_URLS.MAC;
} else if (userAgent.includes("linux")) {
return DOWNLOAD_BASE_URL + DESKTOP_INSTALLER_FILES.LINUX_DEB;
}
return "https://github.com/Stirling-Tools/Stirling-PDF/releases/latest";
}
// Only show download for non-Docker installations
if (
machineInfo.machineType === "Docker" ||
-14
View File
@@ -1099,17 +1099,3 @@
animation-duration: 160ms;
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
}
/* Mantine animates a button's background-color and its text/icon colour on
* separate transitions, so when a filled button flips state (selected ↔
* unselected, enabled ↔ disabled) you briefly see e.g. a blue background
* with the old dark-grey label still showing through. Drop the colour
* tween — the swap is instant, but transforms/shadows/borders/opacity keep
* animating for hover and press feedback. */
.mantine-Button-root,
.mantine-ActionIcon-root,
.mantine-SegmentedControl-control,
.mantine-SegmentedControl-label,
.mantine-SegmentedControl-indicator {
transition-property: transform, box-shadow, border-color, opacity !important;
}
@@ -1,4 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
import { openSettings } from "@app/tests/helpers/ui-helpers";
@@ -1,4 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
@@ -1,4 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import { test, expect } from "@playwright/test";
import { ensureCookieConsent } from "@app/tests/helpers/login";
import { bypassOnboarding } from "@app/tests/helpers/api-stubs";
@@ -1,7 +1,8 @@
import { test as base, expect } from "@app/tests/helpers/test-base";
import { test as base, expect } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
seedCookieConsent,
skipOnboarding,
type MockAppApiOptions,
} from "@app/tests/helpers/api-stubs";
@@ -56,8 +57,7 @@ export const test = base.extend<StubFixtures>({
seedJwt: [false, { option: true }],
page: async ({ page, stubOptions, autoGoto, seedJwt }, use) => {
// `page` comes from test-base, which has already seeded cookie consent
// and attached the console-error recorder before any navigation runs.
await seedCookieConsent(page);
if (seedJwt) {
// Logged-in users hit the orchestrator path that surfaces the
// analytics opt-in / MFA prompts — use the stronger bypass-all flag
@@ -1,216 +1,14 @@
import { test as base, expect, type Page } from "@playwright/test";
import { test as base, expect } from "@playwright/test";
/**
* Console message types that should fail the test if they appear.
* `console.error()` -> type "error", `console.warn()` -> type "warning".
*/
const FAILING_CONSOLE_TYPES = new Set(["error", "warning"]);
/**
* Patterns ignored globally on every page. Keep this list small and
* well-justified — each entry suppresses a genuine console warning for
* every test, which means we lose detection of regressions in that
* surface. Only add things that:
*
* - fire on first page render of *every* test (so per-test
* suppression would just be ceremony), AND
* - are environmental noise (third-party CDN, dev-server quirk,
* known init-order quirk) rather than something a test could
* reasonably assert.
*
* Anything that fires only on specific flows belongs in an inline
* `expectConsoleError` / `suppressConsoleErrors` at the call site.
*/
const GLOBAL_IGNORE_PATTERNS: RegExp[] = [
// Stripe.js logs an HTTP warning when loaded over localhost. Third-party,
// expected in dev, no production impact.
/You may test your Stripe\.js integration over HTTP/,
// i18next's HTTP backend fails to load namespace files under Vite dev's
// `@fs/` URLs; the app falls back to embedded English copy and tests
// still pass functional assertions.
/i18next::backendConnector: loading namespace/,
// scarfTracking.firePixel() is invoked from a router effect on the first
// route render, before the useScarfTracking hook has called
// setScarfConfig(). Harmless (the pixel is a no-op on first call) but
// worth a follow-up to reorder init. See utils/scarfTracking.ts.
/\[scarfTracking\] firePixel\(\) called before setScarfConfig/,
// ── Vite dev-server flakiness under parallel-worker load ────────────────
// The next block suppresses the entire cascade that follows when Vite's
// dev server briefly stops accepting connections (because several workers
// hit it simultaneously). In CI we serve a pre-built dist via
// `vite preview`, where none of this happens; locally the cascade is just
// environmental noise. None of these patterns mask production-only bugs.
// 1) Browser-level network failure for an unreachable URL.
/Failed to load resource: net::ERR_/,
// 2) Vite's lazy chunk loader sees the network failure and throws.
/Failed to fetch dynamically imported module/,
// 3) PDF.js / pdfium WASM streaming fetch trips on the same outage.
/WebAssembly compilation aborted: Network error/,
/wasm streaming compile failed/,
/failed to asynchronously prepare wasm/,
/falling back to ArrayBuffer instantiation/,
// 4) React-dom logs its own wrapper line when the lazy chunk error reaches
// a Suspense / ErrorBoundary. Suppress only this exact wrapper — real
// React errors that aren't chunk-load failures still surface elsewhere.
/The above error occurred in one of your React components/,
// 5) Our ErrorBoundary's componentDidCatch dumps ~15 supplementary
// diagnostic lines. They are useful in prod but in tests they are
// pure noise on top of whatever already failed. Match by source URL.
/\(https?:\/\/[^)]*\/src\/core\/components\/shared\/ErrorBoundary\.tsx:/,
];
/**
* Per-page collector for console errors / warnings / uncaught page errors.
*
* The fixture installs one of these on every page. Messages that aren't
* absorbed by an active `expectConsoleError` / `suppressConsoleErrors`
* scope are reported in fixture teardown and fail the test.
*/
class ConsoleErrorRecorder {
private readonly failed: string[] = [];
private readonly scopes: Array<{ pattern: RegExp; matched: boolean }> = [];
record(text: string): void {
for (const pattern of GLOBAL_IGNORE_PATTERNS) {
if (pattern.test(text)) return; // documented global noise
}
for (const scope of this.scopes) {
if (scope.pattern.test(text)) {
scope.matched = true;
return; // absorbed by an active scope, not a failure
}
}
this.failed.push(text);
}
async withScope<T>(
pattern: RegExp,
fn: () => Promise<T>,
requireMatch: boolean,
): Promise<T> {
const scope = { pattern, matched: false };
this.scopes.push(scope);
try {
const result = await fn();
if (requireMatch && !scope.matched) {
throw new Error(
`expectConsoleError: no console error/warning matched ${pattern} ` +
`during the scoped action`,
);
}
return result;
} finally {
const idx = this.scopes.indexOf(scope);
if (idx >= 0) this.scopes.splice(idx, 1);
}
}
failures(): string[] {
return this.failed;
}
}
const recordersByPage = new WeakMap<Page, ConsoleErrorRecorder>();
function attachConsoleErrorRecorder(page: Page): ConsoleErrorRecorder {
const recorder = new ConsoleErrorRecorder();
recordersByPage.set(page, recorder);
page.on("console", (msg) => {
const type = msg.type();
if (!FAILING_CONSOLE_TYPES.has(type)) return;
const { url, lineNumber, columnNumber } = msg.location();
const where = url ? ` (${url}:${lineNumber}:${columnNumber})` : "";
recorder.record(`[console.${type}] ${msg.text()}${where}`);
});
page.on("pageerror", (err) => {
recorder.record(`[pageerror] ${err.message}`);
});
return recorder;
}
function getRecorder(page: Page, caller: string): ConsoleErrorRecorder {
const recorder = recordersByPage.get(page);
if (!recorder) {
throw new Error(
`${caller} requires the \`test\` exported from ` +
`\`@app/tests/helpers/test-base\` (or stub-test-base). ` +
`Are you importing \`test\` directly from "@playwright/test"?`,
);
}
return recorder;
}
/**
* Run `fn` and *require* at least one console error / warning / page error
* that matches `pattern` to occur during it. Matching messages are
* absorbed (they don't fail the test); if none match, this throws.
*
* Use when a test deliberately exercises an error path and the error
* surfaces in the console:
*
* await expectConsoleError(page, /Validation failed/, async () => {
* await page.getByRole("button", { name: "Submit" }).click();
* await expect(page.getByRole("alert")).toBeVisible();
* });
*
* The scope only covers messages emitted while `fn` is awaiting, so
* remember to `await` any UI assertion that the error has surfaced
* *inside* the callback rather than after it returns.
*/
export async function expectConsoleError<T>(
page: Page,
pattern: RegExp,
fn: () => Promise<T>,
): Promise<T> {
return getRecorder(page, "expectConsoleError").withScope(pattern, fn, true);
}
/**
* Run `fn` and silently absorb any console errors / warnings / page
* errors matching `pattern`, without asserting that one occurred. Use
* sparingly — `expectConsoleError` is preferred because it also verifies
* the error path actually fires.
*
* await suppressConsoleErrors(page, /MUI Grid v1 deprecated/, async () => {
* await page.getByRole("button", { name: "Open settings" }).click();
* });
*/
export async function suppressConsoleErrors<T>(
page: Page,
pattern: RegExp,
fn: () => Promise<T>,
): Promise<T> {
return getRecorder(page, "suppressConsoleErrors").withScope(
pattern,
fn,
false,
);
}
/**
* Custom test fixture shared across all Playwright suites. Two things
* happen for every test that uses this base (directly or transitively
* via `stub-test-base.ts`):
*
* 1. The cookie-consent cookie is seeded before any navigation so the
* `#cc-main` banner never renders and never intercepts clicks.
* 2. Console errors/warnings and uncaught page errors are captured.
* If any unhandled message appears during the test, the fixture
* throws during teardown and the test fails. Tests that legitimately
* produce errors should wrap the offending step in
* `expectConsoleError(page, /pattern/, async () => { ... })`.
* Custom test fixture that auto-dismisses the cookie consent banner
* before every test. The banner (#cc-main) overlays the page and
* intercepts pointer events, causing click timeouts across all tests.
*
* Usage: import { test, expect } from '@app/tests/helpers/test-base';
*/
export const test = base.extend({
page: async ({ page }, use) => {
const recorder = attachConsoleErrorRecorder(page);
// Set the cookie consent cookie before any navigation so the banner
// never appears. The cookieconsent library (orestbida/cookieconsent)
// reads this cookie on init and skips the banner if consent exists.
@@ -231,16 +29,6 @@ export const test = base.extend({
]);
await use(page);
const failures = recorder.failures();
if (failures.length > 0) {
throw new Error(
`Test produced ${failures.length} unhandled console error(s)/warning(s):\n` +
failures.map((m) => ` ${m}`).join("\n") +
`\n\nIf any of these are expected, wrap the action in ` +
`expectConsoleError(page, /pattern/, async () => { ... }).`,
);
}
},
});
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
// ---------------------------------------------------------------------------
@@ -18,8 +18,7 @@
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
@@ -5,8 +5,7 @@
* The Vite dev server must be running (handled by playwright.config.ts webServer).
*/
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
@@ -17,8 +17,7 @@
* unlock-all-wrong-password - all transitively covered or low-value.
*/
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import path from "path";
import fs from "fs";
import { mockAppApis } from "@app/tests/helpers/api-stubs";
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs";
/**
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -31,9 +31,9 @@ test.describe("Navigation", () => {
await page.locator('a[href="/merge"]').first().click();
await expect(page).toHaveURL(/\/merge/);
// In the redesigned UI, "Back to all tools" button replaces the old "Tools" link
// In the redesigned UI, "Back to tools" button replaces the old "Tools" link
await page
.getByRole("button", { name: /Back to all tools/i })
.getByRole("button", { name: /Back to tools/i })
.first()
.click();
await expect(page).toHaveURL("/");
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -6,8 +6,7 @@
* STIRLING_FLAVOR=saas ./gradlew :stirling-pdf:bootRun --args="--server.port=18083 --spring.profiles.include=dev"
* STIRLING_SAAS_URL=http://localhost:18083 npx playwright test --project=stubbed saas-backend-smoke
*/
import { test, expect } from "@app/tests/helpers/test-base";
import { request } from "@playwright/test";
import { test, expect, request } from "@playwright/test";
const SAAS_URL = process.env.STIRLING_SAAS_URL ?? "http://localhost:18083";
@@ -1,5 +1,4 @@
import { test, expect } from "@app/tests/helpers/test-base";
import type { Page } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
bypassOnboarding,
mockAppApis,
@@ -36,10 +36,10 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => {
await page.goto("/compress");
await page.waitForLoadState("domcontentloaded");
// Step 2: Click the "Back to all tools" button in ToolPanel to go back to /.
// Step 2: Click the "Back to tools" button in ToolPanel to go back to /.
// In the redesigned UI this replaces the old "Tools" sidebar link.
const homeLink = page
.getByRole("button", { name: /Back to all tools/i })
.getByRole("button", { name: /Back to tools/i })
.first();
await homeLink.click();

Some files were not shown because too many files have changed in this diff Show More