Compare commits

..
Author SHA1 Message Date
EthanHealy01 fc9960b6e6 fast path added 2026-06-02 01:20:35 +01:00
EthanHealy01 f8ce30ff37 Add AGPL-3.0 license text and fix formatting
- Add licenses/AGPL-3.0.txt (full license text for scripts/pymupdf_convert.py)
- Update root LICENSE to reference it
- Fix Spotless formatting in PyMuPdfConverter and ExternalAppDepConfig
2026-06-02 00:58:39 +01:00
EthanHealy01 f0a7f9af78 Collapse pymupdf-worker package into a single script
The pymupdf-worker/ directory existed only to register a pip entry point
for pymupdf-convert. Replace it with scripts/pymupdf_convert.py — a
standalone script installed directly onto PATH in the Dockerfile.

- Add scripts/pymupdf_convert.py (AGPL-3.0, same logic, shebang added)
- Update docker/base/Dockerfile to copy + install the script directly
  instead of pip-installing the package; remove now-redundant symlink
- Delete pymupdf-worker/ and its taskfile
- Update root LICENSE to reference the script file instead of directory
2026-06-02 00:48:55 +01:00
EthanHealy01 6309ca7234 Clean up pymupdf-convert integration per review feedback
- Remove AGPL/license comments from main codebase (Dockerfile, Java
  classes, javadocs) — legal isolation is structural, comments were noise
- PyMuPdfConverter: check CLI availability once at @PostConstruct instead
  of caching with a TTL; simpler and consistent with how LibreOffice/
  Tesseract availability is handled
2026-06-02 00:44:41 +01:00
EthanHealy01 f3f17d6381 fix: non-root user in Dockerfile, update stale HTTP references in worker 2026-06-01 19:45:35 +01:00
EthanHealy01 4487f23ff7 feat(pdf-to-markdown): add pymupdf-convert CLI for faster PDF→Markdown conversion
Introduces a new  directory containing a small standalone
CLI tool () that converts PDFs to Markdown using PyMuPDF
and pymupdf4llm. When installed, it replaces the slower pdftohtml-based
converter with a significantly faster path.
The tool is invoked as an OS subprocess, following the same pattern already
used for LibreOffice and Tesseract. A new  service handles
discovery (PATH probe, cached 30 s), temp file I/O, and process execution via
the existing  infrastructure. Auto-detected at startup via
 — no configuration flag required. Falls back
transparently to the bundled pdftohtml converter if not found.
 is baked into the Docker base image via the existing
Python venv build stage and symlinked onto PATH, so all Docker users get
the faster path automatically with no manual steps.
AGPL notice:  is licensed under AGPL-3.0-or-later because
it depends on PyMuPDF (AGPL-3.0 / Artifex commercial). It is a SEPARATE
program from the rest of Stirling PDF and shares no source code with the
MIT-licensed core or the proprietary engine. The subprocess boundary
ensures the AGPL copyleft attaches to this tool alone and does not extend
to Stirling PDF. The LICENSE file and pymupdf-worker/LICENSE document this
separation explicitly. Do not replace the subprocess invocation with an
in-process import of PyMuPDF.
2026-06-01 19:42:49 +01:00
brios 2c0ebc28a7 perf(api): optimize static asset caching, enable ETag support, and expand response compression mime types. (#6273) 2026-06-01 16:37:56 +01:00
Ludy d1486c7762 ci(github-actions): replace deprecated app-id input with client-id (#6485)
# Description of Changes

This change updates the GitHub App token generation step in the custom
`setup-bot` GitHub Action to use the new `client-id` input instead of
the deprecated `app-id` input when invoking
`actions/create-github-app-token`.

### What was changed

- Replaced the deprecated `app-id` parameter with `client-id` in
`.github/actions/setup-bot/action.yml`.
- Continued sourcing the value from the existing `inputs.app-id` action
input to avoid broader interface changes.

### Why the change was made

- `actions/create-github-app-token` has deprecated the `app-id` input
and now expects `client-id`.
- Updating the workflow removes the deprecation warning and ensures
compatibility with current and future versions of the action.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-05-31 08:07:30 +01:00
Ludy 2ccff6f73f fix(update-service): correct GitHub branch reference for version retrieval (#6333) 2026-05-30 22:44:42 +01:00
Ludy 78da227eba fix: Use frontend/editor for locales paths (#6483) 2026-05-30 22:00:17 +01:00
Anthony Stirling 30e782e29c Disable Save-to-server when storage off, fix QR port 0 (#6473) 2026-05-29 19:37:42 +01:00
Anthony Stirling 2b0905887b Add desktop multi-window support (#6463) 2026-05-29 19:35:54 +01:00
ConnorYoh 28b81828b5 PAYG: PricingPolicyService + admin REST + 30s read cache (#6469)
## What this is

PR-I1 service half from `notes/PAYG_DESIGN.md`. Built on top of the data
model from #6460 — answers "what pricing policy applies to this team
right now?" with a fast cache and an admin write surface.

## Scope

| Piece | Where |
|---|---|
| `PricingPolicyService` — `getEffectivePolicy(teamId)` with 30s
Caffeine cache + admin write paths |
`app/saas/.../payg/policy/PricingPolicyService.java` |
| `PolicyChangedEvent` — published after admin writes for in-process
cache invalidation | `app/saas/.../payg/policy/PolicyChangedEvent.java`
|
| Admin REST — list / get / create / set-default / set team override /
get effective |
`app/saas/.../payg/policy/admin/PricingPolicyAdminController.java` +
DTOs |
| `PricingPolicyRepository.clearDefaultFlag()` — atomic clear for
set-default | repository update |
| `SaasJpaConfigScanTest` — drift guard against the JPA scan paths going
stale (carried over from the #6460 review concern) | new test |
| V12 default-policy seed (`v1-initial`, 25 pages/unit, 5 MiB/unit,
per-`JobSource` step limits) | `V12__seed_default_payg_policy.sql` |

## Lookup precedence

1. `PaygTeamExtensions.pricingPolicyId` set → return that policy
2. Else return the `pricing_policy` row with `is_default = TRUE`
3. Override row points at a deleted policy → log warn, fall back to
default (safety net for racing deletes)
4. No default → `IllegalStateException` (V12 seed guarantees one exists)

## Cache behaviour

- 30s `expireAfterWrite` Caffeine, max 10k entries, keyed by `teamId`.
- **Single correctness model: the TTL.** Cross-instance propagation is
at-most-30-seconds. The writer instance sees its own change immediately
via the `PolicyChangedEvent` after-commit publish. Other instances pick
it up on the next TTL expiry.
- Admin reads use `getEffectivePolicyUncached` so admins always see
their own write straight back.

**Why no LISTEN/NOTIFY runner.** An earlier cut of this PR included a
Postgres `LISTEN policy_changed` runner so cross-instance propagation
was instant. Dropped — admin policy changes are events-per-week and the
30s TTL is already the correctness floor; the listener was ~250 lines of
nontrivial code (raw JDBC outside HikariCP, daemon thread, reconnect
loop, lock-protected connection lifecycle) for a use case that isn't on
the hot path. Trade-off is documented in `notes/PAYG_DESIGN.md` §9 with
three concrete triggers that would justify reintroducing it (aggressive
cap enforcement, Redis landing for other reasons, real-time admin UI).

## Writes — transactional, fire `PolicyChangedEvent` after commit

- `create(draft)` — rejects pre-set `policy_id` or `is_default=true`
(promotion must go through `setDefault` so the partial unique idx is
freed first).
- `setDefault(id)` — atomically clears the existing default via
`clearDefaultFlag()` then flips the new row. Idempotent: silent no-op if
the row is already default.
- `setTeamOverride(teamId, policyId | null)` — validates the policy
exists before save; `null` clears the override.

`publishOnCommit` uses `TransactionSynchronizationManager.afterCommit`
so listeners never see pre-commit state. Outside a transaction (test
paths) falls through to immediate publish.

## Admin REST surface — `/api/v1/admin/payg/...`

All endpoints `@PreAuthorize("hasRole('ADMIN')")`:

- `GET  /policies` — list all
- `GET  /policies/{id}` — read one
- `POST /policies` — create new (non-default)
- `POST /policies/{id}/set-default` — atomic promote
- `PUT /teams/{teamId}/policy-override` — set or clear per-team override
- `GET  /teams/{teamId}/effective-policy` — cache-bypassing live read

Validation errors → 400, unknown rows → 404.

## Counterpart Supabase PR


[`Stirling-PDF-SaaS#298`](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/298)
— seeds the same V1 default policy on the Supabase side via
`20260528000002_payg_seed_default_policy.sql`.

## Tests

- 17 × `PricingPolicyServiceTest` — lookup precedence, cache hit/miss,
invalidation on event, mutation paths publishing event, error cases.
- 14 × `PricingPolicyAdminControllerTest` — every endpoint's happy path
+ error mapping, DTO defensive-copy invariant.
- 2 × `SaasJpaConfigScanTest` — reflection-based guard that
`payg.repository` is in `@EnableJpaRepositories` and `payg` is in
`@EntityScan`. Without this, new sub-packages can silently fail to wire
at runtime — same class of bug that the #6460 review caught.

Full `:saas:test` BUILD SUCCESSFUL.

## Design doc

`notes/PAYG_DESIGN.md` §7.4 PR-I1 — completes the service half (the
schema half landed in #6460). §9 carries the 30s-TTL trade-off note.
2026-05-29 16:27:01 +00:00
James Brunton 2c01f41142 Update indexeddb to v9 to unify SaaS and OSS users (#6474)
# Description of Changes
The production SaaS is currently on v8 of IndexedDB due to various
schema changes for Smart Folders, which haven't made their way into OSS.
OSS is currently on v4 of IndexedDB, so if we release an OSS build to
the SaaS deployment, existing users will not be able to use it because
the DB version is 'too old'.

This PR updates the IDB version number to v9 so both OSS and SaaS users
will be able to upgrade to it. Theoretically both types of user should
be able to keep their IDB files without issue. SaaS previously actively
wiped the user's files in an old version (v6/v7) and users who haven't
used it since then will have their DBs wiped, but that'd happen anyway
if they use current SaaS so I don't think that matters.
2026-05-29 15:23:40 +00:00
James Brunton 4d5eeb103f Fix username display issues (#6471)
# Description of Changes
Main fixes:
- Fix the display of the username in the bottom left
- Now displays as "User" when not logged in on self-hosted (desktop) and
"Guest" on SaaS when logged in anonymously
- Now updates properly when the user logs in/out in SaaS, desktop and
self-hosted
- Fix incremental build issues in the desktop app that have been here
since the start (I hope at least - I think the issue is that the JLink
is built read-only and then on subsequent builds you get OS errors when
trying to override the JLink with the new version. There's no real need
for it to be read-only that I know of, so we might as well just make it
R/W and ship like that)
2026-05-29 14:35:47 +00:00
ConnorYoh 83ea07ed6a saas: DocumentClassifier + PAYG data model (#6460)
# Description of Changes

Two layers — the `DocumentClassifier` utility plus the full data model
for the new billing engine. Nothing wires the entities into application
behaviour yet; services and controllers land in follow-up PRs.

**Companion PR:**
[Stirling-PDF-SaaS#296](https://github.com/Stirling-Tools/Stirling-PDF-SaaS/pull/296)
— Supabase migration for the v3 dev branch, schema-equivalent to the
Flyway migration in this PR.

## 1. DocumentClassifier (under `payg.docs`)

`DocumentClassifier` computes the doc-unit cost of an uploaded file (or
multi-file input) under a `PricingPolicy`. PDFs read page count via
`stirling.software.jpdfium.PdfDocument`; non-PDFs are bytes-only.
Formula: `max(ceil(pages / docPagesPerUnit), ceil(bytes /
docBytesPerUnit))` clamped to `[1, fileUnitCap]`. Multi-file is the sum
of raw per-file units capped at `fileUnitCap × file_count`.

Two floors, by design: the classifier returns `docUnits` with an
absolute `1` floor for non-empty input; the policy-level
`minChargeUnits` is intentionally applied later, at process-open time in
`JobChargeService`, per design § 3.4 (`unitsForProcess =
max(policy.min_charge_units, docUnits)`). Documented in the interface +
impl javadoc.

Upload bytes are materialised through
`TempFileManager.createManagedTempFile` so jpdfium gets a `Path`; the
temp file auto-deletes on close.

Twelve tests, all in-memory fixtures generated with PDFBox at test time
— no committed binary blobs.

## 2. PAYG data model (under `payg.*`)

JPA entities, repositories, and a Flyway migration covering the full
schema in §6 of the design.

**Enums** (`payg.model`):

`JobSource`, `ProcessType`, `JobStatus`, `JobStepStatus`,
`ArtifactKind`, `LedgerEntryType`, `LedgerBucket`, `ReferenceType`,
`EntitlementState`, `FeatureSet`, `FeatureGate`, `WalletEngine`,
`CapPeriod`, `AutoGroupStrategy`.

**Entities + repositories:**

| Entity | Table | Notes |
|---|---|---|
| `PricingPolicy` | `pricing_policy` | Promoted from a record.
`stepLimits` is `Map<JobSource, Integer>` persisted via normalised child
table `pricing_policy_step_limit`. `stripePriceIds` is `Set<String>`
persisted via `pricing_policy_stripe_price` — currency comes from
`stripe.prices` via Sync Engine, not stored locally. |
| `ProcessingJob` | `processing_job` | UUID PK. Tracks lineage window
via `step_count` and `last_step_at`. |
| `ProcessingJobStep` | `processing_job_step` | Per-tool-call audit. |
| `JobArtifactHash` | `job_artifact_hash` | Composite key `(job_id,
content_hash, kind)`. `content_hash VARCHAR(128)` so multiple signature
schemes coexist as `"type:value"` storage keys. Lineage detector queries
this. |
| `WalletLedgerEntry` | `wallet_ledger` | Append-only, signed
`amount_units`. Two unique indexes kill double-posting. |
| `WalletPolicy` | `wallet_policy` | Per-team engine + cap + degradation
rules + lineage strategy. No `@Version` — admin-only writes (documented
in javadoc). |
| `WalletEntitlementSnapshot` | `wallet_entitlement_snapshot` |
Composite key `(team_id, user_id)`; `user_id = 0` is the team-wide
sentinel. No `@Version` — full-row recompute via
`EntitlementService.recompute` (documented in javadoc). |
| `PaygShadowCharge` | `payg_shadow_charge` | Per-job diff while in
`PAYG_SHADOW` engine mode. |
| `PaygTeamExtensions` | `payg_team_extensions` | Sidecar 1:1 with
`teams` carrying `pricing_policy_id` (per-team override) +
`stripe_customer_id`. Sidecar pattern (mirrors `saas_team_extensions`)
so OSS Hibernate ddl-auto never sees PAYG columns on `teams`. |

**Column adds:**

- `team_memberships.cap_units` (optional per-member sub-cap)

**Width split (intentional, documented in V11):** per-row deltas
(`wallet_ledger.amount_units`, `processing_job.charged_units`) are
`INTEGER` because no single charge realistically approaches 2B units.
Cap and period-rollup columns (`team_memberships.cap_units`,
`wallet_policy.cap_units`,
`wallet_entitlement_snapshot.period_spend_units / period_cap_units`) are
`BIGINT` because they accumulate across a billing period and admins may
legitimately set headroom-cap values into the millions.

**JPA wiring:** `SaasJpaConfig` was updated to include
`stirling.software.saas.payg.repository` in
`@EnableJpaRepositories.basePackages` and `stirling.software.saas.payg`
in `@EntityScan` (covers `payg.policy` / `payg.job` / `payg.wallet` /
`payg.entitlement` / `payg.shadow` recursively). New
`SaasJpaConfigScanTest` reads the annotations reflectively and asserts
every expected package is wired — catches the next time someone adds a
new sub-package without updating the scan paths.

**Migration:** `V11__saas_payg_model.sql` (purely additive).
Schema-equivalent to the Supabase migration in the companion PR —
including the `VARCHAR(128) content_hash` width that's needed for the
multi-signature-scheme storage encoding the lineage layer uses.

## 3. Smoke tests

`PaygEntitiesSmokeTest` exercises each entity via the no-arg ctor JPA
requires, plus getter/setter round-trips and composite-key equality —
catches Lombok/annotation regressions without needing a database.
Real-DB integration coverage lands alongside the services that consume
each entity.

## Why this is safe to land now

- All schema changes are additive — no existing rows modified, no
columns dropped.
- The entities are not yet referenced from any production code path;
they exist for the next PRs to build on.
- The v3 Supabase dev branch picks up the schema via the companion PR;
the main repo's Flyway migration applies the same shape when an instance
boots against a freshly-migrated v3 database.

## Open decisions made

- **Step-limits keyed by `JobSource`** rather than by `ProcessType`.
Captures the "self-hosted gets a different knob" framing in earlier
feedback. Trivially overridable per pricing policy version.
- **Step limits + Stripe price IDs normalised into child tables** rather
than JSONB on `pricing_policy` (per Connor's review on #296). Typed
columns, queryable directly, no JSON parsing.
- **Currency dropped from `pricing_policy_stripe_price`** — it lives on
`stripe.prices.currency` and is resolved via Sync Engine. App is
currency-blind.

## Rollback

Straight `git revert` on this PR. The Supabase migration in #296 is
additive and can be left in place safely — the running app ignores
tables it doesn't reference.

---

## Checklist

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
- [x] I have run `task check` (via `./gradlew :saas:test` with
`ENABLE_SAAS=true`) — passes
2026-05-29 12:03:01 +00:00
James Brunton 61ebe977d3 Auto-delete CI linting comments on success (#6465)
# Description of Changes
Set CI backend & engine comments to auto-delete once the CI has passed. 

Also redesign the engine CI to call `task engine:check` like it should
have been, and make it post a comment when the tool models need to be
updated.

Also makes the comment wording more consistent between the three
languages.
2026-05-29 10:13:12 +00:00
EthanHealy01 763595a5a3 feat: add Agents UI to proprietary right sidebar (#6454)
Update UI to include agents

Run `task dev:all` to test
2026-05-28 17:26:23 +00:00
Anthony Stirling 398617391b Fix SSO auto-login and custom metadata settings not persisting on restart (#6468) 2026-05-28 17:39:05 +01:00
ConnorYoh a0e0e88f07 saas: harden CreditService Stripe ordering + lint @AutoJobPostMapping weights (#6458)
# Description of Changes

Two narrowly-scoped hardening changes to the credits engine.

## 1. CreditService — move Stripe meter call to `afterCommit`

The Stripe metered-usage call sits inside the surrounding
`@Transactional`, holding the `user_credits` row lock for the duration
of an HTTP round-trip to Supabase. Under load this starves concurrent
debits; a transient Stripe blip rolls back a (correct) free-credit
consumption and forces the caller to retry.

The Stripe call now runs in a `TransactionSynchronization.afterCommit`
hook — DB commits first, Stripe fires immediately after. If Stripe fails
after commit, we log + increment a new `credits.stripe_report.failures`
counter; the idempotency key is stable, so a manual replay recovers
without double-charging.

Applied to both `consumeCreditBySupabaseId` and
`consumeCreditWithWaterfall`.

**Dead-code removed:**
- Unreachable UUID fallback for MDC `requestId` — `CorrelationIdFilter`
already guarantees the key on every request.
- The `"Unable to report usage to Stripe"` `RuntimeException` and its
catch block — the afterCommit refactor eliminates the throw path.
- `StripeRollbackOnFailureTest` — pinned the rollback-on-Stripe-fail
behaviour this refactor replaces.

## 2. `@AutoJobPostMapping` — build-time lint for `resourceWeight`

`UnifiedCreditInterceptor` multiplies `resourceWeight` into the per-call
charge. An endpoint that falls through to the annotation default
produces a charge derived from a value nobody chose.

- Annotation default flipped from `1` to `Integer.MIN_VALUE` (sentinel).
Both runtime readers (`UnifiedCreditInterceptor`, `AutoJobAspect`)
already clamp into `[1, 100]` so behaviour is unchanged.
- New `AutoJobPostMappingWeightTest` scans the classpath and fails the
build if any method leaves the sentinel.
- Initial run caught 11 endpoints relying on the default. Explicit
weights now declared, chosen by comparing to peer endpoints:
  - `EditTextController` — LARGE
  - `EmailController#sendEmailWithAttachment` — SMALL
  - `ConvertPDFToMarkdown` — MEDIUM
  - `AttachmentController` (extract/list/rename/delete) — SMALL × 4
  - `ConvertImgPDFController` (cbr/cbz ↔ pdf) — MEDIUM × 2, LARGE × 2

## Tests

- `StripeUsageIdempotencyKeyTest` — pins the `(supabaseId, overage,
requestId)` idempotency key shape so Stripe always dedupes a retry.
- `StripeAfterCommitOrderingTest` — pins that `afterCommit` fires after
commit and NOT on rollback.
- `AutoJobPostMappingWeightTest` — the lint itself, plus a self-check
that the classpath scan finds at least 10 `@AutoJobPostMapping` methods
(guards against the lint passing vacuously).

Build verified: `ENABLE_SAAS=true ./gradlew :stirling-pdf:test
:saas:test`.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable) — no translation changes
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
— internal-billing change, no public docs impact
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
— N/A

### Translations (if applicable)

- [ ] Not applicable

### UI Changes (if applicable)

- [ ] Not applicable

### Testing (if applicable)

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

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