Compare commits

...
Author SHA1 Message Date
EthanHealy01 b22a71de0a Scope ADMINS_AND_TEAM_LEADS default access to the owning team
matchesDefault answered 'is this user a leader of ANY team' without looking
at which team owns the resource, so once a real TeamLeadLookup is wired a
leader of one team could use (read) another team's resource shared at that
level. Thread the owning team id through canUseResource and require
leadership of THAT team; resources with no owning team (portal /
server-scoped) keep admitting any lead, which is the intended semantic
there. Dormant today: the only TeamLeadLookup is the always-false no-op.
2026-07-06 15:45:16 +01:00
Peter Dave HelloandJames Brunton 16cfbc170e Clean up typos in docs, comments, and UI copy (#6045)
# Description of Changes

Fix wording, numbering, path references, and minor grammar issues across
project guides, backend comments, and frontend strings.

This keeps documentation and user-facing text consistent without
changing application behavior.

---

## Checklist

### General

- [x] 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/devGuide/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 tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-07-06 14:07:06 +00:00
James Brunton 355d736487 Skip enterprise tests for forks since they can't run without secrets (#6888)
# Description of Changes
OSS contributions which trigger the enterprise e2e tests will always
fail due to missing secrets (see
https://github.com/Stirling-Tools/Stirling-PDF/actions/runs/28776342146/job/85337748018?pr=6045).
This PR disables them for OSS PRs.
2026-07-06 13:26:10 +00:00
ConnorYohandJames Brunton f201aa5915 feat(account-link): Phase 2 — instance metering + daily usage sync (#6839)
## Account-link Phase 2: metering + daily usage sync

Phase 1 (already on main) let a self-hosted instance link a SaaS account
and blocked billable work when it was over its limit. It blocked, but it
never actually charged anything. This PR adds the metering + billing
half.

**It's off by default.** Everything sits behind
`stirling.billing.account-link.metering.enabled`, on top of the existing
`stirling.billing.account-link.enabled` master flag. Both have to be on
for any of it to run, so it can't touch production. The billing model
isn't going live yet — this is a dark merge.

### How it works
1. The instance classifies each billable request (API / AI / Automation
— manual PDF editing stays free) and counts it locally into a per-period
counter.
2. Once a day it reports its running totals to SaaS.
3. SaaS bills only the delta since the last report, reusing the existing
charge path (free grant + wallet ledger + Stripe meter). No new money
logic.
4. The portal shows current usage (synced spend plus anything not
reported yet), and when you subscribe it now reflects the new plan right
away instead of waiting for a cache to expire.

### What's worth a reviewer's eyes
- **It can't double-charge.** SaaS only ever bills the delta, refuses a
counter that goes backwards, dedups repeat/late reports on a monotonic
sequence number, and takes a row lock so a duplicate delivery can't
charge twice.
- **The cap is enforced at the instance gate**, not in the charge path
(same as the in-cloud flow). A $0 cap blocks all metered work.
- Page counts use jpdfium so the instance and the cloud agree on the
number that gets billed.
- New SaaS surface: `POST /api/v1/instance/sync`, migrations V25
(`payg_instance_usage`) and V26 (allow the `LINKED_INSTANCE` job
source), and a small `POST /api/v1/payg/wallet/refresh` the portal calls
after checkout.

### Companion PR
Stirling-PDF-SaaS #314 (on `v3`): the checkout edge function so the
embedded Stripe flow finishes in-page instead of reloading, plus a
`Deno.serve` migration so the edge functions actually deploy.

### Testing
Java unit tests (proprietary + saas), portal vitest, and the SaaS
edge-function tests all pass. Branch is merged up to date with main.

### Not done yet (doesn't block this merge — only matters once both
flags are on)
- V25 Supabase twin in the SaaS repo.
- Same in-page checkout fix for the editor's upgrade modal.
- A flags-on smoke test in staging (one real sync round-trip).

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-07-06 11:39:24 +00:00
James Brunton b69b787d63 Fix Playwright tests in Firefox and Safari (#6868)
# Description of Changes
Playwright tests currently fail in Firefox and Safari because of
inconsistent behaviour across the browsers. This is causing the
nightlies to fail every night. This PR fixes the test behaviour to work
consistently across browsers (most of the issues were to do with the
tests opening the file picker, which was being automatically suppressed
in Chromium, but not the other browsers).
2026-07-06 09:25:46 +00:00
James Brunton e630d6697b Fix tooltip positioning on Add Page Numbers (#6885)
# Description of Changes
## Before

<img width="483" height="227" alt="image"
src="https://github.com/user-attachments/assets/4bf86eec-a9cc-4f63-84f0-4eb2bd535bab"
/>

## After

<img width="732" height="235" alt="image"
src="https://github.com/user-attachments/assets/101d2ea4-36e8-4e8f-990a-d72b33fa0ac2"
/>
2026-07-06 09:22:41 +00:00
Ludy a15e8227b4 fix(ci): upload Playwright reports from the correct frontend directory (#6859)
# Description of Changes

This change fixes the artifact upload path used by the Playwright E2E
workflows after the frontend directory structure was updated.

### What was changed

- Updated the Playwright report artifact path from:
  - `frontend/editor/playwright-report/`
  - to `frontend/playwright-report/`
- Applied the fix to:
  - `build-enterprise.yml`
  - `e2e-stubbed.yml`
  - `nightly.yml`
- Renamed the nightly Playwright artifact from:
  - `playwright-nightly-${{ github.run_id }}`
  - to `playwright-report-nightly-${{ github.run_id }}`
  for consistency with the other workflows.

### Why the change was made

The workflows attempted to upload artifacts from a directory that no
longer exists, causing GitHub Actions to report:

> No files were found with the provided path:
`frontend/editor/playwright-report/`

Updating the upload path ensures Playwright reports are successfully
collected and available for debugging failed E2E runs.

---

## 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-07-06 09:21:57 +00:00
Ludy 12563050a6 Generate frontend license report on push (#6877)
# Description of Changes

`app/allowed-licenses.json` has been modified in preparation for when
"org.springframework.boot" is upgraded to version "4.0.7".

---

## 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-07-06 09:21:41 +00:00
Ludy 1abd23cf94 fix(frontend): respect analytics config before initializing PostHog (#6812)
# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.

- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.

Closes #6358

---

## 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)
- [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/)
(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)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] 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-07-06 09:21:09 +00:00
James Brunton 11ba3814e5 Restructure Portal code to be inside Editor (#6857)
# Description of Changes
We don't have any strong reasons to keep the Portal as a separate Vite
app, and it needs access to so many things from the Editor that it no
longer makes sense to keep them separate. This PR moves the Portal code
to have direct access to the Editor code and gets rid of the shared
folder.
2026-07-03 13:20:02 +00:00
Anthony Stirling 675afe9b71 Disable update check and notification in SaaS mode (#6863)
# Description of Changes

In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:

- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.

**What changed**

- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.

**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.

---

## Checklist

### General

- [x] 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)
- [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/)
(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)

- [x] 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-07-03 08:55:07 +00:00
779 changed files with 8625 additions and 5287 deletions
-1
View File
@@ -27,7 +27,6 @@ node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/dist-portal/
frontend/editor/playwright-report/
.npm/
.yarn/
+6 -4
View File
@@ -2,7 +2,7 @@ name: Enterprise E2E (Playwright)
# Enterprise Playwright suite — exercises premium-key gated features (audit,
# teams, analytics) plus full OAuth + SAML logins via the Keycloak compose
# stacks under testing/compose. Slow and secret-gated, so it runs in three
# stacks under testing/compose. Slow and secret-gated, so it runs in four
# situations:
#
# - PRs that touch proprietary / premium / SSO compose / enterprise tests
@@ -12,8 +12,6 @@ name: Enterprise E2E (Playwright)
# - on a nightly cron schedule (catches Keycloak image drift, license
# expiry, upstream proprietary changes),
# - manual workflow_dispatch.
#
# Auto-skipped when secrets.PREMIUM_KEY_ENTERPRISE is missing (forks, dependabot).
on:
workflow_call:
@@ -52,6 +50,10 @@ jobs:
playwright-e2e-enterprise:
needs: pick
# Skip on fork PRs / untrusted authors: they have no PREMIUM_KEY_ENTERPRISE
# (nor DEPOT_TOKEN), so the suite can't boot premium and would fail. See the
# header comment. GitHub reports the skipped reusable workflow as success.
if: needs.pick.outputs.is_fork != 'true'
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 45
env:
@@ -285,5 +287,5 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
+3
View File
@@ -202,6 +202,9 @@ jobs:
contents: read
uses: ./.github/workflows/coverage-aggregate.yml
secrets: inherit
with:
frontend-validation-result: ${{ needs.frontend-validation.result }}
playwright-e2e-live-result: ${{ needs.playwright-e2e-live.result }}
# Single status check that branch protection should mark as required.
# Succeeds when every upstream job is either `success` or `skipped` (path-
+18 -7
View File
@@ -13,6 +13,17 @@ name: Aggregate backend coverage
# producers themselves
on:
workflow_call:
inputs:
frontend-validation-result:
description: Result of the frontend-validation producer job
required: false
type: string
default: skipped
playwright-e2e-live-result:
description: Result of the playwright-e2e-live producer job
required: false
type: string
default: skipped
permissions:
contents: read
@@ -196,9 +207,9 @@ jobs:
# --------------------------------------------------------------
- name: Download vitest coverage artifact
# frontend-validation uploads as `frontend-coverage`. Tolerate
# absence so a backend-only PR still produces the matrix with
# just backend rows populated.
if: always()
# absence on backend-only runs by skipping the download entirely
# when the producer job was not part of this workflow run.
if: inputs.frontend-validation-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: frontend-coverage
@@ -206,12 +217,12 @@ jobs:
continue-on-error: true
- name: Download Playwright frontend coverage artifact
# e2e-live uploads as `playwright-frontend-coverage-<run_id>`.
# Same tolerance as vitest - matrix script handles missing inputs.
if: always()
# e2e-live uploads the artifact with a stable name. Skip the
# download entirely when the producer job did not run.
if: inputs.playwright-e2e-live-result == 'success'
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: matrix-inputs/playwright/
continue-on-error: true
+1 -1
View File
@@ -169,7 +169,7 @@ jobs:
if: always() && steps.pw-frontend-coverage.outputs.summary == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-frontend-coverage-${{ github.run_id }}
name: playwright-frontend-coverage
path: |
.test-state/playwright/coverage-pw-summary/
.test-state/playwright/coverage-pw/
+1 -1
View File
@@ -50,5 +50,5 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
path: frontend/editor/playwright-report/
path: frontend/playwright-report/
retention-days: 7
@@ -98,6 +98,13 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Generate frontend license report (Push only)
if: github.event_name == 'push'
env:
PR_IS_FORK: "false"
run: task frontend:licenses:generate
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
env:
@@ -353,6 +360,7 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Check licenses and generate report
id: license-check
run: task backend:licenses:generate || echo "LICENSE_CHECK_FAILED=true" >> $GITHUB_ENV
+2 -2
View File
@@ -53,8 +53,8 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-nightly-${{ github.run_id }}
path: frontend/editor/playwright-report/
name: playwright-report-nightly-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 14
# Builds all desktop platforms on a schedule so the Rust dependency cache is
+2 -2
View File
@@ -15,10 +15,10 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
+5 -77
View File
@@ -128,37 +128,6 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
dev:portal:
desc: "Start developer portal dev server"
ignore_error: true
deps: [install]
vars:
PORT: '{{.PORT | default "5173"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
OPEN: '{{.OPEN | default ""}}'
SUBPATH: '{{.SUBPATH | default ""}}'
MOCKS: '{{.MOCKS | default ""}}'
env:
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
dev:portal:proxy:serve:
internal: true
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
cmds:
- npx tsx scripts/dev-origin-proxy.ts
# ============================================================
# Build
# ============================================================
@@ -205,29 +174,6 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [install]
vars:
SUBPATH: '{{.SUBPATH | default ""}}'
cmds:
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
preview:portal:proxy:
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
deps: [prepare]
vars:
PORT: '{{.PORT | default "3000"}}'
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
env:
PORT: '{{.PORT}}'
BACKEND_URL: '{{.BACKEND_URL}}'
cmds:
- task: build:proprietary
vars: { PREVIEW: '1' }
- task: build:portal
vars: { SUBPATH: portal }
- npx tsx scripts/dev-origin-proxy.ts
storybook:
desc: "Start Storybook dev server"
@@ -263,8 +209,8 @@ tasks:
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers editor, portal, and the shared design system.
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -345,8 +291,6 @@ tasks:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
@@ -362,14 +306,7 @@ tasks:
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: portal/tsconfig.json }
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: shared/tsconfig.json }
vars: { PROJECT: editor/src/portal/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -382,7 +319,6 @@ tasks:
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:shared
# ============================================================
# Quality Gate
@@ -411,7 +347,6 @@ tasks:
- task: lint
- task: format:check
- task: build
- task: build:portal
- task: test
- task: storybook:build
@@ -423,7 +358,6 @@ tasks:
desc: "Run tests"
cmds:
- task: test:editor
- task: test:portal
test:editor:
desc: "Run editor tests"
@@ -431,12 +365,6 @@ tasks:
cmds:
- npx vitest run --root editor
test:portal:
desc: "Run portal tests"
deps: [prepare]
cmds:
- npx vitest run --root portal
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
@@ -481,7 +409,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
- cmd: rm -rf node_modules/.vite editor/dist dist
platforms: [linux, darwin]
+2 -1
View File
@@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
- `frontend/editor/.env` — core, proprietary, and shared vars
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- These files are committed to Git and must not contain private keys
+3 -3
View File
@@ -92,7 +92,7 @@ Visit the [Lombok website](https://projectlombok.org/setup/) for installation in
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
5. **Frontend Setup (Required for Stirling 2.0)**
6. **Frontend Setup (Required for Stirling 2.0)**
Navigate to the frontend directory and install dependencies using npm.
### Verify Setup
@@ -275,7 +275,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
export DISABLE_ADDITIONAL_FEATURES=true # or false to enable login and security features for builds
```
2. Build the project:
@@ -305,7 +305,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase
Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. However, to improve build times these can often be removed depending on your use case
## 7. Testing
+2 -2
View File
@@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+2 -2
View File
@@ -53,8 +53,8 @@ For full installation options (including desktop and Kubernetes), see our [Docum
## Support
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- **Community**: [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [GitHub Issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
## Contributing
+3 -106
View File
@@ -79,86 +79,12 @@ tasks:
OPEN: "true"
dev:portal:
desc: "Start backend + developer portal concurrently on free ports"
desc: "Start backend + editor; the portal is an admin route at /portal"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
MOCKS: 'false'
OPEN: "true"
dev:portal:all:
desc: "Start backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# Point the portal's "Editor" app switcher at the editor we spawn here.
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:all:saas:
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
deps:
- task: backend:dev:saas
vars:
PORT: '{{.BACKEND_PORT}}'
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:proxy:
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
@@ -169,18 +95,7 @@ tasks:
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
SUBPATH: portal
MOCKS: 'false'
- task: frontend:dev:portal:proxy:serve
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
@@ -228,24 +143,6 @@ tasks:
- task: backend:build
- task: frontend:build
preview:portal:proxy:
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
vars:
PORTS:
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
PORT: '{{.BACKEND_PORT}}'
SECURITY_ENABLELOGIN: "true"
POLICIES_ENABLED: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
+20
View File
@@ -80,10 +80,18 @@
"moduleName": ".*",
"moduleLicense": "Apache License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Apache License, version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "The Apache License, Version 2.0"
@@ -108,6 +116,10 @@
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
@@ -172,6 +184,14 @@
"moduleName": ".*",
"moduleLicense": "Eclipse Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "EPL-2.0"
},
{
"moduleName": ".*",
"moduleLicense": "LGPL-2.1-only"
},
{
"moduleName": ".*",
"moduleLicense": "Ubuntu Font Licence 1.0"
@@ -132,7 +132,7 @@ public class AppConfig {
return true;
}
Path mountInfo = Path.of("/proc/1/mountinfo");
// this should always exist, if not some unknown usecase
// this should always exist, if not some unknown use case
if (!Files.exists(mountInfo)) {
return true;
}
@@ -2,7 +2,8 @@ package stirling.software.proprietary.access.model;
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
public enum ResourceType {
// The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
// resourceId).
PORTAL,
// A stored S3/MCP/API integration configuration.
INTEGRATION_CONFIG
@@ -37,6 +37,7 @@ public class OwnershipService {
type,
String.valueOf(resource.getId()),
resource.getOwnerUserId(),
resource.getOwnerTeamId(),
resource.getDefaultAccess(),
user);
}
@@ -37,7 +37,8 @@ public class ResourceAccessService {
/** Whether the user may use the portal / processor. */
public boolean canAccessPortal(User user) {
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
// Server-wide resource: no owning team, so the team-lead default admits any lead.
return canUseResource(ResourceType.PORTAL, "", null, null, portalDefaultPolicy, user);
}
/** Whether the user may use a resource, falling back to its default policy. */
@@ -45,6 +46,7 @@ public class ResourceAccessService {
ResourceType type,
String resourceId,
Long ownerUserId,
Long ownerTeamId,
DefaultAccessPolicy defaultPolicy,
User user) {
if (user == null) {
@@ -56,7 +58,7 @@ public class ResourceAccessService {
if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) {
return true;
}
return matchesDefault(defaultPolicy, user);
return matchesDefault(defaultPolicy, ownerTeamId, user);
}
/** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */
@@ -161,14 +163,19 @@ public class ResourceAccessService {
return held == AccessPermission.MANAGE;
}
private boolean matchesDefault(DefaultAccessPolicy policy, User user) {
private boolean matchesDefault(DefaultAccessPolicy policy, Long ownerTeamId, User user) {
if (policy == null) {
return false;
}
return switch (policy) {
case ORG_ALL -> true;
// Admins already pass above; only team leads here.
case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user);
// Admins already pass above. For a team-owned resource only a lead OF THAT team
// qualifies — "any team leader" would let team A's lead use team B's resource. A
// resource with no owning team (portal / server-scoped) admits any team lead.
case ADMINS_AND_TEAM_LEADS ->
ownerTeamId == null
? teamLeadLookup.isAnyTeamLeader(user)
: teamLeadLookup.isLeaderOfTeam(user, ownerTeamId);
case EXPLICIT_ONLY -> false;
};
}
@@ -6,6 +6,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -14,25 +15,31 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Outbound calls from a self-hosted instance to its linked SaaS backend (combined-billing "Mode
* A").
*
* <p>Two calls:
* <p>Calls:
*
* <ul>
* <li>{@link #register} — relays the admin's short-lived Supabase JWT to {@code POST
* /api/v1/account-link/register}; the SaaS side mints + returns a device credential.
* <li>{@link #fetchEntitlement} — authenticates with the stored device credential against {@code
* GET /api/v1/instance/entitlement}; what the local gate consults.
* <li>{@link #reportUsage} — daily usage sync ({@code POST /api/v1/instance/sync}); reports
* cumulative units and returns the refreshed entitlement.
* <li>{@link #revokeSelf} — self-revokes the credential on local unlink ({@code POST
* /api/v1/instance/revoke-self}).
* </ul>
*
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern, see
* {@code AiEngineClient}). The base URL + client are injectable so tests can stub the SaaS
* endpoint.
* <p>Uses {@code java.net.http.HttpClient} (the established self-hosted outbound pattern; see
* {@code AiEngineClient}); base URL + client are injectable so tests can stub SaaS.
*/
@Slf4j
@Service
@@ -86,11 +93,9 @@ public class AccountLinkClient {
}
/**
* Authoritative deny (401/403) from the entitlement endpoint — the device credential is revoked
* or invalid. Distinct from a transport/server failure (which returns {@code null} and fails
* open): the cache must BLOCK billable work on this rather than serve a stale entitled
* snapshot. Unchecked so it propagates cleanly through {@link #fetchEntitlement}'s transport
* try/catch.
* Authoritative deny (401/403) — the device credential is revoked or invalid. Unlike a
* transport/server failure (which returns {@code null} and fails open), the cache must BLOCK on
* this. Unchecked so it propagates through {@link #fetchEntitlement}'s transport try/catch.
*/
public static final class RevokedException extends RuntimeException {
private final int status;
@@ -142,11 +147,9 @@ public class AccountLinkClient {
}
/**
* Revokes this instance's own credential on the SaaS side ({@code POST
* /api/v1/instance/revoke-self}), authenticated by the device credential — a credential is
* allowed to revoke its own identity. Best-effort: returns {@code false} if SaaS is unreachable
* or rejects the call, so the caller (local unlink) can still clear locally and log the orphan
* row for follow-up. Idempotent on SaaS (already-revoked → still 204).
* Revokes this instance's own credential on the SaaS side, authenticated by that credential.
* Best-effort: returns {@code false} if SaaS is unreachable or rejects, so the caller (local
* unlink) can still clear locally and log the orphan for follow-up. Idempotent on SaaS.
*/
public boolean revokeSelf(String deviceId, String deviceSecret) {
try {
@@ -218,6 +221,63 @@ public class AccountLinkClient {
}
}
/**
* Reports the period's cumulative per-category units to {@code POST /api/v1/instance/sync} and
* returns the fresh entitlement in the same reply — one round-trip both reports and refreshes.
* SaaS bills the delta against its last-seen cumulative, so resending the same totals is
* idempotent. Same three outcomes as {@link #fetchEntitlement}; on {@code null} the caller must
* not advance its last-synced markers so the usage retries next sync.
*/
public InstanceEntitlement reportUsage(
String deviceId,
String deviceSecret,
long syncSeq,
LocalDateTime periodStart,
long apiUnits,
long aiUnits,
long automationUnits) {
HttpResponse<String> response;
try {
ObjectNode root = mapper.createObjectNode();
root.put("syncSeq", syncSeq);
// Explicit ISO-8601 string so it round-trips regardless of the mapper's time config.
root.put("periodStart", periodStart.toString());
ObjectNode units = root.putObject("cumulativeUnits");
units.put("api", apiUnits);
units.put("ai", aiUnits);
units.put("automation", automationUnits);
String body = mapper.writeValueAsString(root);
HttpRequest request =
HttpRequest.newBuilder()
.uri(uri("/api/v1/instance/sync"))
.header(HEADER_DEVICE_ID, deviceId)
.header(HEADER_DEVICE_SECRET, deviceSecret)
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout())
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
response = send(request);
} catch (Exception e) {
log.debug("Usage sync failed: {}", e.getMessage());
return null;
}
int status = response.statusCode();
if (status == 401 || status == 403) {
throw new RevokedException(status);
}
if (status / 100 != 2) {
log.debug("Usage sync returned HTTP {}", status);
return null;
}
try {
return parseEntitlement(response.body());
} catch (IOException e) {
log.debug("Usage sync parse failed: {}", e.getMessage());
return null;
}
}
private InstanceEntitlement parseEntitlement(String body) throws IOException {
JsonNode root = mapper.readTree(body);
boolean subscribed = root.path("subscribed").asBoolean(false);
@@ -226,7 +286,45 @@ public class AccountLinkClient {
Long periodCap =
root.hasNonNull("periodCapUnits") ? root.get("periodCapUnits").asLong() : null;
EntitlementState state = mapState(root.path("state").asText(null));
return new InstanceEntitlement(subscribed, freeRemaining, periodSpend, periodCap, state);
return new InstanceEntitlement(
subscribed,
freeRemaining,
periodSpend,
periodCap,
state,
parseUnitCalcPolicy(root),
parseDateTime(root, "periodStart"),
parseDateTime(root, "periodEnd"));
}
/** Parses the nested unit-calc policy; null if absent or any knob is invalid (e.g. zero). */
private static UnitCalcPolicy parseUnitCalcPolicy(JsonNode root) {
if (!root.hasNonNull("unitCalcPolicy")) {
return null;
}
JsonNode node = root.get("unitCalcPolicy");
try {
return new UnitCalcPolicy(
node.path("docPagesPerUnit").asInt(),
node.path("docBytesPerUnit").asLong(),
node.path("minChargeUnits").asInt(),
node.path("fileUnitCap").asInt());
} catch (RuntimeException e) {
// Malformed policy → degrade to "none" rather than fail the whole entitlement parse.
return null;
}
}
/** ISO date-time field → LocalDateTime; null if absent or unparseable. */
private static LocalDateTime parseDateTime(JsonNode root, String field) {
if (!root.hasNonNull(field)) {
return null;
}
try {
return LocalDateTime.parse(root.get(field).asText(null));
} catch (RuntimeException e) {
return null;
}
}
/** Maps the SaaS state string to our coarse enum; unrecognised → UNKNOWN. */
@@ -2,6 +2,7 @@ package stirling.software.proprietary.accountlink;
import java.io.IOException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
@@ -23,7 +24,9 @@ import lombok.extern.slf4j.Slf4j;
* <p>The portal (served from this same origin, admin authenticated by the existing self-hosted
* security chain) calls these. {@code POST /link} relays the admin's Supabase JWT to the SaaS
* backend, which mints + returns a device credential we store locally. {@code GET /status} backs
* the portal's link card.
* the portal's link card; {@code GET /usage} exposes locally-accrued unsynced usage the portal adds
* to SaaS-synced spend; {@code POST /sync-now} forces an immediate usage sync (ops "reconcile now"
* / test aid).
*
* <p>Admin-only, {@code @Profile("!saas")}, gated behind {@code
* stirling.billing.account-link.enabled} — off → bean absent → 404.
@@ -38,9 +41,17 @@ import lombok.extern.slf4j.Slf4j;
public class AccountLinkController {
private final AccountLinkService service;
private final LocalUsageService localUsageService;
// Present only when metering is on (its own flag); absent → /sync-now reports 409.
private final ObjectProvider<UsageSyncService> syncServiceProvider;
public AccountLinkController(AccountLinkService service) {
public AccountLinkController(
AccountLinkService service,
LocalUsageService localUsageService,
ObjectProvider<UsageSyncService> syncServiceProvider) {
this.service = service;
this.localUsageService = localUsageService;
this.syncServiceProvider = syncServiceProvider;
}
/** {@code supabaseJwt} is the admin's short-lived token the portal already holds. */
@@ -85,4 +96,29 @@ public class AccountLinkController {
service.unlink();
return ResponseEntity.noContent().build();
}
/**
* Locally accrued usage not yet reported to SaaS — the portal adds it to the SaaS-synced spend
* so "current usage" includes work done since the last daily sync.
*/
@GetMapping("/usage")
public ResponseEntity<LocalUsageService.LocalUsage> usage() {
return ResponseEntity.ok(localUsageService.currentPeriodUnsynced());
}
/**
* Forces an immediate usage sync to SaaS — the same work the daily scheduler does. An admin
* "reconcile now" action (and a test aid so you don't wait on the scheduler). Idempotent:
* re-reports the current cumulative, so a repeat trigger bills nothing. {@code 204} once run;
* {@code 409} when metering is off (the sync bean is absent).
*/
@PostMapping("/sync-now")
public ResponseEntity<Void> syncNow() {
UsageSyncService sync = syncServiceProvider.getIfAvailable();
if (sync == null) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
sync.syncNow();
return ResponseEntity.noContent().build();
}
}
@@ -1,5 +1,7 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@@ -36,4 +38,39 @@ public class AccountLinkProperties {
/** Connect/read timeout for the outbound SaaS calls. */
private int requestTimeoutSeconds = 10;
/** Phase 2 usage metering + daily sync. Keyed under {@code …account-link.metering.*}. */
private final Metering metering = new Metering();
/**
* Dedicated billing switch, <b>separate</b> from {@link #enabled} so the link plumbing can be
* enabled (e.g. to test linking) without ever turning on real usage metering, reporting, or cap
* enforcement. Both default off; metering requires the master flag too. This is the production
* safety key — flipping it on is what actually bills linked instances.
*/
@Getter
@Setter
public static class Metering {
/** Turns on usage metering, the daily sync, and cap enforcement. Default off. */
private boolean enabled = false;
/**
* How often the instance syncs usage + refreshes entitlement (matches the licence sync).
*/
private int syncIntervalHours = 24;
/**
* Block billable work after this many days with no successful sync (fail-open → closed).
*/
private int graceDays = 3;
/**
* Dedup window for identical input sets. A re-run of the same inputs within this window is
* treated as workflow chaining and not re-charged; the same inputs run again after it are
* billed afresh. Mirrors the cloud's {@code payg.lineage.workflow-window} so the same op
* costs the same on the instance and in the cloud.
*/
private Duration workflowWindow = Duration.ofMinutes(5);
}
}
@@ -0,0 +1,46 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Singleton row holding this instance's daily-sync bookkeeping (combined-billing "Mode A").
*
* <p>{@link #lastSyncSeq} is reserved (incremented + persisted) <em>before</em> each report so it
* is strictly monotonic across restarts and partial failures — SaaS dedups replays by comparing it,
* so a never-decreasing seq is the contract. {@link #lastSuccessAt} is the wall-clock of the last
* sync SaaS accepted and drives the fail-open→closed grace window.
*
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated sync.
*/
@Entity
@Table(name = "account_link_sync_state")
@Getter
@Setter
@NoArgsConstructor
public class AccountLinkSyncState {
/** One instance links to one team → one bookkeeping row. */
public static final long SINGLETON_ID = 1L;
@Id private Long id;
// columnDefinition default keeps the ddl-auto ADD COLUMN safe on a populated external Postgres.
@Column(
name = "last_sync_seq",
nullable = false,
columnDefinition = "bigint not null default 0")
private long lastSyncSeq;
/** Null until the first sync SaaS accepts. */
@Column(name = "last_success_at")
private LocalDateTime lastSuccessAt;
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.accountlink;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the singleton {@link AccountLinkSyncState} (combined-billing "Mode A"). */
public interface AccountLinkSyncStateRepository extends JpaRepository<AccountLinkSyncState, Long> {}
@@ -3,14 +3,26 @@ package stirling.software.proprietary.accountlink;
import jakarta.servlet.http.HttpServletRequest;
import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.BillingCategoryClassifier;
/**
* Classifies a request as <b>billable</b> (AI / automation) or free (a manual tool).
* Buckets a request into a {@link BillingCategory} for the account-link gate + meter, using only
* HTTP-level signals (no dependency on the saas module):
*
* <p>Mirrors the saas billing categorisation at a coarse level, without depending on the saas
* module: billable = the AI surface ({@code /api/v1/ai/**}) or any request carrying the automation
* marker header ({@link InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy
* sub-steps). Everything else — interactive manual PDF tools — is always free.
* <ul>
* <li><b>AUTOMATION</b> — the automation marker header ({@link
* InternalApiClient#AUTOMATION_HEADER}, set on pipeline / workflow / policy sub-steps);
* <li><b>AI</b> — the AI surface ({@code /api/v1/ai/**});
* <li><b>API</b> — an API-key authenticated tool call;
* <li><b>BYPASSED</b> — a manual interactive tool call, never billed.
* </ul>
*
* <p>Same precedence as the SaaS classifier (AUTOMATION → AI → API → BYPASSED) via the shared
* {@link BillingCategoryClassifier}; the AI signal is resolved by path prefix rather than the
* saas-only {@code @RequiresFeature} annotation. The {@code apiKey} signal is supplied by the
* caller (resolved from the security context), so this class stays free of any security-type
* dependency.
*/
public final class BillableOperationClassifier {
@@ -18,16 +30,22 @@ public final class BillableOperationClassifier {
private BillableOperationClassifier() {}
public static boolean isBillable(HttpServletRequest request) {
if (request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null) {
return true;
}
/**
* @param apiKey whether the request authenticated via an API key (an {@code
* ApiKeyAuthenticationToken} principal), resolved by the caller from the security context.
*/
public static BillingCategory categorize(HttpServletRequest request, boolean apiKey) {
boolean automation = request.getHeader(InternalApiClient.AUTOMATION_HEADER) != null;
return BillingCategoryClassifier.classify(automation, isAiSurface(request), apiKey);
}
private static boolean isAiSurface(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null) {
return false;
}
// Prefix-match the AI surface (not a loose substring contains), stripping a deployment
// context path so /<ctx>/api/v1/ai/** still classifies as billable.
// context path so /<ctx>/api/v1/ai/** still classifies as AI.
String ctx = request.getContextPath();
String path =
ctx != null && !ctx.isEmpty() && uri.startsWith(ctx)
@@ -12,18 +12,13 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Caches the linked team's entitlement so the request-time gate does not call the SaaS backend on
* every billable request. Single-slot (one instance = one linked team), TTL-based.
* Caches the linked team's entitlement so the request-time gate needn't call SaaS on every billable
* request. Single-slot (one instance = one linked team), TTL-based.
*
* <p>Fail-open friendly for TRANSPORT failures: {@link #current()} returns the freshest snapshot it
* has, even if a refresh just failed; it returns {@link Optional#empty()} only when nothing has
* ever been fetched <i>and</i> the latest refresh failed (the gate treats empty as "unknown →
* allow").
*
* <p>But an AUTHORITATIVE deny (revoked/invalid credential → {@link
* AccountLinkClient.RevokedException}) is NOT a transport failure: the snapshot is replaced with a
* {@link EntitlementState#REVOKED} blocked entitlement so the gate stops billable work immediately
* rather than serving a stale entitled snapshot.
* <p>A transport failure fails open — {@link #current()} keeps serving the freshest snapshot it has
* and returns {@link Optional#empty()} ("unknown → allow") only when nothing was ever fetched. An
* authoritative deny ({@link AccountLinkClient.RevokedException}) does not: the snapshot is
* replaced with a {@link EntitlementState#REVOKED} entitlement so the gate blocks immediately.
*/
@Slf4j
@Service
@@ -63,9 +58,8 @@ public class EntitlementCache {
* not linked or the SaaS side is unreachable and we have no prior snapshot.
*/
public Optional<InstanceEntitlement> current() {
// Single-flight: when stale, exactly one thread refreshes (blocking on the SaaS
// call) while concurrent callers serve the last snapshot — no thundering herd of
// synchronous round-trips on the billable hot path. Safe because the gate fails open.
// Single-flight: when stale, exactly one thread refreshes while concurrent callers serve
// the last snapshot — no thundering herd of round-trips on the billable hot path.
if (isStale(snapshot) && refreshing.compareAndSet(false, true)) {
try {
refresh();
@@ -77,16 +71,15 @@ public class EntitlementCache {
}
private boolean isStale(Snapshot snap) {
// fetchedAt is the last *attempt* time (stamped on success AND failure), so a failed
// fetch backs off for a full TTL instead of every billable request re-triggering a
// blocking round-trip against a dead/slow SaaS endpoint.
// fetchedAt is the last *attempt* time (stamped on success and failure), so a failed fetch
// backs off a full TTL instead of every request re-triggering a round-trip to a dead SaaS.
return Duration.between(snap.fetchedAt(), Instant.now()).compareTo(ttl) >= 0;
}
/**
* Pulls a fresh snapshot. Keeps the previous entitlement on a TRANSPORT failure (fail-open) but
* still stamps the attempt time so re-fetches throttle to the TTL; on an AUTHORITATIVE deny
* (revoked credential) replaces it with a blocked snapshot so the gate stops billable work.
* Pulls a fresh snapshot. On a transport failure keeps the previous entitlement but stamps the
* attempt time so re-fetches throttle to the TTL; on an authoritative deny replaces it with a
* blocked snapshot.
*/
void refresh() {
Optional<DeviceCredential> cred = credentialStore.get();
@@ -101,15 +94,15 @@ public class EntitlementCache {
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
} else {
// Unreachable / server error: keep the last known entitlement (may be null) but
// stamp the attempt so we don't hammer SaaS; the gate fails open in the meantime.
// Unreachable / server error: keep the last known entitlement but stamp the attempt
// so we don't hammer SaaS; the gate fails open meanwhile.
log.debug(
"Entitlement refresh failed; reusing last known snapshot, backing off a TTL");
snapshot = new Snapshot(snapshot.entitlement(), Instant.now());
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny — credential revoked/invalid. Do NOT fail open: block immediately
// rather than serving the stale entitled snapshot until the next unlink.
// Authoritative deny — block immediately rather than serving the stale entitled
// snapshot.
log.info(
"Entitlement denied (HTTP {}); blocking billable work for the revoked credential",
e.status());
@@ -121,4 +114,14 @@ public class EntitlementCache {
public void invalidate() {
snapshot = new Snapshot(snapshot.entitlement(), Instant.EPOCH);
}
/**
* Seeds the cache with an entitlement obtained out-of-band (the sync reply carries a fresh
* one), saving a redundant fetch. No-op on null.
*/
public void accept(InstanceEntitlement fresh) {
if (fresh != null) {
snapshot = new Snapshot(fresh, Instant.now());
}
}
}
@@ -16,6 +16,11 @@ public record GateDecision(boolean allowed, Reason reason) {
ENTITLED,
/** Entitlement source unreachable — fail open, allow. */
FAIL_OPEN,
/**
* Linked + metering, but SaaS has been unreachable past the grace window — block (the
* fail-open backstop expired) so unbounded free/unbilled billable work can't continue.
*/
GRACE_EXPIRED,
/** Not linked — block billable work; FE should prompt to link. */
NOT_LINKED,
/** Linked but over the limit / no subscription — block billable work. */
@@ -1,19 +1,53 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import stirling.software.proprietary.billing.UnitCalcPolicy;
/**
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response
* just the fields the gate needs. Mirrors the saas {@code EntitlementResponse} shape but carries no
* saas types.
* Cached, proprietary-local view of the SaaS {@code GET /api/v1/instance/entitlement} response.
* Mirrors the saas {@code EntitlementResponse} shape but carries no saas types.
*
* <p>The first five fields are what the <b>gate</b> enforces against; the trailing three are the
* metering inputs (Phase 2) the instance uses to cost + bucket its own usage and reset its
* per-period counters. The 5-arg constructor builds a gate-only view (metering fields null) for the
* revoked sentinel and unit tests that don't exercise metering.
*
* @param subscribed team has an active subscription
* @param freeRemainingUnits remaining free-pool units (>0 means free work is available)
* @param periodSpendUnits paid units spent this period
* @param periodCapUnits paid cap for the period; {@code null} = uncapped
* @param state coarse state classification (see {@link EntitlementState})
* @param unitCalcPolicy doc-unit pricing knobs for local unit computation; {@code null} if not
* supplied (older SaaS / gate-only sentinel)
* @param periodStart inclusive start of the current billing period; {@code null} if not supplied
* @param periodEnd exclusive end of the current billing period; {@code null} if not supplied
*/
public record InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state) {}
EntitlementState state,
UnitCalcPolicy unitCalcPolicy,
LocalDateTime periodStart,
LocalDateTime periodEnd) {
/** Gate-only view with no metering config — used by the revoked sentinel and gate tests. */
public InstanceEntitlement(
boolean subscribed,
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
EntitlementState state) {
this(
subscribed,
freeRemainingUnits,
periodSpendUnits,
periodCapUnits,
state,
null,
null,
null);
}
}
@@ -1,5 +1,6 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -15,14 +16,17 @@ import org.springframework.stereotype.Service;
* <li>Flag off → always allow (feature inert).
* <li>Manual tool → always allow (manual tools are free, never metered).
* <li>Billable + not linked → block with {@code NOT_LINKED} ("link to activate").
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow.
* <li>Billable + linked + entitlement unknown (unreachable) → <b>fail open</b>, allow — unless
* metering is on and SaaS has been unreachable past the grace window, then block with {@code
* GRACE_EXPIRED} so the fail-open can't grant unbounded free/unbilled work forever.
* <li>Billable + linked + entitled → allow.
* <li>Billable + linked + credential revoked → block with {@code REVOKED}.
* <li>Billable + linked + over limit → block with {@code OVER_LIMIT}.
* </ol>
*
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper just supplies the
* live flag / linked-state / entitlement. This is the unit-tested core.
* <p>The decision logic is the pure static {@link #decide}; the Spring wrapper supplies the live
* flag / linked-state / entitlement and computes whether the grace window has expired. This is the
* unit-tested core.
*/
@Service
@Profile("!saas")
@@ -32,14 +36,20 @@ public class InstanceEntitlementGate {
private final AccountLinkProperties properties;
private final DeviceCredentialStore credentialStore;
private final EntitlementCache entitlementCache;
private final AccountLinkSyncStateRepository syncStateRepository;
private final LocalUsageService localUsageService;
public InstanceEntitlementGate(
AccountLinkProperties properties,
DeviceCredentialStore credentialStore,
EntitlementCache entitlementCache) {
EntitlementCache entitlementCache,
AccountLinkSyncStateRepository syncStateRepository,
LocalUsageService localUsageService) {
this.properties = properties;
this.credentialStore = credentialStore;
this.entitlementCache = entitlementCache;
this.syncStateRepository = syncStateRepository;
this.localUsageService = localUsageService;
}
/** Evaluates the gate for a request, resolving live state from the store + cache. */
@@ -53,18 +63,39 @@ public class InstanceEntitlementGate {
boolean linked = credentialStore.isLinked();
Optional<InstanceEntitlement> entitlement =
linked ? entitlementCache.current() : Optional.empty();
return decide(true, true, linked, entitlement);
boolean graceExpired = linked && entitlement.isEmpty() && isGraceExpired();
// Deplete the applicable ceiling — free grant (unsubscribed) or spend cap (capped
// subscription) — by local usage not yet synced, so the gate stops in real time instead of
// overshooting until the next sync. An uncapped subscription has no ceiling to deplete → 0.
long pendingUnsynced =
entitlement.map(InstanceEntitlementGate::depletesCeiling).orElse(false)
? localUsageService.currentPeriodUnsynced().totalUnsyncedUnits()
: 0L;
return decide(true, true, linked, entitlement, graceExpired, pendingUnsynced);
}
/** Whether local unsynced usage pushes against a real ceiling (free grant or a spend cap). */
private static boolean depletesCeiling(InstanceEntitlement e) {
return !e.subscribed() || e.periodCapUnits() != null;
}
/**
* Pure decision function — no Spring, no I/O. {@code entitlement} empty means "unknown"
* (unreachable): when linked, that fails open.
* (unreachable): when linked, that fails open unless {@code graceExpired} (the metering grace
* window elapsed with no authoritative contact), in which case it blocks.
*
* @param pendingUnsyncedUnits billable units accrued locally since the last sync — depletes the
* free grant (unsubscribed) or the spend cap (capped subscription) in real time so the gate
* stops without waiting for the next sync (0 for uncapped-subscribed / unknown-entitlement
* cases, where it has no effect).
*/
public static GateDecision decide(
boolean flagEnabled,
boolean billable,
boolean linked,
Optional<InstanceEntitlement> entitlement) {
Optional<InstanceEntitlement> entitlement,
boolean graceExpired,
long pendingUnsyncedUnits) {
if (!flagEnabled) {
return GateDecision.allow(GateDecision.Reason.FLAG_OFF);
}
@@ -75,30 +106,69 @@ public class InstanceEntitlementGate {
return GateDecision.block(GateDecision.Reason.NOT_LINKED);
}
if (entitlement.isEmpty()) {
// Linked but entitlement source unreachable — never hard-block billable work on our
// inability to reach billing.
return GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
// Linked but entitlement unreachable: fail open, unless the grace window has expired
// (so
// the fail-open can't grant unbounded unbilled work forever).
return graceExpired
? GateDecision.block(GateDecision.Reason.GRACE_EXPIRED)
: GateDecision.allow(GateDecision.Reason.FAIL_OPEN);
}
InstanceEntitlement e = entitlement.get();
if (e.state() == EntitlementState.REVOKED) {
// Credential revoked/invalid (authoritative deny) — block, distinct from over-limit.
return GateDecision.block(GateDecision.Reason.REVOKED);
}
return entitled(e)
return entitled(e, pendingUnsyncedUnits)
? GateDecision.allow(GateDecision.Reason.ENTITLED)
: GateDecision.block(GateDecision.Reason.OVER_LIMIT);
}
/**
* True when metering is on and it's been {@code graceDays} since the last authoritative contact
* (last successful sync, or link time if never synced). {@code graceDays <= 0} or metering off
* disables the backstop.
*/
private boolean isGraceExpired() {
AccountLinkProperties.Metering metering = properties.getMetering();
if (!metering.isEnabled() || metering.getGraceDays() <= 0) {
return false;
}
LocalDateTime reference = lastAuthoritativeContact();
if (reference == null) {
return false; // can't determine elapsed time → fail open
}
return reference.plusDays(metering.getGraceDays()).isBefore(LocalDateTime.now());
}
private LocalDateTime lastAuthoritativeContact() {
LocalDateTime lastSuccess =
syncStateRepository
.findById(AccountLinkSyncState.SINGLETON_ID)
.map(AccountLinkSyncState::getLastSuccessAt)
.orElse(null);
if (lastSuccess != null) {
return lastSuccess;
}
return credentialStore.get().map(DeviceCredential::getLinkedAt).orElse(null);
}
/** True when the snapshot permits billable work (subscribed, free pool left, or within cap). */
private static boolean entitled(InstanceEntitlement e) {
private static boolean entitled(InstanceEntitlement e, long pendingUnsyncedUnits) {
if (e.state() == EntitlementState.OVER_LIMIT || e.state() == EntitlementState.REVOKED) {
return false;
}
if (e.subscribed()) {
// Subscribed: allowed unless a period cap is set and exceeded.
return e.periodCapUnits() == null || e.periodSpendUnits() < e.periodCapUnits();
if (e.periodCapUnits() == null) {
return true; // uncapped subscription
}
// Project the cap the way the grant is projected: synced paid spend plus the paid part
// of local usage not yet synced (free grant is consumed first, so only the excess
// bills) — stops at the cap in real time instead of overshooting until the next sync.
long pendingPaid = Math.max(0, pendingUnsyncedUnits - e.freeRemainingUnits());
return e.periodSpendUnits() + pendingPaid < e.periodCapUnits();
}
// Unsubscribed: only the free pool covers billable work.
return e.freeRemainingUnits() > 0;
// Unsubscribed: free pool must cover SaaS-charged usage (in freeRemainingUnits) plus local
// usage not yet synced — deplete by the pending delta so we stop at the grant in real time.
return e.freeRemainingUnits() - pendingUnsyncedUnits > 0;
}
}
@@ -1,27 +1,51 @@
package stirling.software.proprietary.accountlink;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.WebUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.ContentHasher;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
/**
* Request-time gate for combined-billing "Mode A". Runs before billable (AI / automation) work and
* blocks it when the instance is unlinked or over its limit; manual tools pass straight through.
* Request-time gate + meter for combined-billing "Mode A". {@code preHandle} blocks billable (API /
* AI / automation) work when the instance is unlinked or over its limit; manual tools pass through.
* {@code afterCompletion} meters a successful billable op into the per-period cumulative counter.
*
* <p>Blocking responds {@code 402 Payment Required} with a small machine-readable body — {@code
* {"error":"ACCOUNT_LINK_REQUIRED","reason":"NOT_LINKED"}} — that the FE maps to a "link to
* activate" prompt (the same DownstreamEntitlementError-style envelope already used for saas limit
* responses). Fail-open and flag-off both let the request continue.
*
* <p>Gated + {@code @Profile("!saas")}; when the flag is off the bean is absent and the {@link
* AccountLinkWebMvcConfig} never registers it, so there is no per-request cost.
* <p>Blocking responds {@code 402} with a machine-readable body the FE maps to a "link to activate"
* prompt; fail-open and flag-off both let the request continue. Metering is separately gated behind
* {@code …metering.enabled} via {@link ObjectProvider} — switch off means the {@link
* UsageMeterService} bean is absent and nothing accrues, while the gate still works.
*/
@Slf4j
@Component
@@ -29,10 +53,23 @@ import lombok.extern.slf4j.Slf4j;
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceEntitlementInterceptor implements HandlerInterceptor {
private final InstanceEntitlementGate gate;
private static final String ATTR_CATEGORY =
InstanceEntitlementInterceptor.class.getName() + ".category";
public InstanceEntitlementInterceptor(InstanceEntitlementGate gate) {
private final InstanceEntitlementGate gate;
private final EntitlementCache entitlementCache;
private final ObjectProvider<UsageMeterService> meterProvider;
private final TempFileManager tempFileManager;
public InstanceEntitlementInterceptor(
InstanceEntitlementGate gate,
EntitlementCache entitlementCache,
ObjectProvider<UsageMeterService> meterProvider,
TempFileManager tempFileManager) {
this.gate = gate;
this.entitlementCache = entitlementCache;
this.meterProvider = meterProvider;
this.tempFileManager = tempFileManager;
}
@Override
@@ -41,7 +78,13 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
throws Exception {
GateDecision decision;
try {
decision = gate.evaluate(BillableOperationClassifier.isBillable(request));
// API-key tool calls are billable (category API); stash the category for the meter.
boolean apiKey =
SecurityContextHolder.getContext().getAuthentication()
instanceof ApiKeyAuthenticationToken;
BillingCategory category = BillableOperationClassifier.categorize(request, apiKey);
request.setAttribute(ATTR_CATEGORY, category);
decision = gate.evaluate(category != BillingCategory.BYPASSED);
} catch (RuntimeException e) {
// Fail open: an inability to resolve entitlement (e.g. a DB or SaaS blip) must never
// turn into a hard block on billable work.
@@ -62,4 +105,139 @@ public class InstanceEntitlementInterceptor implements HandlerInterceptor {
+ "\"}");
return false;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
// Meter successful billable ops only.
if (ex != null || response.getStatus() >= 400) {
return;
}
UsageMeterService meter = meterProvider.getIfAvailable();
if (meter == null) {
return; // metering switch off
}
if (!(request.getAttribute(ATTR_CATEGORY) instanceof BillingCategory category)
|| category == BillingCategory.BYPASSED) {
return;
}
try {
InstanceEntitlement ent = entitlementCache.current().orElse(null);
if (ent == null || ent.unitCalcPolicy() == null || ent.periodStart() == null) {
// Not yet synced (no policy/period) — can't compute units; skip until next sync.
return;
}
meterRequest(request, category, ent, meter);
} catch (RuntimeException e) {
// Metering must never affect the response that already completed.
log.debug("Usage metering failed for {}", request.getRequestURI(), e);
}
}
/**
* Computes doc-units (page + byte axes) and the input-set signature, then accrues. The instance
* is authoritative for units (SaaS bills the delta and never sees the file), so a page-heavy
* but small PDF must be page-counted or it under-bills. A fileless op has no input identity —
* null signature (no dedup), billed the 1-unit floor each time.
*/
private void meterRequest(
HttpServletRequest request,
BillingCategory category,
InstanceEntitlement ent,
UsageMeterService meter) {
UnitCalcPolicy policy = ent.unitCalcPolicy();
MultipartHttpServletRequest mreq =
WebUtils.getNativeRequest(request, MultipartHttpServletRequest.class);
if (mreq == null) {
long fileless = DocumentUnitCalculator.unitsForFile(0, 0, policy);
meter.accrue(ent.periodStart(), category, fileless, null);
return;
}
List<TempFile> temps = new ArrayList<>();
try {
List<FileSize> sizes = new ArrayList<>();
List<String> hashes = new ArrayList<>();
int fileCount = 0;
for (List<MultipartFile> files : mreq.getMultiFileMap().values()) {
for (MultipartFile f : files) {
fileCount++;
try {
TempFile temp = tempFileManager.createManagedTempFile(".bin");
temps.add(temp);
// Hash in the same pass that writes the temp file — one read of the upload,
// not a second full read just to fingerprint it.
MessageDigest digest = ContentHasher.newSha256();
try (InputStream in = f.getInputStream();
DigestOutputStream out =
new DigestOutputStream(
Files.newOutputStream(temp.getPath()), digest)) {
in.transferTo(out);
}
sizes.add(new FileSize(pageCount(temp.getPath(), f), f.getSize()));
hashes.add(ContentHasher.toHex(digest.digest()));
} catch (IOException | RuntimeException perFile) {
// Couldn't materialise/hash this input — bill on bytes only and, by leaving
// it out of `hashes`, drop dedup for the whole op rather than risk a
// mismatch.
log.debug(
"Metering materialise/hash failed for {}; bytes-only",
f.getOriginalFilename());
sizes.add(new FileSize(0, f.getSize()));
}
}
}
long units =
sizes.isEmpty()
? DocumentUnitCalculator.unitsForFile(0, 0, policy)
: DocumentUnitCalculator.unitsForGroup(sizes, policy);
// Only dedup when every input hashed; a partial signature could collide with a
// different input set, so fall back to no-dedup (bill it) if any file failed.
String opSignature =
fileCount > 0 && hashes.size() == fileCount ? opSignature(hashes) : null;
meter.accrue(ent.periodStart(), category, units, opSignature);
} finally {
for (TempFile temp : temps) {
try {
temp.close();
} catch (RuntimeException cleanup) {
log.debug("Temp file cleanup failed: {}", cleanup.getMessage());
}
}
}
}
/** Page count via jpdfium (parser-identical to SaaS); 0 for non-PDF / unreadable inputs. */
private static int pageCount(Path path, MultipartFile file) {
if (!isPdf(file)) {
return 0;
}
try (PdfDocument doc = PdfDocument.open(path)) {
return doc.pageCount();
} catch (RuntimeException e) {
// Malformed / encrypted → byte axis only, matching the SaaS classifier.
log.debug(
"Page count unavailable for {}; metering on bytes only",
file.getOriginalFilename());
return 0;
}
}
/** Order-independent signature of the input set: sorted per-file hashes, hashed together. */
private static String opSignature(List<String> hashes) {
List<String> sorted = new ArrayList<>(hashes);
Collections.sort(sorted);
return ContentHasher.sha256(String.join("\n", sorted).getBytes(StandardCharsets.UTF_8));
}
private static boolean isPdf(MultipartFile file) {
String contentType = file.getContentType();
if (contentType != null && contentType.toLowerCase().contains("pdf")) {
return true;
}
String name = file.getOriginalFilename();
return name != null && name.toLowerCase().endsWith(".pdf");
}
}
@@ -0,0 +1,59 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.EnumMap;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Reads this instance's locally accrued but not-yet-synced usage for the current period. The portal
* adds this on top of SaaS-synced spend so "current usage" reflects work done since the last sync.
*
* <p>Unsynced per category = {@code cumulativeUnits lastSyncedUnits} (floored at 0), scoped to
* the current period so prior-period leftovers don't inflate it. Zeros when the period is unknown
* or metering is off.
*/
@Service
@Profile("!saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class LocalUsageService {
private final UsageCounterRepository counters;
private final EntitlementCache entitlementCache;
public LocalUsageService(UsageCounterRepository counters, EntitlementCache entitlementCache) {
this.counters = counters;
this.entitlementCache = entitlementCache;
}
/** Per-category unsynced units for the current period; {@code periodStart} null = unknown. */
public record LocalUsage(
LocalDateTime periodStart,
long apiUnsyncedUnits,
long aiUnsyncedUnits,
long automationUnsyncedUnits,
long totalUnsyncedUnits) {}
public LocalUsage currentPeriodUnsynced() {
LocalDateTime period =
entitlementCache.current().map(InstanceEntitlement::periodStart).orElse(null);
if (period == null) {
return new LocalUsage(null, 0, 0, 0, 0);
}
EnumMap<BillingCategory, Long> unsynced = new EnumMap<>(BillingCategory.class);
for (UsageCounter c : counters.findByPeriodStart(period)) {
BillingCategory cat = c.billingCategory();
if (cat != null && cat != BillingCategory.BYPASSED) {
unsynced.merge(cat, c.unsyncedUnits(), Long::sum);
}
}
long api = unsynced.getOrDefault(BillingCategory.API, 0L);
long ai = unsynced.getOrDefault(BillingCategory.AI, 0L);
long automation = unsynced.getOrDefault(BillingCategory.AUTOMATION, 0L);
return new LocalUsage(period, api, ai, automation, api + ai + automation);
}
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* The last time the instance metered a given input set this period — the local equivalent of the
* cloud's lineage join (combined-billing "Mode A"). The meter dedups on a rolling <b>workflow
* window</b>: an identical input set re-submitted within the window (see {@link
* AccountLinkProperties.Metering}) is treated as workflow chaining and not re-charged, while the
* same inputs run again after the window are billed afresh — matching the cloud's 5-minute open-job
* window so the same operation costs the same on the instance and in the cloud.
*
* <p>{@code lastMeteredAt} is refreshed on every sighting (the window slides, as recording a cloud
* artifact touches its job). One row per {@code (period, signature)}; the unique constraint also
* makes the first-sighting insert an atomic claim under concurrency.
*
* <p>Auto-created by Hibernate ({@code ddl-auto=update}); written only by the flag-gated meter.
*/
@Entity
@Table(
name = "account_link_metered_signature",
uniqueConstraints =
@UniqueConstraint(
name = "uk_account_link_metered_signature",
columnNames = {"period_start", "signature"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MeteredInputSignature {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "period_start", nullable = false)
private LocalDateTime periodStart;
/** SHA-256 hex of the op's input set (64 chars); the dedup key within a period. */
@Column(name = "signature", nullable = false, length = 64)
private String signature;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
/**
* When this input set was last metered — the anchor the workflow-window dedup compares against.
*/
@Column(name = "last_metered_at")
private LocalDateTime lastMeteredAt;
public MeteredInputSignature(LocalDateTime periodStart, String signature, LocalDateTime at) {
this.periodStart = periodStart;
this.signature = signature;
this.createdAt = at;
this.lastMeteredAt = at;
}
/** Slides the window forward — the input set was seen again. */
public void touch(LocalDateTime at) {
this.lastMeteredAt = at;
}
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
/** Persistence for the per-period metered input-set signatures (combined-billing "Mode A"). */
public interface MeteredInputSignatureRepository
extends JpaRepository<MeteredInputSignature, Long> {
/** The existing row for a seen input set, so the meter can apply the workflow-window check. */
Optional<MeteredInputSignature> findByPeriodStartAndSignature(
LocalDateTime periodStart, String signature);
}
@@ -0,0 +1,105 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Durable per-(billing period, category) cumulative usage counter for combined-billing "Mode A".
* Each successful billable op increments its row; the daily sync reports the cumulative totals and
* SaaS bills the delta since the last sync. The cumulative model is idempotent (a resend bills
* nothing) and tamper-evident (a counter that drops is a signal). One row per {@code (period_start,
* category)}, auto-created by Hibernate; only the flag-gated {@link UsageMeterService} writes it.
*/
@Entity
@Table(
name = "account_link_usage_counter",
uniqueConstraints =
@UniqueConstraint(
name = "uk_usage_counter_period_category",
columnNames = {"period_start", "category"}))
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UsageCounter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* Inclusive start of the billing period this counter belongs to (from the entitlement sync).
*/
@Column(name = "period_start", nullable = false)
private LocalDateTime periodStart;
/** {@code BillingCategory} name — API / AI / AUTOMATION (never BYPASSED). */
@Column(name = "category", nullable = false, length = 32)
private String category;
/** Running total of metered units in this period+category. */
@Column(name = "cumulative_units", nullable = false)
private long cumulativeUnits;
/**
* {@link #cumulativeUnits} as of the last sync SaaS accepted; the difference is the unreported
* usage the portal shows on top of SaaS-synced spend. The {@code columnDefinition} default
* keeps the {@code ddl-auto=update} ADD COLUMN safe against a table an earlier build already
* populated (NOT NULL with no default would fail the ALTER).
*/
@Column(
name = "last_synced_units",
nullable = false,
columnDefinition = "bigint not null default 0")
private long lastSyncedUnits;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/** Fresh-accrual row: nothing synced yet. */
public UsageCounter(
LocalDateTime periodStart,
String category,
long cumulativeUnits,
LocalDateTime updatedAt) {
this(periodStart, category, cumulativeUnits, 0L, updatedAt);
}
public UsageCounter(
LocalDateTime periodStart,
String category,
long cumulativeUnits,
long lastSyncedUnits,
LocalDateTime updatedAt) {
this.periodStart = periodStart;
this.category = category;
this.cumulativeUnits = cumulativeUnits;
this.lastSyncedUnits = lastSyncedUnits;
this.updatedAt = updatedAt;
}
/** This row's category as the enum, or {@code null} for an unrecognised stored value. */
public BillingCategory billingCategory() {
try {
return BillingCategory.valueOf(category);
} catch (IllegalArgumentException unknown) {
return null;
}
}
/** Units accrued but not yet accepted by SaaS (floored at 0). */
public long unsyncedUnits() {
return Math.max(0, cumulativeUnits - lastSyncedUnits);
}
}
@@ -0,0 +1,57 @@
package stirling.software.proprietary.accountlink;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
/** Persistence for the per-period/per-category usage counters (combined-billing "Mode A"). */
public interface UsageCounterRepository extends JpaRepository<UsageCounter, Long> {
/**
* Atomically adds {@code delta} to an existing counter row. Returns the number of rows updated
* (0 when the row doesn't exist yet — the caller then inserts). Doing the add in SQL avoids a
* read-modify-write race between concurrent billable requests.
*/
@Modifying
@Transactional
@Query(
"UPDATE UsageCounter c SET c.cumulativeUnits = c.cumulativeUnits + :delta,"
+ " c.updatedAt = :now"
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
int increment(
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category,
@Param("delta") long delta,
@Param("now") LocalDateTime now);
/** All counters for a period — the daily sync reads these to report cumulative totals. */
List<UsageCounter> findByPeriodStart(LocalDateTime periodStart);
/**
* Periods (oldest first) that still hold usage not yet accepted by SaaS. The sync reports each
* so end-of-period usage isn't stranded when the billing period rolls over between syncs.
*/
@Query(
"SELECT DISTINCT c.periodStart FROM UsageCounter c"
+ " WHERE c.cumulativeUnits > c.lastSyncedUnits ORDER BY c.periodStart")
List<LocalDateTime> findPeriodsWithUnsyncedUsage();
/**
* Marks a counter synced up to {@code syncedUnits} (the cumulative value just accepted by
* SaaS), not the live cumulative — concurrent accruals during the sync stay correctly unsynced.
*/
@Modifying
@Transactional
@Query(
"UPDATE UsageCounter c SET c.lastSyncedUnits = :syncedUnits"
+ " WHERE c.periodStart = :periodStart AND c.category = :category")
int markSynced(
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category,
@Param("syncedUnits") long syncedUnits);
}
@@ -0,0 +1,115 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.LocalDateTime;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Accrues metered usage into the durable per-(period, category) {@link UsageCounter}; the daily
* sync later reports the cumulative totals to SaaS.
*
* <p>Workflow-window dedup: an identical input set re-submitted within {@code metering.workflow-
* window} is treated as chaining and not re-charged; the same inputs run again after the window are
* billed afresh — matching the cloud's open-job lineage window so the same op costs the same on the
* instance and in the cloud. Fileless ops pass a null signature and always accrue. {@link #accrue}
* is best-effort: callers need not handle persistence errors.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(
name = "stirling.billing.account-link.metering.enabled",
havingValue = "true")
public class UsageMeterService {
private final UsageCounterRepository repo;
private final MeteredInputSignatureRepository signatureRepo;
private final Duration workflowWindow;
public UsageMeterService(
UsageCounterRepository repo,
MeteredInputSignatureRepository signatureRepo,
AccountLinkProperties properties) {
this.repo = repo;
this.signatureRepo = signatureRepo;
this.workflowWindow = properties.getMetering().getWorkflowWindow();
}
/**
* Adds {@code units} to the {@code (periodStart, category)} counter (creating the row on first
* use), unless {@code opSignature} was already metered this period. No-ops for non-billable
* categories, non-positive units, or a missing period.
*/
public void accrue(
LocalDateTime periodStart, BillingCategory category, long units, String opSignature) {
if (periodStart == null
|| category == null
|| category == BillingCategory.BYPASSED
|| units <= 0) {
return;
}
if (opSignature != null && !shouldCharge(periodStart, opSignature)) {
return; // identical inputs seen within the workflow window — chaining, already billed
}
incrementOrInsert(periodStart, category.name(), units);
}
/**
* True when this input set should be charged: unseen this period, or last seen outside the
* workflow window. Records a first sighting (an atomic insert-as-claim under concurrency) and
* slides the window on a repeat. Fails toward charging so a store hiccup never drops a charge.
*/
private boolean shouldCharge(LocalDateTime periodStart, String opSignature) {
LocalDateTime now = LocalDateTime.now();
MeteredInputSignature seen =
signatureRepo.findByPeriodStartAndSignature(periodStart, opSignature).orElse(null);
if (seen == null) {
try {
signatureRepo.saveAndFlush(
new MeteredInputSignature(periodStart, opSignature, now));
return true; // first sighting this period
} catch (DataIntegrityViolationException raced) {
return false; // a concurrent op just claimed it — within window → chaining
} catch (RuntimeException e) {
log.debug("Signature claim failed for {}: {}", periodStart, e.getMessage());
return true;
}
}
LocalDateTime last = seen.getLastMeteredAt() != null ? seen.getLastMeteredAt() : now;
boolean withinWindow = last.isAfter(now.minus(workflowWindow));
try {
seen.touch(now);
signatureRepo.save(seen);
} catch (RuntimeException e) {
log.debug("Signature touch failed for {}: {}", periodStart, e.getMessage());
}
return !withinWindow;
}
private void incrementOrInsert(LocalDateTime periodStart, String category, long units) {
LocalDateTime now = LocalDateTime.now();
try {
if (repo.increment(periodStart, category, units, now) > 0) {
return;
}
try {
repo.saveAndFlush(new UsageCounter(periodStart, category, units, now));
} catch (DataIntegrityViolationException raceLostInsert) {
// A concurrent request inserted the row first — increment the now-existing row.
repo.increment(periodStart, category, units, now);
}
} catch (RuntimeException e) {
// Metering must never break the request it rode in on; a lost accrual self-heals on the
// next increment and the daily sync reports the cumulative total either way.
log.debug("Usage accrual failed for {}/{}: {}", periodStart, category, e.getMessage());
}
}
}
@@ -0,0 +1,189 @@
package stirling.software.proprietary.accountlink;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.EnumMap;
import java.util.List;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.FixedDelayTask;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.BillingCategory;
/**
* Daily usage sender for combined-billing "Mode A". Reports each period's cumulative per-category
* usage to SaaS, which bills the delta against its own last-seen totals.
*
* <p>Resilience: the sync seq is persisted before the report so it never regresses across
* restarts/failures; a transport failure leaves the {@code lastSyncedUnits} markers untouched so
* usage rolls into the next sync; and reporting the same cumulative twice bills nothing. All
* periods with unsynced usage are reported so nothing is stranded when the period rolls over
* between syncs.
*/
@Slf4j
@Service
@Profile("!saas")
@ConditionalOnProperty(
name = "stirling.billing.account-link.metering.enabled",
havingValue = "true")
public class UsageSyncService implements SchedulingConfigurer {
// First run waits out startup churn; then every interval.
private static final Duration INITIAL_DELAY = Duration.ofMinutes(5);
private final UsageCounterRepository counters;
private final AccountLinkSyncStateRepository syncState;
private final DeviceCredentialStore credentialStore;
private final AccountLinkClient client;
private final EntitlementCache entitlementCache;
private final AccountLinkProperties properties;
public UsageSyncService(
UsageCounterRepository counters,
AccountLinkSyncStateRepository syncState,
DeviceCredentialStore credentialStore,
AccountLinkClient client,
EntitlementCache entitlementCache,
AccountLinkProperties properties) {
this.counters = counters;
this.syncState = syncState;
this.credentialStore = credentialStore;
this.client = client;
this.entitlementCache = entitlementCache;
this.properties = properties;
}
/**
* Registers the daily sync, binding the interval from {@code metering.sync-interval-hours} in
* code rather than a {@code @Scheduled} SpEL string so a bad interval fails at boot/test rather
* than only on a flags-on run.
*/
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
Duration interval = Duration.ofHours(properties.getMetering().getSyncIntervalHours());
registrar.addFixedDelayTask(
new FixedDelayTask(this::scheduledSync, interval, INITIAL_DELAY));
}
public void scheduledSync() {
try {
syncNow();
} catch (RuntimeException e) {
log.debug("Scheduled usage sync failed", e);
}
}
/**
* Reports every period with unsynced usage and refreshes the cached entitlement from the reply.
* Single daily caller (non-reentrant {@code fixedDelay}), so no internal locking. No-op when
* unlinked or when nothing is pending.
*/
public void syncNow() {
Optional<DeviceCredential> cred = credentialStore.get();
if (cred.isEmpty()) {
return; // not linked
}
List<LocalDateTime> periods = counters.findPeriodsWithUnsyncedUsage();
if (periods.isEmpty()) {
// Nothing to report, but a sync is also our cue to pick up an out-of-band entitlement
// change (e.g. the admin just subscribed) that otherwise wouldn't surface until the
// cache TTL lapses. Force an immediate refresh so the gate reflects the new plan now.
entitlementCache.invalidate();
entitlementCache.current();
return;
}
InstanceEntitlement latest = null;
try {
for (LocalDateTime period : periods) {
InstanceEntitlement fresh = syncPeriod(cred.get(), period);
if (fresh != null) {
latest = fresh;
}
}
} catch (AccountLinkClient.RevokedException e) {
// Authoritative deny — stop reporting; the entitlement cache blocks billable work on
// its
// own next refresh, so we don't synthesise the blocked state here.
log.info(
"Usage sync denied (HTTP {}); credential revoked/invalid — gate blocks on next"
+ " refresh",
e.status());
return;
}
// Adopt the freshest entitlement the sync returned, saving the cache a redundant fetch.
entitlementCache.accept(latest);
}
/** Reports one period; returns the fresh entitlement, or null on a transport/server failure. */
private InstanceEntitlement syncPeriod(DeviceCredential cred, LocalDateTime period) {
EnumMap<BillingCategory, Long> cumulative = new EnumMap<>(BillingCategory.class);
for (UsageCounter c : counters.findByPeriodStart(period)) {
BillingCategory cat = c.billingCategory();
if (cat != null && cat != BillingCategory.BYPASSED) {
cumulative.merge(cat, c.getCumulativeUnits(), Long::sum);
}
}
AccountLinkSyncState state = loadState();
long seq = reserveNextSeq(state);
InstanceEntitlement fresh =
client.reportUsage(
cred.getDeviceId(),
cred.getDeviceSecret(),
seq,
period,
cumulative.getOrDefault(BillingCategory.API, 0L),
cumulative.getOrDefault(BillingCategory.AI, 0L),
cumulative.getOrDefault(BillingCategory.AUTOMATION, 0L));
if (fresh == null) {
// Transport/server failure: leave the synced markers untouched. The burned seq is
// harmless (seqs need only be monotonic) and the delta bills on the next successful
// sync.
return null;
}
recordSuccess(period, cumulative, state);
return fresh;
}
/** Reserves and persists the next strictly-increasing sequence before the report goes out. */
private long reserveNextSeq(AccountLinkSyncState state) {
long next = state.getLastSyncSeq() + 1;
state.setLastSyncSeq(next);
syncState.save(state);
return next;
}
/**
* Advances the per-category synced markers to the reported totals + stamps the success time.
*/
private void recordSuccess(
LocalDateTime period,
EnumMap<BillingCategory, Long> cumulative,
AccountLinkSyncState state) {
cumulative.forEach(
(category, units) -> {
if (units > 0) {
counters.markSynced(period, category.name(), units);
}
});
state.setLastSuccessAt(LocalDateTime.now());
syncState.save(state);
}
private AccountLinkSyncState loadState() {
return syncState
.findById(AccountLinkSyncState.SINGLETON_ID)
.orElseGet(
() -> {
AccountLinkSyncState s = new AccountLinkSyncState();
s.setId(AccountLinkSyncState.SINGLETON_ID);
return s;
});
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.billing;
/**
* The billing / analytics axis for a metered operation. PAYG runs on a single flat-priced meter, so
* category is metadata only and never affects price.
*
* <p>Classification precedence is {@code AUTOMATION → AI → API → BYPASSED} (see {@link
* BillingCategoryClassifier}); {@link #BYPASSED} is a manual interactive tool call that is never
* billed.
*
* <p>Mirrors the value set of the SaaS {@code payg.model.BillingCategory}. A linked self-hosted
* instance reports usage per category to SaaS as the lower-case names ({@code api} / {@code ai} /
* {@code automation}) in the daily sync, and SaaS maps them back — so the two enums must keep the
* same names. (We deliberately do not share one enum across the modules: that would drag the SaaS
* billing enum through ~20 hot-path files for what is JSON-string metadata on the wire.)
*/
public enum BillingCategory {
BYPASSED,
API,
AI,
AUTOMATION
}
@@ -0,0 +1,29 @@
package stirling.software.proprietary.billing;
/**
* Pure precedence for bucketing a request into a {@link BillingCategory}, so the SaaS engine and a
* linked self-hosted instance classify identically. Each backend resolves the three signals from
* its own types — the automation marker header; an AI-surface signal (a {@code @RequiresFeature}
* annotation / route on SaaS, a path prefix on the instance); API-key authentication — and this
* applies the order {@code AUTOMATION → AI → API → BYPASSED}.
*
* <p>An AI tool dispatched inside a pipeline / workflow therefore bills as {@code AUTOMATION} (the
* automation header dominates), while a direct call to it bills as {@code AI}.
*/
public final class BillingCategoryClassifier {
private BillingCategoryClassifier() {}
public static BillingCategory classify(boolean automation, boolean ai, boolean apiKey) {
if (automation) {
return BillingCategory.AUTOMATION;
}
if (ai) {
return BillingCategory.AI;
}
if (apiKey) {
return BillingCategory.API;
}
return BillingCategory.BYPASSED;
}
}
@@ -0,0 +1,68 @@
package stirling.software.proprietary.billing;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/**
* SHA-256 content fingerprint shared by the SaaS charge path and the linked self-hosted instance's
* meter (combined-billing "Mode A"), so both derive an <em>identical</em> signature for the same
* bytes — the basis for lineage dedup. Pure, no Spring: fixed 64 KiB buffer (allocation independent
* of file size), hardware-accelerated by the JVM where available.
*
* <p>Lives in {@code :proprietary} (not {@code :common}) so it stays out of the community core
* build yet is reachable from {@code :saas} (which depends on {@code :proprietary}).
*/
public final class ContentHasher {
private static final String ALGORITHM = "SHA-256";
private static final int BUFFER_SIZE = 64 * 1024;
private ContentHasher() {}
/** Lower-case hex SHA-256 of the file's bytes. */
public static String sha256(Path file) throws IOException {
MessageDigest digest = newDigest();
try (InputStream raw = Files.newInputStream(file);
DigestInputStream in = new DigestInputStream(raw, digest)) {
byte[] buf = new byte[BUFFER_SIZE];
while (in.read(buf) != -1) {
// drain through the digest; we only want the side effect
}
}
return HexFormat.of().formatHex(digest.digest());
}
/** Lower-case hex SHA-256 of the given bytes (e.g. to combine per-file hashes into one key). */
public static String sha256(byte[] bytes) {
return HexFormat.of().formatHex(newDigest().digest(bytes));
}
/**
* A fresh SHA-256 digest, for callers that stream bytes through a {@link
* java.security.DigestOutputStream} to hash in the same pass that writes the file — avoiding a
* second full read just to fingerprint it. Pair with {@link #toHex(byte[])}.
*/
public static MessageDigest newSha256() {
return newDigest();
}
/** Lower-case hex of a completed digest — the same format {@link #sha256(Path)} produces. */
public static String toHex(byte[] digest) {
return HexFormat.of().formatHex(digest);
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance(ALGORITHM);
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by every JDK; unreachable in practice.
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
}
}
}
@@ -0,0 +1,76 @@
package stirling.software.proprietary.billing;
import java.util.List;
/**
* Pure doc-unit math shared by the SaaS billing engine and a linked self-hosted instance, so both
* cost an operation identically. No Spring, no IO: callers supply page/byte facts (read however
* their backend reads them — e.g. jpdfium for PDFs) plus a {@link UnitCalcPolicy}.
*
* <p>Raw units for one file = the larger of {@code ceil(pages / docPagesPerUnit)} and {@code
* ceil(bytes / docBytesPerUnit)} (non-PDF inputs pass {@code pages = 0}, so only the bytes axis
* contributes). A single file is clamped to {@code [1, fileUnitCap]}; a multi-file group is the
* <em>raw</em> per-file sum clamped to {@code [1, fileUnitCap * file_count]} (summing raw, not
* per-file-clamped, units so the group cap can actually bind).
*
* <p>{@link UnitCalcPolicy#minChargeUnits()} is applied by the charge layer, not here; this
* enforces only an absolute floor of {@link #MIN_UNITS_PER_NONEMPTY_FILE} so callers can rely on
* "non-empty input → at least 1 unit". Extracted verbatim from the SaaS {@code
* DefaultDocumentClassifier} to preserve behaviour.
*/
public final class DocumentUnitCalculator {
/** Floor for non-empty input. Distinct from {@link UnitCalcPolicy#minChargeUnits()}. */
public static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
private DocumentUnitCalculator() {}
/** One file's page count (0 for non-PDF / unreadable) and byte size. */
public record FileSize(int pages, long bytes) {}
/** Raw (unclamped) units for one file. */
public static long rawUnits(int pages, long bytes, UnitCalcPolicy policy) {
long pageUnits = pages > 0 ? ceilDiv(pages, policy.docPagesPerUnit()) : 0L;
long byteUnits = ceilDiv(bytes, policy.docBytesPerUnit());
return Math.max(pageUnits, byteUnits);
}
/** Units for a single file, clamped to {@code [1, fileUnitCap]}. */
public static int unitsForFile(int pages, long bytes, UnitCalcPolicy policy) {
long raw = rawUnits(pages, bytes, policy);
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
return Math.toIntExact(
Math.max(MIN_UNITS_PER_NONEMPTY_FILE, Math.min(policy.fileUnitCap(), raw)));
}
/**
* Units for a multi-file group: raw per-file sum clamped to {@code [1, fileUnitCap * count]}.
*/
public static int unitsForGroup(List<FileSize> files, UnitCalcPolicy policy) {
if (files.isEmpty()) {
throw new IllegalArgumentException("files must not be empty");
}
long rawSum = 0;
for (FileSize f : files) {
rawSum = saturatedAdd(rawSum, rawUnits(f.pages(), f.bytes(), policy));
}
long groupCap = (long) policy.fileUnitCap() * files.size();
return Math.toIntExact(
Math.max((long) MIN_UNITS_PER_NONEMPTY_FILE, Math.min(groupCap, rawSum)));
}
private static long ceilDiv(long numerator, long divisor) {
if (numerator <= 0) {
return 0;
}
return (numerator + divisor - 1) / divisor;
}
private static long saturatedAdd(long a, long b) {
try {
return Math.addExact(a, b);
} catch (ArithmeticException e) {
return Long.MAX_VALUE;
}
}
}
@@ -0,0 +1,30 @@
package stirling.software.proprietary.billing;
/**
* The four billing knobs the doc-unit math needs, split out of the SaaS {@code PricingPolicy} JPA
* entity so the calculation ({@link DocumentUnitCalculator}) can live in {@code :proprietary} and
* be shared by the SaaS billing engine and a linked self-hosted instance — both then cost an
* operation identically.
*
* <p>The SaaS engine builds one from its persisted {@code PricingPolicy}; a linked instance
* receives these values in the daily entitlement sync. {@code minChargeUnits} is carried here for
* the charge layer; {@link DocumentUnitCalculator} itself does not apply it (see its docs).
*/
public record UnitCalcPolicy(
int docPagesPerUnit, long docBytesPerUnit, int minChargeUnits, int fileUnitCap) {
public UnitCalcPolicy {
if (docPagesPerUnit <= 0) {
throw new IllegalArgumentException("docPagesPerUnit must be > 0");
}
if (docBytesPerUnit <= 0) {
throw new IllegalArgumentException("docBytesPerUnit must be > 0");
}
if (minChargeUnits < 1) {
throw new IllegalArgumentException("minChargeUnits must be >= 1");
}
if (fileUnitCap < 1) {
throw new IllegalArgumentException("fileUnitCap must be >= 1");
}
}
}
@@ -223,7 +223,7 @@ Use consistent event types throughout the application:
- `FILE_DOWNLOAD` - When a file is downloaded
- `PDF_PROCESS` - When a PDF is processed (split, merged, etc.)
- `USER_CREATE` - When a user is created
- `USER_UPDATE` - When a user details are updated
- `USER_UPDATE` - When a user's details are updated
- `PASSWORD_CHANGE` - When a password is changed
- `PERMISSION_CHANGE` - When permissions are modified
- `SETTINGS_CHANGE` - When system settings are changed
@@ -120,7 +120,8 @@ class OwnershipServiceTest {
void enabledResourceUseDelegatesToTheAcl() {
TestResource r = new TestResource(1L);
User user = user(7);
when(accessService.canUseResource(any(), any(), any(), any(), any())).thenReturn(true);
when(accessService.canUseResource(any(), any(), any(), any(), any(), any()))
.thenReturn(true);
assertThat(ownership.canUse(TYPE, r, user)).isTrue();
}
@@ -47,7 +47,7 @@ class ResourceAccessServiceTest {
void adminMayUseEvenWithExplicitOnlyAndNoGrants() {
assertThat(
service.canUseResource(
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, admin(1)))
TYPE, RID, null, null, DefaultAccessPolicy.EXPLICIT_ONLY, admin(1)))
.isTrue();
}
@@ -55,13 +55,13 @@ class ResourceAccessServiceTest {
void ownerMayUseEvenWithExplicitOnly() {
assertThat(
service.canUseResource(
TYPE, RID, 5L, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
TYPE, RID, 5L, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
.isTrue();
}
@Test
void nullUserIsAlwaysDenied() {
assertThat(service.canUseResource(TYPE, RID, 5L, DefaultAccessPolicy.ORG_ALL, null))
assertThat(service.canUseResource(TYPE, RID, 5L, null, DefaultAccessPolicy.ORG_ALL, null))
.isFalse();
assertThat(service.canManageResource(TYPE, RID, 5L, null)).isFalse();
}
@@ -73,7 +73,7 @@ class ResourceAccessServiceTest {
stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.USE));
assertThat(
service.canUseResource(
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
TYPE, RID, null, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
.isTrue();
}
@@ -85,6 +85,7 @@ class ResourceAccessServiceTest {
TYPE,
RID,
null,
null,
DefaultAccessPolicy.EXPLICIT_ONLY,
userInTeam(5, 7)))
.isTrue();
@@ -98,6 +99,7 @@ class ResourceAccessServiceTest {
TYPE,
RID,
null,
null,
DefaultAccessPolicy.EXPLICIT_ONLY,
userInTeam(5, 99)))
.isFalse();
@@ -108,7 +110,7 @@ class ResourceAccessServiceTest {
stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.MANAGE));
assertThat(
service.canUseResource(
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
TYPE, RID, null, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
.isTrue();
}
@@ -129,7 +131,9 @@ class ResourceAccessServiceTest {
@Test
void orgAllDefaultAllowsAnyUser() {
stubGrants();
assertThat(service.canUseResource(TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5)))
assertThat(
service.canUseResource(
TYPE, RID, null, null, DefaultAccessPolicy.ORG_ALL, user(5)))
.isTrue();
}
@@ -138,7 +142,7 @@ class ResourceAccessServiceTest {
stubGrants();
assertThat(
service.canUseResource(
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
TYPE, RID, null, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
.isFalse();
}
@@ -149,7 +153,12 @@ class ResourceAccessServiceTest {
when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true);
assertThat(
service.canUseResource(
TYPE, RID, null, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, leader))
TYPE,
RID,
null,
null,
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS,
leader))
.isTrue();
stubGrants();
@@ -158,11 +167,43 @@ class ResourceAccessServiceTest {
TYPE,
RID,
null,
null,
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS,
user(6)))
.isFalse();
}
@Test
void teamLeadDefaultOnTeamResourceAdmitsOnlyThatTeamsLead() {
// Team-owned resource: only a lead OF THE OWNING TEAM qualifies — a lead of some
// other team must not cross the boundary.
stubGrants();
User owningTeamLead = user(5);
when(teamLeadLookup.isLeaderOfTeam(owningTeamLead, 7L)).thenReturn(true);
assertThat(
service.canUseResource(
TYPE,
RID,
null,
7L,
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS,
owningTeamLead))
.isTrue();
stubGrants();
User foreignTeamLead = user(6);
when(teamLeadLookup.isLeaderOfTeam(foreignTeamLead, 7L)).thenReturn(false);
assertThat(
service.canUseResource(
TYPE,
RID,
null,
7L,
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS,
foreignTeamLead))
.isFalse();
}
// ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ----
@Test
@@ -12,6 +12,7 @@ import java.net.ConnectException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -189,4 +190,73 @@ class AccountLinkClientTest {
.thenThrow(new ConnectException("refused"));
assertEquals(false, client.revokeSelf("dev-1", "sec-1"));
}
@Test
@SuppressWarnings("unchecked")
void reportUsagePostsToSyncWithDeviceHeadersAndParsesFreshEntitlement() throws Exception {
HttpResponse<String> resp =
response(
200,
"{\"subscribed\":true,\"freeRemainingUnits\":0,\"periodSpendUnits\":42,\"periodCapUnits\":100,\"state\":\"OK\"}");
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
when(httpClient.send(captor.capture(), any(HttpResponse.BodyHandler.class)))
.thenReturn(resp);
InstanceEntitlement e =
client.reportUsage(
"dev-1", "sec-1", 7L, LocalDateTime.of(2026, 6, 1, 0, 0), 12, 4, 8);
assertNotNull(e);
assertEquals(42, e.periodSpendUnits());
assertEquals(EntitlementState.OK, e.state());
HttpRequest sent = captor.getValue();
assertEquals("https://saas.example.com/api/v1/instance/sync", sent.uri().toString());
assertEquals("POST", sent.method());
assertEquals("dev-1", sent.headers().firstValue("X-Device-Id").orElse(null));
assertEquals("sec-1", sent.headers().firstValue("X-Device-Secret").orElse(null));
}
@Test
@SuppressWarnings("unchecked")
void reportUsageThrowsRevokedOnDeny() throws Exception {
for (int status : new int[] {401, 403}) {
HttpResponse<String> resp = response(status, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
AccountLinkClient.RevokedException ex =
assertThrows(
AccountLinkClient.RevokedException.class,
() ->
client.reportUsage(
"dev-1",
"sec-1",
1L,
LocalDateTime.of(2026, 6, 1, 0, 0),
1,
0,
0));
assertEquals(status, ex.status());
}
}
@Test
@SuppressWarnings("unchecked")
void reportUsageReturnsNullWhenUnreachable() throws Exception {
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class)))
.thenThrow(new ConnectException("refused"));
// Null = don't advance synced markers; the usage retries on the next sync.
assertNull(
client.reportUsage(
"dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
}
@Test
@SuppressWarnings("unchecked")
void reportUsageReturnsNullOnServerError() throws Exception {
HttpResponse<String> resp = response(503, "{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(resp);
assertNull(
client.reportUsage(
"dev-1", "sec-1", 1L, LocalDateTime.of(2026, 6, 1, 0, 0), 1, 0, 0));
}
}
@@ -2,12 +2,15 @@ package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -21,12 +24,18 @@ import stirling.software.proprietary.accountlink.AccountLinkController.LinkReque
class AccountLinkControllerTest {
private AccountLinkService service;
private UsageSyncService syncService;
private ObjectProvider<UsageSyncService> syncProvider;
private AccountLinkController controller;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
service = mock(AccountLinkService.class);
controller = new AccountLinkController(service);
syncService = mock(UsageSyncService.class);
syncProvider = mock(ObjectProvider.class);
controller =
new AccountLinkController(service, mock(LocalUsageService.class), syncProvider);
}
@Test
@@ -65,4 +74,24 @@ class AccountLinkControllerTest {
ResponseEntity<?> resp = controller.link(new LinkRequest("jwt", null));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
}
@Test
void syncNow_triggersSyncWhenMeteringOn() {
when(syncProvider.getIfAvailable()).thenReturn(syncService);
ResponseEntity<Void> resp = controller.syncNow();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(syncService).syncNow();
}
@Test
void syncNow_returns409WhenMeteringOff() {
when(syncProvider.getIfAvailable()).thenReturn(null); // metering disabled → bean absent
ResponseEntity<Void> resp = controller.syncNow();
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
verify(syncService, never()).syncNow();
}
}
@@ -1,49 +1,78 @@
package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import stirling.software.common.service.InternalApiClient;
import stirling.software.proprietary.billing.BillingCategory;
class BillableOperationClassifierTest {
@Test
void aiPathIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/tools/foo");
assertTrue(BillableOperationClassifier.isBillable(req));
private static MockHttpServletRequest req(String uri) {
return new MockHttpServletRequest("POST", uri);
}
@Test
void automationHeaderIsBillable() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
void aiPathIsAi() {
assertEquals(
BillingCategory.AI,
BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), false));
}
@Test
void automationHeaderIsAutomation() {
MockHttpServletRequest req = req("/api/v1/general/merge");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "1");
assertTrue(BillableOperationClassifier.isBillable(req));
assertEquals(
BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, false));
}
@Test
void plainManualToolIsFree() {
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/merge");
assertFalse(BillableOperationClassifier.isBillable(req));
void apiKeyToolCallIsApi() {
assertEquals(
BillingCategory.API,
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), true));
}
@Test
void aiSegmentNotAtPathStartIsFree() {
// Tightened from substring to prefix: the AI segment appearing mid-path (e.g. behind a
// proxy prefix) must NOT classify a manual tool as billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/proxy/api/v1/ai/tools/foo");
assertFalse(BillableOperationClassifier.isBillable(req));
void plainManualToolIsBypassed() {
assertEquals(
BillingCategory.BYPASSED,
BillableOperationClassifier.categorize(req("/api/v1/general/merge"), false));
}
@Test
void aiPathUnderContextPathIsBillable() {
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is billable.
MockHttpServletRequest req =
new MockHttpServletRequest("POST", "/stirling/api/v1/ai/tools/foo");
void automationDominatesAiAndApiKey() {
// An AI tool dispatched inside a workflow (automation header) + API-key auth → AUTOMATION.
MockHttpServletRequest req = req("/api/v1/ai/tools/foo");
req.addHeader(InternalApiClient.AUTOMATION_HEADER, "true");
assertEquals(BillingCategory.AUTOMATION, BillableOperationClassifier.categorize(req, true));
}
@Test
void aiDominatesApiKey() {
// A direct API-key call to an AI tool bills as AI, not API.
assertEquals(
BillingCategory.AI,
BillableOperationClassifier.categorize(req("/api/v1/ai/tools/foo"), true));
}
@Test
void aiSegmentNotAtPathStartIsBypassed() {
// Tightened from substring to prefix: the AI segment mid-path (e.g. behind a proxy prefix)
// must NOT classify a manual tool as AI.
assertEquals(
BillingCategory.BYPASSED,
BillableOperationClassifier.categorize(req("/proxy/api/v1/ai/tools/foo"), false));
}
@Test
void aiPathUnderContextPathIsAi() {
// A real context-path deployment still classifies: /<ctx>/api/v1/ai/** is AI.
MockHttpServletRequest req = req("/stirling/api/v1/ai/tools/foo");
req.setContextPath("/stirling");
assertTrue(BillableOperationClassifier.isBillable(req));
assertEquals(BillingCategory.AI, BillableOperationClassifier.categorize(req, false));
}
}
@@ -3,20 +3,32 @@ package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.accountlink.GateDecision.Reason;
/**
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, linked-free, and
* over-limit. Exercises the pure {@link InstanceEntitlementGate#decide} so no Spring / I/O is
* needed.
* Covers the gate decision matrix: flag-off, manual-free, unlinked, fail-open, grace-expired,
* linked-free, and over-limit. The pure {@link InstanceEntitlementGate#decide} cases need no
* Spring; the grace-window reference computation is exercised through {@link
* InstanceEntitlementGate#evaluate} with mocked collaborators.
*/
@ExtendWith(MockitoExtension.class)
class InstanceEntitlementGateTest {
@Mock private DeviceCredentialStore credentialStore;
@Mock private EntitlementCache entitlementCache;
@Mock private AccountLinkSyncStateRepository syncStateRepository;
@Mock private LocalUsageService localUsageService;
private static InstanceEntitlement free() {
return new InstanceEntitlement(false, 100, 0, null, EntitlementState.OK);
}
@@ -35,35 +47,67 @@ class InstanceEntitlementGateTest {
@Test
void flagOff_allowsEverything_evenBillableUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(false, true, false, Optional.empty());
GateDecision d =
InstanceEntitlementGate.decide(false, true, false, Optional.empty(), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.FLAG_OFF, d.reason());
}
@Test
void manualTool_alwaysFree_evenUnlinked() {
GateDecision d = InstanceEntitlementGate.decide(true, false, false, Optional.empty());
GateDecision d =
InstanceEntitlementGate.decide(true, false, false, Optional.empty(), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.MANUAL_FREE, d.reason());
}
@Test
void billable_notLinked_blocksWithLinkSignal() {
GateDecision d = InstanceEntitlementGate.decide(true, true, false, Optional.empty());
GateDecision d =
InstanceEntitlementGate.decide(true, true, false, Optional.empty(), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.NOT_LINKED, d.reason());
}
@Test
void billable_linked_entitlementUnreachable_failsOpen() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.empty());
void billable_linked_entitlementUnreachable_withinGrace_failsOpen() {
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.empty(), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.FAIL_OPEN, d.reason());
}
@Test
void billable_linked_entitlementUnreachable_graceExpired_blocks() {
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.empty(), true, 0L);
assertFalse(d.allowed());
assertEquals(Reason.GRACE_EXPIRED, d.reason());
}
@Test
void billable_linked_freePoolAvailable_allows() {
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(free()));
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_unsubscribed_pendingLocalUsageDepletesGrant_blocks() {
// free() has 100 free units left per the last sync; 100 accrued locally since would exhaust
// it once charged, so the gate stops here in real time rather than waiting for the sync.
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 100L);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_unsubscribed_pendingLocalUsageLeavesRoom_allows() {
// 99 pending against 100 remaining → one unit of grant still projected free → allow.
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(free()), false, 99L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@@ -72,7 +116,7 @@ class InstanceEntitlementGateTest {
void billable_linked_unsubscribedAndExhausted_blocksOverLimit() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(exhaustedUnsubscribed()));
true, true, true, Optional.of(exhaustedUnsubscribed()), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@@ -81,7 +125,7 @@ class InstanceEntitlementGateTest {
void billable_linked_subscribedWithinCap_allows() {
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedWithinCap()));
true, true, true, Optional.of(subscribedWithinCap()), false, 0L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@@ -89,18 +133,67 @@ class InstanceEntitlementGateTest {
@Test
void billable_linked_subscribedOverCap_blocks() {
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(subscribedOverCap()));
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedOverCap()), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_subscribedCapped_pendingLocalUsageWouldExceedCap_blocks() {
// Within cap per the last sync (spend 10 / cap 100), but 95 accrued locally since would
// push
// projected spend to 105 → the gate stops now, not after the next sync reconciles.
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedWithinCap()), false, 95L);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void billable_linked_subscribedCapped_pendingLeavesCapRoom_allows() {
// 10 synced + 80 pending = 90 < 100 cap → still room.
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedWithinCap()), false, 80L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_subscribedCapped_freeGrantAbsorbsPending_allows() {
// 50 free units remain, so 40 pending is entirely free → 0 projected paid < 100 cap →
// allow.
InstanceEntitlement subscribedWithGrant =
new InstanceEntitlement(true, 50, 0, 100L, EntitlementState.OK);
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(subscribedWithGrant), false, 40L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_subscribedUncapped_pendingIgnored_allows() {
// No cap → local pending has no ceiling to hit → always allowed.
InstanceEntitlement uncapped =
new InstanceEntitlement(true, 0, 999, null, EntitlementState.OK);
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(uncapped), false, 500L);
assertTrue(d.allowed());
assertEquals(Reason.ENTITLED, d.reason());
}
@Test
void billable_linked_revoked_blocksWithRevokedSignal() {
// Authoritative deny (revoked/invalid credential) surfaced by the cache as REVOKED —
// blocks distinctly from over-limit, even though the snapshot is "present".
InstanceEntitlement revoked =
new InstanceEntitlement(false, 0, 0, null, EntitlementState.REVOKED);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked));
GateDecision d =
InstanceEntitlementGate.decide(true, true, true, Optional.of(revoked), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.REVOKED, d.reason());
}
@@ -110,7 +203,98 @@ class InstanceEntitlementGateTest {
// Defensive: an explicit OVER_LIMIT state blocks even if a stale free count looks positive.
InstanceEntitlement conflicting =
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OVER_LIMIT);
GateDecision d = InstanceEntitlementGate.decide(true, true, true, Optional.of(conflicting));
GateDecision d =
InstanceEntitlementGate.decide(
true, true, true, Optional.of(conflicting), false, 0L);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
// --- grace window (evaluate()) ---------------------------------------------------------------
private InstanceEntitlementGate gate(AccountLinkProperties props) {
return new InstanceEntitlementGate(
props, credentialStore, entitlementCache, syncStateRepository, localUsageService);
}
private static AccountLinkProperties props(boolean meteringEnabled, int graceDays) {
AccountLinkProperties p = new AccountLinkProperties();
p.setEnabled(true);
p.getMetering().setEnabled(meteringEnabled);
p.getMetering().setGraceDays(graceDays);
return p;
}
@Test
void evaluate_meteringOff_unreachable_failsOpen_neverGraceBlocks() {
when(credentialStore.isLinked()).thenReturn(true);
when(entitlementCache.current()).thenReturn(Optional.empty());
GateDecision d = gate(props(false, 3)).evaluate(true);
// Metering off → grace never applies, even if a sync is ancient.
assertTrue(d.allowed());
assertEquals(Reason.FAIL_OPEN, d.reason());
}
@Test
void evaluate_neverSynced_pastGraceSinceLink_blocks() {
when(credentialStore.isLinked()).thenReturn(true);
when(entitlementCache.current()).thenReturn(Optional.empty());
when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
.thenReturn(Optional.empty());
DeviceCredential cred = new DeviceCredential();
cred.setLinkedAt(LocalDateTime.now().minusDays(5));
when(credentialStore.get()).thenReturn(Optional.of(cred));
GateDecision d = gate(props(true, 3)).evaluate(true);
assertFalse(d.allowed());
assertEquals(Reason.GRACE_EXPIRED, d.reason());
}
@Test
void evaluate_recentSync_withinGrace_failsOpen() {
when(credentialStore.isLinked()).thenReturn(true);
when(entitlementCache.current()).thenReturn(Optional.empty());
AccountLinkSyncState state = new AccountLinkSyncState();
state.setLastSuccessAt(LocalDateTime.now().minusDays(1));
when(syncStateRepository.findById(AccountLinkSyncState.SINGLETON_ID))
.thenReturn(Optional.of(state));
GateDecision d = gate(props(true, 3)).evaluate(true);
assertTrue(d.allowed());
assertEquals(Reason.FAIL_OPEN, d.reason());
}
@Test
void evaluate_unsubscribed_localUsageWouldExceedGrant_blocksInRealTime() {
// 100 free units remaining per the last sync, but 100 already accrued locally since — the
// gate subtracts the pending delta and blocks now, not after the next sync reconciles.
when(credentialStore.isLinked()).thenReturn(true);
when(entitlementCache.current()).thenReturn(Optional.of(free()));
when(localUsageService.currentPeriodUnsynced())
.thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 100, 0, 0, 100));
GateDecision d = gate(props(true, 3)).evaluate(true);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@Test
void evaluate_subscribedCapped_localUsageWouldExceedCap_blocksInRealTime() {
// Subscribed within cap per the last sync (spend 10 / cap 100), but 90 accrued locally
// since — evaluate() now depletes the cap by pending usage for capped subscriptions too, so
// the gate stops now instead of overshooting the cap until the next sync.
when(credentialStore.isLinked()).thenReturn(true);
when(entitlementCache.current()).thenReturn(Optional.of(subscribedWithinCap()));
when(localUsageService.currentPeriodUnsynced())
.thenReturn(new LocalUsageService.LocalUsage(LocalDateTime.now(), 0, 90, 0, 90));
GateDecision d = gate(props(true, 3)).evaluate(true);
assertFalse(d.allowed());
assertEquals(Reason.OVER_LIMIT, d.reason());
}
@@ -19,6 +19,7 @@ class InstanceEntitlementGateWiringTest {
private AccountLinkProperties properties;
private DeviceCredentialStore store;
private EntitlementCache cache;
private LocalUsageService localUsage;
private InstanceEntitlementGate gate;
@BeforeEach
@@ -27,7 +28,14 @@ class InstanceEntitlementGateWiringTest {
properties.setEnabled(true);
store = mock(DeviceCredentialStore.class);
cache = mock(EntitlementCache.class);
gate = new InstanceEntitlementGate(properties, store, cache);
localUsage = mock(LocalUsageService.class);
gate =
new InstanceEntitlementGate(
properties,
store,
cache,
mock(AccountLinkSyncStateRepository.class),
localUsage);
}
@Test
@@ -55,6 +63,10 @@ class InstanceEntitlementGateWiringTest {
.thenReturn(
Optional.of(
new InstanceEntitlement(false, 5, 0, null, EntitlementState.OK)));
// Unsubscribed → the gate reads local unsynced usage to deplete the grant in real time;
// nothing pending here, so the 5 free units still allow the request.
when(localUsage.currentPeriodUnsynced())
.thenReturn(new LocalUsageService.LocalUsage(null, 0, 0, 0, 0));
GateDecision d = gate.evaluate(true);
assertTrue(d.allowed());
assertEquals(GateDecision.Reason.ENTITLED, d.reason());
@@ -3,24 +3,55 @@ package stirling.software.proprietary.accountlink;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.notNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.io.ByteArrayOutputStream;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Optional;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.mock.web.MockMultipartHttpServletRequest;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.billing.BillingCategory;
import stirling.software.proprietary.billing.UnitCalcPolicy;
@ExtendWith(MockitoExtension.class)
class InstanceEntitlementInterceptorTest {
@Mock private InstanceEntitlementGate gate;
@Mock private EntitlementCache entitlementCache;
@Mock private ObjectProvider<UsageMeterService> meterProvider;
@Mock private TempFileManager tempFileManager;
private InstanceEntitlementInterceptor interceptor() {
return new InstanceEntitlementInterceptor(
gate, entitlementCache, meterProvider, tempFileManager);
}
private boolean preHandle(MockHttpServletResponse response) throws Exception {
return new InstanceEntitlementInterceptor(gate)
return interceptor()
.preHandle(
new MockHttpServletRequest("GET", "/api/v1/ai/x"), response, new Object());
}
@@ -58,4 +89,85 @@ class InstanceEntitlementInterceptorTest {
assertTrue(preHandle(response));
assertEquals(200, response.getStatus());
}
@Test
void metersSuccessfulBillableOp() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
UsageMeterService meter = mock(UsageMeterService.class);
when(meterProvider.getIfAvailable()).thenReturn(meter);
UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
MockHttpServletResponse resp = new MockHttpServletResponse();
interceptor.preHandle(req, resp, new Object()); // stashes AI category
interceptor.afterCompletion(req, resp, new Object(), null);
// No uploaded files → bytes axis → the 1-unit floor; no input identity → null signature.
verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(1L), isNull());
}
@Test
void metersPdfByPageCountNotJustBytes(@TempDir Path tmp) throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
UsageMeterService meter = mock(UsageMeterService.class);
when(meterProvider.getIfAvailable()).thenReturn(meter);
// docPagesPerUnit=1, docBytesPerUnit=1MB → a tiny 5-page PDF costs 5 on the page axis but
// only 1 on the byte axis: page-counting (via jpdfium) is what makes this bill correctly.
UnitCalcPolicy policy = new UnitCalcPolicy(1, 1_048_576L, 1, 1000);
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
when(entitlementCache.current()).thenReturn(Optional.of(entitled(policy, period)));
// Materialise to a real path under @TempDir; the interceptor writes the upload there and
// jpdfium + the hasher read it back.
TempFile temp = mock(TempFile.class);
when(temp.getPath()).thenReturn(tmp.resolve("input.bin"));
when(tempFileManager.createManagedTempFile(any())).thenReturn(temp);
InstanceEntitlementInterceptor interceptor = interceptor();
MockMultipartHttpServletRequest req = new MockMultipartHttpServletRequest();
req.setRequestURI("/api/v1/ai/x");
req.addFile(new MockMultipartFile("file", "doc.pdf", "application/pdf", fivePagePdf()));
MockHttpServletResponse resp = new MockHttpServletResponse();
interceptor.preHandle(req, resp, new Object());
interceptor.afterCompletion(req, resp, new Object(), null);
// 5 pages + a non-null input-set signature (file ops carry a dedup key).
verify(meter).accrue(eq(period), eq(BillingCategory.AI), eq(5L), notNull());
}
@Test
void doesNotMeterWhenMeteringSwitchOff() throws Exception {
when(gate.evaluate(anyBoolean()))
.thenReturn(GateDecision.allow(GateDecision.Reason.ENTITLED));
when(meterProvider.getIfAvailable()).thenReturn(null); // metering.enabled = false
InstanceEntitlementInterceptor interceptor = interceptor();
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/ai/x");
MockHttpServletResponse resp = new MockHttpServletResponse();
interceptor.preHandle(req, resp, new Object());
interceptor.afterCompletion(req, resp, new Object(), null);
// Meter absent → no entitlement lookup, no accrual.
verifyNoInteractions(entitlementCache);
}
private static InstanceEntitlement entitled(UnitCalcPolicy policy, LocalDateTime period) {
return new InstanceEntitlement(
true, 0, 0, 100L, EntitlementState.OK, policy, period, period.plusMonths(1));
}
private static byte[] fivePagePdf() throws Exception {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
for (int i = 0; i < 5; i++) {
doc.addPage(new PDPage());
}
doc.save(out);
return out.toByteArray();
}
}
}
@@ -0,0 +1,75 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class LocalUsageServiceTest {
@Mock private UsageCounterRepository counters;
@Mock private EntitlementCache entitlementCache;
private LocalUsageService service;
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
@BeforeEach
void setUp() {
service = new LocalUsageService(counters, entitlementCache);
}
private static UsageCounter counter(
LocalDateTime period, String category, long cumulative, long synced) {
return new UsageCounter(period, category, cumulative, synced, LocalDateTime.now());
}
private static InstanceEntitlement entitledFor(LocalDateTime periodStart) {
return new InstanceEntitlement(
true,
0,
0,
null,
EntitlementState.OK,
null,
periodStart,
periodStart.plusMonths(1));
}
@Test
void unknownPeriodReturnsZeros() {
when(entitlementCache.current()).thenReturn(Optional.empty());
LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
assertThat(usage.periodStart()).isNull();
assertThat(usage.totalUnsyncedUnits()).isZero();
}
@Test
void sumsPerCategoryUnsyncedDeltaForCurrentPeriod() {
when(entitlementCache.current()).thenReturn(Optional.of(entitledFor(period)));
when(counters.findByPeriodStart(period))
.thenReturn(
List.of(
counter(period, "API", 30L, 10L), // 20 unsynced
counter(period, "AI", 4L, 4L), // 0 unsynced (all reported)
counter(period, "AUTOMATION", 7L, 2L))); // 5 unsynced
LocalUsageService.LocalUsage usage = service.currentPeriodUnsynced();
assertThat(usage.periodStart()).isEqualTo(period);
assertThat(usage.apiUnsyncedUnits()).isEqualTo(20L);
assertThat(usage.aiUnsyncedUnits()).isEqualTo(0L);
assertThat(usage.automationUnsyncedUnits()).isEqualTo(5L);
assertThat(usage.totalUnsyncedUnits()).isEqualTo(25L);
}
}
@@ -0,0 +1,133 @@
package stirling.software.proprietary.accountlink;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
import stirling.software.proprietary.billing.BillingCategory;
@ExtendWith(MockitoExtension.class)
class UsageMeterServiceTest {
@Mock private UsageCounterRepository repo;
@Mock private MeteredInputSignatureRepository signatureRepo;
private UsageMeterService service;
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
@BeforeEach
void setUp() {
service = new UsageMeterService(repo, signatureRepo, new AccountLinkProperties());
}
@Test
void incrementsExistingCounter() {
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
service.accrue(period, BillingCategory.AI, 5, null);
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
verify(repo, never()).saveAndFlush(any());
}
@Test
void insertsWhenNoRowExists() {
when(repo.increment(eq(period), eq("API"), eq(3L), any())).thenReturn(0);
service.accrue(period, BillingCategory.API, 3, null);
verify(repo).saveAndFlush(any(UsageCounter.class));
}
@Test
void retriesIncrementWhenInsertLosesRace() {
// First increment misses (no row); insert loses the race to a concurrent thread; the
// second increment then succeeds against the row that thread created.
when(repo.increment(eq(period), eq("AUTOMATION"), eq(2L), any())).thenReturn(0, 1);
when(repo.saveAndFlush(any())).thenThrow(new DataIntegrityViolationException("dup"));
service.accrue(period, BillingCategory.AUTOMATION, 2, null);
verify(repo, times(2)).increment(eq(period), eq("AUTOMATION"), eq(2L), any());
}
@Test
void skipsBypassedNonPositiveAndNullPeriod() {
service.accrue(period, BillingCategory.BYPASSED, 5, null);
service.accrue(period, BillingCategory.AI, 0, null);
service.accrue(null, BillingCategory.AI, 5, null);
verifyNoInteractions(repo, signatureRepo);
}
@Test
void chargesNewSignatureThenAccrues() {
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-new"))
.thenReturn(Optional.empty());
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
service.accrue(period, BillingCategory.AI, 5, "op-sig-new");
verify(signatureRepo).saveAndFlush(any(MeteredInputSignature.class));
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
}
@Test
void skipsConcurrentDuplicateClaim() {
// Unseen this period, but a concurrent op wins the insert first → treated as within-window
// chaining, not re-charged.
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig-race"))
.thenReturn(Optional.empty());
when(signatureRepo.saveAndFlush(any()))
.thenThrow(new DataIntegrityViolationException("dup"));
service.accrue(period, BillingCategory.AI, 5, "op-sig-race");
verify(repo, never()).increment(any(), any(), anyLong(), any());
verify(repo, never()).saveAndFlush(any());
}
@Test
void skipsRepeatWithinWorkflowWindow() {
// Same input set seen moments ago → chaining → not re-charged; the window slides.
MeteredInputSignature recent =
new MeteredInputSignature(period, "op-sig", LocalDateTime.now());
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
.thenReturn(Optional.of(recent));
service.accrue(period, BillingCategory.AI, 5, "op-sig");
verify(repo, never()).increment(any(), any(), anyLong(), any());
verify(signatureRepo).save(recent); // window touched
}
@Test
void chargesRepeatOutsideWorkflowWindow() {
// Same input set last seen well past the 5-minute window → an independent re-run → charged.
MeteredInputSignature stale =
new MeteredInputSignature(period, "op-sig", LocalDateTime.now().minusMinutes(10));
when(signatureRepo.findByPeriodStartAndSignature(period, "op-sig"))
.thenReturn(Optional.of(stale));
when(repo.increment(eq(period), eq("AI"), eq(5L), any())).thenReturn(1);
service.accrue(period, BillingCategory.AI, 5, "op-sig");
verify(repo).increment(eq(period), eq("AI"), eq(5L), any());
verify(signatureRepo).save(stale); // window touched
}
}
@@ -0,0 +1,171 @@
package stirling.software.proprietary.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
@ExtendWith(MockitoExtension.class)
class UsageSyncServiceTest {
@Mock private UsageCounterRepository counters;
@Mock private AccountLinkSyncStateRepository syncState;
@Mock private DeviceCredentialStore credentialStore;
@Mock private AccountLinkClient client;
@Mock private EntitlementCache entitlementCache;
private UsageSyncService service;
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
@BeforeEach
void setUp() {
service =
new UsageSyncService(
counters,
syncState,
credentialStore,
client,
entitlementCache,
new AccountLinkProperties());
}
@Test
void registersFixedDelayTaskWithConfiguredInterval() {
AccountLinkProperties props = new AccountLinkProperties();
props.getMetering().setSyncIntervalHours(6);
UsageSyncService svc =
new UsageSyncService(
counters, syncState, credentialStore, client, entitlementCache, props);
ScheduledTaskRegistrar registrar = new ScheduledTaskRegistrar();
svc.configureTasks(registrar);
// Pins the interval binding in CI — the old @Scheduled SpEL only resolved at flags-on boot.
assertThat(registrar.getFixedDelayTaskList()).hasSize(1);
assertThat(registrar.getFixedDelayTaskList().get(0).getIntervalDuration())
.isEqualTo(Duration.ofHours(6));
}
private static DeviceCredential credential() {
DeviceCredential c = new DeviceCredential();
c.setDeviceId("dev-1");
c.setDeviceSecret("sec-1");
return c;
}
private static UsageCounter counter(LocalDateTime period, String category, long cumulative) {
return new UsageCounter(period, category, cumulative, LocalDateTime.now());
}
private static InstanceEntitlement entitled() {
return new InstanceEntitlement(true, 0, 0, null, EntitlementState.OK);
}
@Test
void notLinkedSkipsEntirely() {
when(credentialStore.get()).thenReturn(Optional.empty());
service.syncNow();
verifyNoInteractions(client, entitlementCache);
verify(counters, never()).findPeriodsWithUnsyncedUsage();
}
@Test
void nothingPendingStillForcesEntitlementRefresh() {
when(credentialStore.get()).thenReturn(Optional.of(credential()));
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of());
service.syncNow();
// No usage to report, so nothing is sent and no markers advance — but the sync still forces
// an entitlement refresh so an out-of-band plan change (e.g. a just-completed subscription)
// surfaces on the gate immediately instead of waiting out the entitlement-cache TTL.
verifyNoInteractions(client);
verify(syncState, never()).save(any());
verify(entitlementCache, never()).accept(any());
verify(entitlementCache).invalidate();
verify(entitlementCache).current();
}
@Test
void reportsCumulativePerCategoryAndAdvancesSyncedMarkers() {
AccountLinkSyncState state = new AccountLinkSyncState();
state.setId(AccountLinkSyncState.SINGLETON_ID);
state.setLastSyncSeq(5L);
when(credentialStore.get()).thenReturn(Optional.of(credential()));
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
when(counters.findByPeriodStart(period))
.thenReturn(List.of(counter(period, "API", 12L), counter(period, "AI", 4L)));
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
InstanceEntitlement fresh = entitled();
when(client.reportUsage(
eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L)))
.thenReturn(fresh);
service.syncNow();
// Seq advanced from 5 → 6 and the report carried the per-category cumulative.
verify(client)
.reportUsage(eq("dev-1"), eq("sec-1"), eq(6L), eq(period), eq(12L), eq(4L), eq(0L));
// Only categories with usage are marked; AUTOMATION (0) is skipped.
verify(counters).markSynced(period, "API", 12L);
verify(counters).markSynced(period, "AI", 4L);
verify(counters, never()).markSynced(eq(period), eq("AUTOMATION"), anyLong());
// Two saves: the pre-report seq reservation + the post-success timestamp.
verify(syncState, times(2)).save(state);
verify(entitlementCache).accept(fresh);
}
@Test
void transportFailureReservesSeqButLeavesMarkersUntouched() {
AccountLinkSyncState state = new AccountLinkSyncState();
state.setId(AccountLinkSyncState.SINGLETON_ID);
when(credentialStore.get()).thenReturn(Optional.of(credential()));
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
.thenReturn(null);
service.syncNow();
verify(counters, never()).markSynced(any(), any(), anyLong());
verify(syncState, times(1)).save(state); // seq reserved, success not recorded
verify(entitlementCache).accept(null); // nothing fresh adopted
}
@Test
void revokedAbortsWithoutMarkingOrAdoptingEntitlement() {
AccountLinkSyncState state = new AccountLinkSyncState();
state.setId(AccountLinkSyncState.SINGLETON_ID);
when(credentialStore.get()).thenReturn(Optional.of(credential()));
when(counters.findPeriodsWithUnsyncedUsage()).thenReturn(List.of(period));
when(counters.findByPeriodStart(period)).thenReturn(List.of(counter(period, "API", 12L)));
when(syncState.findById(AccountLinkSyncState.SINGLETON_ID)).thenReturn(Optional.of(state));
when(client.reportUsage(any(), any(), anyLong(), any(), anyLong(), anyLong(), anyLong()))
.thenThrow(new AccountLinkClient.RevokedException(403));
service.syncNow();
verify(counters, never()).markSynced(any(), any(), anyLong());
verify(entitlementCache, never()).accept(any());
}
}
@@ -0,0 +1,30 @@
package stirling.software.proprietary.billing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class BillingCategoryClassifierTest {
@Test
void automationWinsOverEverything() {
assertEquals(
BillingCategory.AUTOMATION, BillingCategoryClassifier.classify(true, true, true));
}
@Test
void aiWinsOverApiKey() {
assertEquals(BillingCategory.AI, BillingCategoryClassifier.classify(false, true, true));
}
@Test
void apiKeyWhenNotAutomationOrAi() {
assertEquals(BillingCategory.API, BillingCategoryClassifier.classify(false, false, true));
}
@Test
void bypassedWhenNoSignal() {
assertEquals(
BillingCategory.BYPASSED, BillingCategoryClassifier.classify(false, false, false));
}
}
@@ -1,5 +1,8 @@
package stirling.software.saas.accountlink;
import java.time.LocalDateTime;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
@@ -9,6 +12,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -16,11 +20,16 @@ import io.swagger.v3.oas.annotations.Hidden;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.instance.InstanceUsageIngestService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
/**
* Instance-facing surface (combined-billing "Mode A"), authenticated by the <b>device
@@ -46,14 +55,23 @@ public class InstanceController {
private final EntitlementService entitlementService;
private final TeamBillingService billingService;
private final AccountLinkService accountLinkService;
private final PricingPolicyService pricingPolicyService;
private final InstanceUsageIngestService usageIngestService;
private final LinkedInstanceRepository linkedInstanceRepository;
public InstanceController(
EntitlementService entitlementService,
TeamBillingService billingService,
AccountLinkService accountLinkService) {
AccountLinkService accountLinkService,
PricingPolicyService pricingPolicyService,
InstanceUsageIngestService usageIngestService,
LinkedInstanceRepository linkedInstanceRepository) {
this.entitlementService = entitlementService;
this.billingService = billingService;
this.accountLinkService = accountLinkService;
this.pricingPolicyService = pricingPolicyService;
this.usageIngestService = usageIngestService;
this.linkedInstanceRepository = linkedInstanceRepository;
}
public record WhoAmIResponse(Long instanceId, Long teamId) {}
@@ -68,7 +86,13 @@ public class InstanceController {
long freeRemainingUnits,
long periodSpendUnits,
Long periodCapUnits,
String state) {}
String state,
// Metering inputs the instance needs to cost + bucket its own usage (Phase 2). The
// instance computes units locally with this policy and resets its per-period cumulative
// counters on the [periodStart, periodEnd) boundary.
UnitCalcPolicy unitCalcPolicy,
LocalDateTime periodStart,
LocalDateTime periodEnd) {}
@GetMapping("/whoami")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
@@ -103,20 +127,96 @@ public class InstanceController {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Long teamId = token.getTeamId();
// Drop the cached snapshot first: this low-frequency read gates real-time billable work, so
// it must reflect a just-changed subscription/cap at once (the flip is a DB-function write
// with no Java event to invalidate on).
entitlementService.invalidate(token.getTeamId());
return ResponseEntity.ok(buildEntitlement(token.getTeamId()));
}
/** Body for {@code POST /sync}: the instance's cumulative units per category this period. */
public record UsageSyncRequest(
long syncSeq, LocalDateTime periodStart, CategoryUnits cumulativeUnits) {
public record CategoryUnits(long api, long ai, long automation) {}
}
/**
* Daily usage sync: the instance reports its cumulative per-category unit totals for the
* period; SaaS bills the delta since the last sync (reusing the standard charge path) and
* returns the fresh entitlement — so one round-trip both reports usage and refreshes the gate
* state.
*/
@PostMapping("/sync")
@PreAuthorize("hasRole('LINKED_INSTANCE')")
@Transactional
public ResponseEntity<EntitlementResponse> sync(
Authentication auth, @RequestBody UsageSyncRequest req) {
if (!(auth instanceof LinkedInstanceAuthenticationToken token)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (req == null || req.periodStart() == null || req.cumulativeUnits() == null) {
return ResponseEntity.badRequest().build();
}
Long teamId = token.getTeamId();
// periodStart is the dedup/regression partition key, so bound a fabricated value to the
// snapshot window (current or immediately-prior period, never future).
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
LocalDateTime reported = req.periodStart();
if (!reported.isBefore(snap.periodEnd())
|| reported.isBefore(snap.periodStart().minusMonths(1))) {
log.warn(
"Instance sync for team {} reported implausible periodStart {} (authoritative"
+ " {}..{}); rejecting.",
teamId,
reported,
snap.periodStart(),
snap.periodEnd());
return ResponseEntity.badRequest().build();
}
// Attribute the charge to the admin who linked the instance (the device credential carries
// no user). Null is tolerated by the ingest service (it skips + retries next sync).
Long actorUserId =
linkedInstanceRepository
.findById(token.getInstanceId())
.map(LinkedInstance::getCreatedByUserId)
.orElse(null);
UsageSyncRequest.CategoryUnits c = req.cumulativeUnits();
usageIngestService.ingest(
teamId,
actorUserId,
req.syncSeq(),
req.periodStart(),
Map.of(
BillingCategory.API, c.api(),
BillingCategory.AI, c.ai(),
BillingCategory.AUTOMATION, c.automation()));
// Drop the cache so the buildEntitlement below (and the portal's next read) reflect the
// just-charged delta + moved free-grant balance now, not after the TTL.
entitlementService.invalidate(teamId);
return ResponseEntity.ok(buildEntitlement(teamId));
}
/** The entitlement view shared by {@code GET /entitlement} and the {@code /sync} response. */
private EntitlementResponse buildEntitlement(Long teamId) {
// Same composition the FE wallet uses: billing facts (subscription, free pool) from
// TeamBillingService, period spend/cap + state from the entitlement snapshot.
// TeamBillingService, period spend/cap + state from the entitlement snapshot, plus the
// unit-calc policy + period the instance needs to meter locally.
TeamBillingContext billing = billingService.forTeam(teamId);
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
return ResponseEntity.ok(
new EntitlementResponse(
billing.subscribed(),
billing.freeRemainingUnits(),
snap.periodSpendUnits(),
snap.periodCapUnits(),
coarseState(snap.state())));
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
return new EntitlementResponse(
billing.subscribed(),
billing.freeRemainingUnits(),
snap.periodSpendUnits(),
snap.periodCapUnits(),
coarseState(snap.state()),
new UnitCalcPolicy(
policy.getDocPagesPerUnit(),
policy.getDocBytesPerUnit(),
policy.getMinChargeUnits(),
policy.getFileUnitCap()),
snap.periodStart(),
snap.periodEnd());
}
/**
@@ -19,6 +19,7 @@ import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -328,6 +329,32 @@ public class PaygWalletController {
/** Request body for {@link #updateCap}. */
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
// ---------------------------------------------------------------------------------------
// POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh
// ---------------------------------------------------------------------------------------
/**
* Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a
* billing state that just changed out-of-band. The subscription flip is written by a Postgres
* function ({@code payg_link_subscription}) with no Java event to invalidate on, so a client
* that knows a change just happened — the portal while finalizing a checkout — pokes the cache
* here rather than waiting out the ~30s TTL. Team-scoped to the caller: a client can only
* refresh its own team, and a no-team caller is a cheap no-op.
*/
@PostMapping("/wallet/refresh")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> refreshWallet(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
primaryMembership(user.getId())
.ifPresent(m -> entitlementService.invalidate(m.getTeam().getId()));
return ResponseEntity.noContent().build();
}
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
@@ -5,6 +5,7 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -18,6 +19,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.jpdfium.PdfDocument;
import stirling.software.proprietary.billing.DocumentUnitCalculator;
import stirling.software.proprietary.billing.DocumentUnitCalculator.FileSize;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.saas.payg.policy.PricingPolicy;
/**
@@ -42,9 +46,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
private static final String PDF_CONTENT_TYPE = "application/pdf";
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
/** Floor for non-empty input. Distinct from {@code policy.minChargeUnits} (applied later). */
private static final int MIN_UNITS_PER_NONEMPTY_FILE = 1;
private final TempFileManager tempFileManager;
@Override
@@ -59,13 +60,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
Objects.requireNonNull(policy, "policy");
FileFacts facts = inspect(file, materialisedPath);
long rawUnits = computeRawUnits(facts.pages, facts.bytes, policy);
// toIntExact: fail loud on overflow rather than silently wrapping a billing number.
int units =
Math.toIntExact(
Math.max(
MIN_UNITS_PER_NONEMPTY_FILE,
Math.min(policy.getFileUnitCap(), rawUnits)));
int units = DocumentUnitCalculator.unitsForFile(facts.pages, facts.bytes, unitCalc(policy));
return new DocumentMetrics(facts.pages, facts.bytes, facts.contentType, units);
}
@@ -93,17 +88,16 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
int totalPages = 0;
long totalBytes = 0;
long rawUnitsSum = 0;
String firstContentType = null;
List<FileSize> sizes = new ArrayList<>(files.size());
for (int i = 0; i < files.size(); i++) {
MultipartFile file = files.get(i);
Path path = materialisedPaths == null ? null : materialisedPaths.get(i);
FileFacts facts = inspect(file, path);
// Sum the *raw* (unclamped) per-file units so the group cap below can actually bind.
// Per-file clamping in this loop would make the group cap a no-op.
rawUnitsSum =
saturatedAdd(rawUnitsSum, computeRawUnits(facts.pages, facts.bytes, policy));
// Collect raw page/byte facts; the group cap is applied over the raw sum in the
// calculator (per-file clamping here would make the group cap a no-op).
sizes.add(new FileSize(facts.pages, facts.bytes));
totalPages = saturatedAdd(totalPages, facts.pages);
totalBytes = saturatedAdd(totalBytes, facts.bytes);
if (firstContentType == null) {
@@ -111,13 +105,7 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
}
}
long groupCap = (long) policy.getFileUnitCap() * files.size();
// toIntExact: fail loud on overflow rather than silently wrapping.
int totalUnits =
Math.toIntExact(
Math.max(
(long) MIN_UNITS_PER_NONEMPTY_FILE,
Math.min(groupCap, rawUnitsSum)));
int totalUnits = DocumentUnitCalculator.unitsForGroup(sizes, unitCalc(policy));
return new DocumentMetrics(
totalPages,
@@ -126,6 +114,14 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
totalUnits);
}
private static UnitCalcPolicy unitCalc(PricingPolicy policy) {
return new UnitCalcPolicy(
policy.getDocPagesPerUnit(),
policy.getDocBytesPerUnit(),
policy.getMinChargeUnits(),
policy.getFileUnitCap());
}
private FileFacts inspect(MultipartFile file, Path materialisedPath) {
long bytes = file.getSize();
String contentType =
@@ -140,19 +136,6 @@ public class DefaultDocumentClassifier implements DocumentClassifier {
return new FileFacts(pages, bytes, contentType);
}
private static long computeRawUnits(int pages, long bytes, PricingPolicy policy) {
long pageUnits = pages > 0 ? ceilDiv(pages, policy.getDocPagesPerUnit()) : 0L;
long byteUnits = ceilDiv(bytes, policy.getDocBytesPerUnit());
return Math.max(pageUnits, byteUnits);
}
private static long ceilDiv(long numerator, long divisor) {
if (numerator <= 0) {
return 0;
}
return (numerator + divisor - 1) / divisor;
}
private static boolean isPdf(String contentType, String filename) {
if (PDF_CONTENT_TYPE.equalsIgnoreCase(contentType)) {
return true;
@@ -0,0 +1,130 @@
package stirling.software.saas.payg.instance;
import java.time.LocalDateTime;
import java.util.Map;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.charge.ChargeContext;
import stirling.software.saas.payg.charge.JobChargeService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
/**
* Ingests a linked instance's daily usage sync (combined-billing "Mode A"). The instance reports a
* monotonic cumulative unit total per {@link BillingCategory}; we bill only the delta since the
* last sync via {@link JobChargeService#chargeStandalone} (reusing the in-cloud free-grant split,
* ledger DEBIT, Stripe meter and idempotency). Idempotent (a resend → delta 0 → no charge) and
* tamper-evident (a backwards total is refused; a monotonic {@code syncSeq} dedups replays). The
* cap is enforced at the instance gate, not here. Gated behind {@code account-link.enabled}.
*/
@Slf4j
@Service
@Profile("saas")
@ConditionalOnProperty(name = "stirling.billing.account-link.enabled", havingValue = "true")
public class InstanceUsageIngestService {
private final PaygInstanceUsageRepository usageRepository;
private final JobChargeService chargeService;
public InstanceUsageIngestService(
PaygInstanceUsageRepository usageRepository, JobChargeService chargeService) {
this.usageRepository = usageRepository;
this.chargeService = chargeService;
}
/**
* Bills the delta for each category and advances the last-seen cumulative + sync sequence. The
* delta-advance and the charge share this transaction, so a crash before commit re-bills
* cleanly on retry (delta unchanged) and a commit means the cumulative moved with the charge.
*
* @param actorUserId the linking admin ({@code linked_instance.created_by_user_id}); required
* to attribute the charge. If {@code null} we skip entirely (don't advance) so a later
* sync, once the actor is resolvable, still bills the usage.
*/
@Transactional
public void ingest(
Long teamId,
Long actorUserId,
long syncSeq,
LocalDateTime periodStart,
Map<BillingCategory, Long> cumulativeByCategory) {
if (teamId == null || periodStart == null || cumulativeByCategory == null) {
return;
}
if (actorUserId == null) {
log.warn(
"Instance usage sync for team {} has no actor (created_by_user_id null); not"
+ " billing — a later sync will pick it up.",
teamId);
return;
}
cumulativeByCategory.forEach(
(category, cumulative) -> {
if (category == null
|| category == BillingCategory.BYPASSED
|| cumulative == null
|| cumulative < 0) {
return;
}
applyCategory(teamId, actorUserId, syncSeq, periodStart, category, cumulative);
});
}
private void applyCategory(
Long teamId,
Long actorUserId,
long syncSeq,
LocalDateTime periodStart,
BillingCategory category,
long cumulative) {
// Pessimistic row lock so a duplicate delivery can't have two txns read the same baseline
// and both charge: the second waits, then sees the advanced seq and replay-skips.
PaygInstanceUsage row =
usageRepository
.findByTeamIdAndPeriodStartAndCategoryForUpdate(
teamId, periodStart, category.name())
.orElse(null);
if (row != null && syncSeq <= row.getLastSyncSeq()) {
return; // replay / out-of-order — already applied this or a later sync
}
long lastCumulative = row == null ? 0L : row.getLastCumulativeUnits();
long delta = cumulative - lastCumulative;
if (delta < 0) {
// The cumulative counter went backwards — a reset or tampering. Refuse to credit; don't
// advance, so the discrepancy stays visible and a corrected resend can reconcile.
log.warn(
"Instance usage regression team={} category={} reported {} < last {}; ignoring.",
teamId,
category,
cumulative,
lastCumulative);
return;
}
if (delta > 0) {
int units = (int) Math.min(delta, Integer.MAX_VALUE);
chargeService.chargeStandalone(
new ChargeContext(
actorUserId,
teamId,
JobSource.LINKED_INSTANCE,
ProcessType.SINGLE_TOOL,
category),
units);
}
if (row == null) {
row = new PaygInstanceUsage(teamId, periodStart, category.name(), cumulative, syncSeq);
} else {
row.setLastCumulativeUnits(cumulative);
row.setLastSyncSeq(syncSeq);
}
usageRepository.save(row);
}
}
@@ -0,0 +1,74 @@
package stirling.software.saas.payg.instance;
import java.time.LocalDateTime;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Last-seen cumulative usage a linked self-hosted instance has reported for one {@code (team,
* billing period, category)} (combined-billing "Mode A"). The instance reports monotonic cumulative
* unit totals on its daily sync; SaaS bills {@code reportedCumulative - lastCumulativeUnits} via
* the standard charge path and advances this row. {@code lastSyncSeq} dedups replays.
*/
@Entity
@Table(
name = "payg_instance_usage",
uniqueConstraints =
@UniqueConstraint(
name = "uk_payg_instance_usage",
columnNames = {"team_id", "period_start", "category"}))
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PaygInstanceUsage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "team_id", nullable = false)
private Long teamId;
@Column(name = "period_start", nullable = false)
private LocalDateTime periodStart;
/** {@code BillingCategory} name — API / AI / AUTOMATION. */
@Column(name = "category", nullable = false, length = 32)
private String category;
@Column(name = "last_cumulative_units", nullable = false)
private long lastCumulativeUnits;
@Column(name = "last_sync_seq", nullable = false)
private long lastSyncSeq;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public PaygInstanceUsage(
Long teamId,
LocalDateTime periodStart,
String category,
long lastCumulativeUnits,
long lastSyncSeq) {
this.teamId = teamId;
this.periodStart = periodStart;
this.category = category;
this.lastCumulativeUnits = lastCumulativeUnits;
this.lastSyncSeq = lastSyncSeq;
}
}
@@ -1,22 +1,18 @@
package stirling.software.saas.payg.lineage;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Set;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.billing.ContentHasher;
/**
* SHA-256 of the file's bytes. The simplest universally-applicable signature — works for every
* content type, doesn't parse, doesn't allocate proportional to file size (fixed 64 KiB read
* buffer), hardware-accelerated by the JVM on modern hardware (Intel SHA-NI, ARM SHA extensions).
* content type, doesn't parse. Delegates to the shared {@link ContentHasher} so the cloud charge
* path and a linked self-hosted instance's meter compute byte-identical signatures.
*
* <p>Always returns exactly one {@link LineageSignature} of type {@code "sha256"}. A future {@code
* PdfMetadataSignatureExtractor} would be a separate bean and add its own signature type — composed
@@ -26,36 +22,15 @@ import org.springframework.stereotype.Component;
@Profile("saas")
public class ByteHashSignatureExtractor implements LineageSignatureExtractor {
private static final String ALGORITHM = "SHA-256";
private static final String SIGNATURE_TYPE = "sha256";
private static final int BUFFER_SIZE = 64 * 1024;
@Override
public Set<LineageSignature> extract(Path file) throws IOException {
MessageDigest digest = newDigest();
try (InputStream raw = Files.newInputStream(file);
DigestInputStream in = new DigestInputStream(raw, digest)) {
byte[] buf = new byte[BUFFER_SIZE];
// Drain through the digest stream; we only care about side effects on the digest.
while (in.read(buf) != -1) {
// no-op
}
}
String hex = HexFormat.of().formatHex(digest.digest());
return Set.of(new LineageSignature(SIGNATURE_TYPE, hex));
return Set.of(new LineageSignature(SIGNATURE_TYPE, ContentHasher.sha256(file)));
}
@Override
public String name() {
return SIGNATURE_TYPE;
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance(ALGORITHM);
} catch (NoSuchAlgorithmException e) {
// SHA-256 is mandated by every JDK; unreachable in practice.
throw new IllegalStateException(ALGORITHM + " unavailable — JDK is misconfigured", e);
}
}
}
@@ -15,5 +15,12 @@ public enum JobSource {
/**
* The Tauri desktop client. Independent of whether it routes to SaaS or a self-hosted backend.
*/
DESKTOP_APP
DESKTOP_APP,
/**
* Usage reported by a linked self-hosted instance via the daily sync (combined-billing "Mode
* A"). The per-request surface is lost in the aggregate — the instance reports cumulative units
* per {@code BillingCategory} — so this just marks the charge as instance-synced. No per-source
* step limit is seeded for it; the charge path's fallback applies.
*/
LINKED_INSTANCE
}
@@ -0,0 +1,35 @@
package stirling.software.saas.payg.repository;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
import stirling.software.saas.payg.instance.PaygInstanceUsage;
/** Last-seen cumulative usage per (team, period, category) for linked-instance daily syncs. */
public interface PaygInstanceUsageRepository extends JpaRepository<PaygInstanceUsage, Long> {
Optional<PaygInstanceUsage> findByTeamIdAndPeriodStartAndCategory(
Long teamId, LocalDateTime periodStart, String category);
/**
* Pessimistic-write variant the ingest uses so two concurrent deliveries of the same sync (e.g.
* a proxy retry) can't both read the same baseline and double-charge the delta. Must run inside
* a transaction.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"SELECT u FROM PaygInstanceUsage u"
+ " WHERE u.teamId = :teamId AND u.periodStart = :periodStart"
+ " AND u.category = :category")
Optional<PaygInstanceUsage> findByTeamIdAndPeriodStartAndCategoryForUpdate(
@Param("teamId") Long teamId,
@Param("periodStart") LocalDateTime periodStart,
@Param("category") String category);
}
@@ -0,0 +1,35 @@
-- Twin of supabase/migrations/<ts>_payg_instance_usage.sql (Stirling-PDF-SaaS). Keep the table
-- definition byte-identical to the Supabase twin — both repos own this stirling_pdf table (the SaaS
-- profile runs this Flyway migration against the Supabase-backed DB; non-Hibernate consumers — RLS,
-- PostgREST, edge functions — rely on the Supabase migration ledger having the matching entry).
--
-- Per-(team, billing period, category) last-seen cumulative usage reported by a linked self-hosted
-- instance (combined-billing "Mode A"). The instance reports monotonic cumulative unit totals on
-- its daily sync; SaaS bills the DELTA since the last sync — idempotent (a resend bills nothing) and
-- tamper-evident (a counter that drops is a signal) — by reusing the standard charge path
-- (JobChargeService.chargeStandalone), so no separate billing logic exists for this flow.
--
-- Inert until release: written only by the InstanceController /sync endpoint, gated behind
-- stirling.billing.account-link.enabled (default off). Additive, idempotent table.
CREATE TABLE IF NOT EXISTS stirling_pdf.payg_instance_usage (
id BIGSERIAL PRIMARY KEY,
team_id BIGINT NOT NULL REFERENCES stirling_pdf.teams(team_id) ON DELETE CASCADE,
period_start TIMESTAMP NOT NULL,
category VARCHAR(32) NOT NULL,
-- Highest cumulative unit total seen for this (team, period, category); the next sync bills
-- (reported cumulative - this).
last_cumulative_units BIGINT NOT NULL DEFAULT 0,
-- Highest sync sequence applied; a sync at or below this is a replay and is ignored.
last_sync_seq BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_payg_instance_usage UNIQUE (team_id, period_start, category)
);
CREATE INDEX IF NOT EXISTS idx_payg_instance_usage_team
ON stirling_pdf.payg_instance_usage (team_id);
COMMENT ON TABLE stirling_pdf.payg_instance_usage IS
'Last-seen cumulative usage per (team, billing period, category) reported by linked self-hosted '
'instances (combined-billing Mode A). SaaS bills the delta vs last_cumulative_units via the '
'standard charge path; last_sync_seq dedups replays.';
@@ -0,0 +1,19 @@
-- Twin of supabase/migrations/<ts>_payg_shadow_charge_linked_instance_source.sql (Stirling-PDF-SaaS).
-- Keep byte-identical to the Supabase twin.
--
-- Widen the payg_shadow_charge.job_source CHECK to allow LINKED_INSTANCE (combined-billing "Mode
-- A"). A linked instance's daily-sync charge runs through JobChargeService.chargeStandalone, which
-- writes a payg_shadow_charge row with job_source=LINKED_INSTANCE — a JobSource value added after
-- the original constraint, so the insert was failing the check and 500ing POST /api/v1/instance/sync.
--
-- Idempotent (DROP IF EXISTS + ADD, so it survives being applied by both the Flyway and Supabase
-- migration sets against the same schema) and additive (the new set is a superset of the JobSource
-- enum; the app only ever writes enum values, so no existing row can violate it).
ALTER TABLE stirling_pdf.payg_shadow_charge
DROP CONSTRAINT IF EXISTS payg_shadow_charge_job_source_check;
ALTER TABLE stirling_pdf.payg_shadow_charge
ADD CONSTRAINT payg_shadow_charge_job_source_check
CHECK (job_source IS NULL
OR job_source IN ('WEB', 'API', 'PIPELINE', 'DESKTOP_APP', 'LINKED_INSTANCE'));
@@ -1,6 +1,7 @@
package stirling.software.saas.accountlink;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@@ -8,9 +9,12 @@ import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
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;
@@ -19,14 +23,19 @@ import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import stirling.software.proprietary.billing.UnitCalcPolicy;
import stirling.software.saas.accountlink.InstanceController.EntitlementResponse;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.instance.InstanceUsageIngestService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
/**
* Pure-Mockito unit tests for {@link InstanceController} — the device-credential entitlement read.
@@ -39,9 +48,22 @@ class InstanceControllerTest {
@Mock private EntitlementService entitlementService;
@Mock private TeamBillingService billingService;
@Mock private AccountLinkService accountLinkService;
@Mock private PricingPolicyService pricingPolicyService;
@Mock private InstanceUsageIngestService usageIngestService;
@Mock private LinkedInstanceRepository linkedInstanceRepository;
private InstanceController controller() {
return new InstanceController(entitlementService, billingService, accountLinkService);
return new InstanceController(
entitlementService,
billingService,
accountLinkService,
pricingPolicyService,
usageIngestService,
linkedInstanceRepository);
}
private static PricingPolicy policy() {
return new PricingPolicy(1, 1_048_576L, 1, 1000);
}
@Test
@@ -50,6 +72,7 @@ class InstanceControllerTest {
when(billingService.forTeam(42L)).thenReturn(subscribedBilling("sub_42", 120L));
when(entitlementService.getSnapshot(42L))
.thenReturn(snapshot(EntitlementState.WARNED, 90L, 1250L));
when(pricingPolicyService.getEffectivePolicy(42L)).thenReturn(policy());
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
@@ -62,6 +85,13 @@ class InstanceControllerTest {
assertThat(body.periodCapUnits()).isEqualTo(1250L);
// WARNED is still within budget for the gate's purposes → coarse OK.
assertThat(body.state()).isEqualTo("OK");
// Phase 2: the metering inputs the instance needs ride along.
assertThat(body.unitCalcPolicy()).isEqualTo(new UnitCalcPolicy(1, 1_048_576L, 1, 1000));
assertThat(body.periodStart()).isNotNull();
assertThat(body.periodEnd()).isNotNull();
// The instance-facing read drops the cached snapshot first so a just-subscribed team's
// plan surfaces on the next poll instead of waiting out the cache TTL.
verify(entitlementService).invalidate(42L);
}
@Test
@@ -70,6 +100,7 @@ class InstanceControllerTest {
when(billingService.forTeam(7L)).thenReturn(freeBilling(500L));
when(entitlementService.getSnapshot(7L))
.thenReturn(snapshot(EntitlementState.FULL, 0L, null));
when(pricingPolicyService.getEffectivePolicy(7L)).thenReturn(policy());
ResponseEntity<EntitlementResponse> resp = controller().entitlement(token);
@@ -89,6 +120,7 @@ class InstanceControllerTest {
when(billingService.forTeam(8L)).thenReturn(subscribedBilling("sub_8", 0L));
when(entitlementService.getSnapshot(8L))
.thenReturn(snapshot(EntitlementState.DEGRADED, 1300L, 1250L));
when(pricingPolicyService.getEffectivePolicy(8L)).thenReturn(policy());
EntitlementResponse body = controller().entitlement(token).getBody();
@@ -110,6 +142,59 @@ class InstanceControllerTest {
verifyNoInteractions(entitlementService, billingService);
}
@Test
void sync_ingestsCumulativePerCategoryAndReturnsFreshEntitlement() {
Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L);
LinkedInstance li = new LinkedInstance();
li.setCreatedByUserId(7L);
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
when(linkedInstanceRepository.findById(4L)).thenReturn(Optional.of(li));
when(billingService.forTeam(99L)).thenReturn(freeBilling(10L));
// The reported periodStart is validated against the authoritative snapshot period.
when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null));
when(pricingPolicyService.getEffectivePolicy(99L)).thenReturn(policy());
InstanceController.UsageSyncRequest req =
new InstanceController.UsageSyncRequest(
3L,
period,
new InstanceController.UsageSyncRequest.CategoryUnits(12, 4, 8));
ResponseEntity<EntitlementResponse> resp = controller().sync(token, req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<BillingCategory, Long>> cumulative = ArgumentCaptor.forClass(Map.class);
verify(usageIngestService)
.ingest(eq(99L), eq(7L), eq(3L), eq(period), cumulative.capture());
assertThat(cumulative.getValue())
.containsEntry(BillingCategory.API, 12L)
.containsEntry(BillingCategory.AI, 4L)
.containsEntry(BillingCategory.AUTOMATION, 8L);
// The sync drops the team's cached snapshot so the just-charged delta (and the free-grant
// balance it moved) show on the next wallet read instead of lagging out the 30s TTL.
verify(entitlementService).invalidate(99L);
}
@Test
void sync_rejectsImplausiblePeriodStart() {
Authentication token = new LinkedInstanceAuthenticationToken(4L, 99L);
LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
when(entitlementService.getSnapshot(99L)).thenReturn(snapshotForPeriod(period, null));
// A fabricated far-future periodStart (would reset the dedup partition) → 400, no ingest.
InstanceController.UsageSyncRequest req =
new InstanceController.UsageSyncRequest(
1L,
period.plusYears(5),
new InstanceController.UsageSyncRequest.CategoryUnits(99, 0, 0));
ResponseEntity<EntitlementResponse> resp = controller().sync(token, req);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verifyNoInteractions(usageIngestService);
}
@Test
void revokeSelf_callsServiceWithTokenIdentityAndReturns204() {
Authentication token = new LinkedInstanceAuthenticationToken(11L, 22L);
@@ -187,4 +272,17 @@ class InstanceControllerTest {
start.plusMonths(1),
false);
}
/** Snapshot with an explicit period — the sync tests need a deterministic period window. */
private static EntitlementSnapshot snapshotForPeriod(LocalDateTime start, Long cap) {
return new EntitlementSnapshot(
EntitlementState.FULL,
FeatureSet.FULL,
List.of(FeatureGate.OFFSITE_PROCESSING),
0L,
cap,
start,
start.plusMonths(1),
false);
}
}
@@ -432,6 +432,52 @@ class PaygWalletControllerTest {
verifyNoInteractions(policyRepo, entitlementService);
}
// -----------------------------------------------------------------------------------------
// POST /wallet/refresh
// -----------------------------------------------------------------------------------------
@Test
void refreshWallet_dropsCallerTeamCache() {
User user = userWithId(30L, UUID.randomUUID());
Team team = teamWithId(70L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(30L))
.thenReturn(List.of(membership(team, user, TeamRole.MEMBER)));
ResponseEntity<Void> resp = controller.refreshWallet(jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// Portal pokes this after checkout so the next /wallet read reflects the subscription
// immediately rather than after the cache TTL.
verify(entitlementService).invalidate(70L);
}
@Test
void refreshWallet_noTeam_isNoOpButOk() {
User user = userWithId(31L, UUID.randomUUID());
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(31L)).thenReturn(List.of());
ResponseEntity<Void> resp = controller.refreshWallet(jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
verify(entitlementService, never()).invalidate(any());
}
@Test
void refreshWallet_anonymousIs401() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<Void> resp = controller.refreshWallet(anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(entitlementService);
}
// -----------------------------------------------------------------------------------------
// Fixtures
// -----------------------------------------------------------------------------------------
@@ -913,6 +913,52 @@ class JobChargeServiceTest {
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void chargeStandalone_floorsUnitsAtMinChargeUnits() {
// Pins the per-call minChargeUnits floor that the linked-instance sync path inherits: a
// daily delta below the floor bills the floor (max(delta, minChargeUnits)) — applied per
// sync-delta here, not per underlying op (documented divergence from the in-cloud per-op
// floor; can only under-bill vs per-op, never over).
long teamId = 100L;
PricingPolicy policy = stubPolicy(/*minCharge*/ 5, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(teamId)).thenReturn(policy);
UUID jobId = UUID.randomUUID();
when(jobService.open(any(JobContext.class), eq(5))).thenReturn(openJob(jobId));
when(jobService.close(jobId)).thenReturn(openJob(jobId));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(teamId);
ext.setStripeCustomerId("cus_x");
ext.setPaygSubscriptionId("sub_x");
ext.setFreeUnitsRemaining(0L);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
.thenReturn(
Optional.of(chargedShadowRow(jobId, teamId, 5, 0, BillingCategory.API)));
ChargeContext ctx =
new ChargeContext(
7L, teamId, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API);
ArgumentCaptor<WalletLedgerEntry> ledger = ArgumentCaptor.forClass(WalletLedgerEntry.class);
withTransactionSynchronization(() -> service.chargeStandalone(ctx, 2));
// Delta of 2 floored to minChargeUnits=5: the job, ledger debit, and meter all use 5.
verify(jobService).open(any(JobContext.class), eq(5));
verify(ledgerRepo).save(ledger.capture());
assertThat(ledger.getValue().getAmountUnits()).isEqualTo(-5);
verify(meterReporter)
.recordUsage(
eq(teamId),
eq("cus_x"),
eq(5),
eq(BillingCategory.API),
eq("process:" + jobId + ":close"),
eq(jobId));
}
private static void withTransactionSynchronization(Runnable body) {
TransactionSynchronizationManager.initSynchronization();
try {
@@ -0,0 +1,135 @@
package stirling.software.saas.payg.instance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
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.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 stirling.software.saas.payg.charge.ChargeContext;
import stirling.software.saas.payg.charge.JobChargeService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.repository.PaygInstanceUsageRepository;
@ExtendWith(MockitoExtension.class)
class InstanceUsageIngestServiceTest {
@Mock private PaygInstanceUsageRepository repo;
@Mock private JobChargeService chargeService;
private InstanceUsageIngestService service;
private final LocalDateTime period = LocalDateTime.of(2026, 6, 1, 0, 0);
@BeforeEach
void setUp() {
service = new InstanceUsageIngestService(repo, chargeService);
}
@Test
void firstSyncChargesFullCumulativeAndSavesRow() {
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "AI"))
.thenReturn(Optional.empty());
service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.AI, 10L));
ArgumentCaptor<ChargeContext> ctx = ArgumentCaptor.forClass(ChargeContext.class);
verify(chargeService).chargeStandalone(ctx.capture(), eq(10));
assertThat(ctx.getValue().ownerTeamId()).isEqualTo(1L);
assertThat(ctx.getValue().ownerUserId()).isEqualTo(7L);
assertThat(ctx.getValue().billingCategory()).isEqualTo(BillingCategory.AI);
assertThat(ctx.getValue().source()).isEqualTo(JobSource.LINKED_INSTANCE);
ArgumentCaptor<PaygInstanceUsage> row = ArgumentCaptor.forClass(PaygInstanceUsage.class);
verify(repo).save(row.capture());
assertThat(row.getValue().getLastCumulativeUnits()).isEqualTo(10L);
assertThat(row.getValue().getLastSyncSeq()).isEqualTo(1L);
}
@Test
void secondSyncChargesOnlyDelta() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 10L, 1L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L));
// One charge for the aggregated delta (15), not per underlying op — pins the per-delta
// model.
verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(15));
verify(repo).save(existing);
assertThat(existing.getLastCumulativeUnits()).isEqualTo(25L);
assertThat(existing.getLastSyncSeq()).isEqualTo(2L);
}
@Test
void replayIsIgnored() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 2L, period, Map.of(BillingCategory.API, 25L));
verify(chargeService, never()).chargeStandalone(any(), anyInt());
verify(repo, never()).save(any());
}
@Test
void regressionIsRefusedAndNotAdvanced() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 5L));
verify(chargeService, never()).chargeStandalone(any(), anyInt());
verify(repo, never()).save(any());
}
@Test
void zeroDeltaAdvancesSeqWithoutCharging() {
PaygInstanceUsage existing = new PaygInstanceUsage(1L, period, "API", 25L, 2L);
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.of(existing));
service.ingest(1L, 7L, 3L, period, Map.of(BillingCategory.API, 25L));
verify(chargeService, never()).chargeStandalone(any(), anyInt());
verify(repo).save(existing);
assertThat(existing.getLastSyncSeq()).isEqualTo(3L);
}
@Test
void billsAccruedDeltaWithoutConsultingCap() {
// Intent pin: the ingest has no cap input and always bills the accrued delta — cap
// enforcement is the request-time gate's job (the instance stops accruing at the cap), not
// this aggregate charge path's. A large valid delta is billed in full.
when(repo.findByTeamIdAndPeriodStartAndCategoryForUpdate(1L, period, "API"))
.thenReturn(Optional.empty());
service.ingest(1L, 7L, 1L, period, Map.of(BillingCategory.API, 5_000_000L));
verify(chargeService).chargeStandalone(any(ChargeContext.class), eq(5_000_000));
}
@Test
void nullActorSkipsEntirely() {
service.ingest(1L, null, 1L, period, Map.of(BillingCategory.AI, 10L));
verifyNoInteractions(repo, chargeService);
}
}
+2 -2
View File
@@ -17,8 +17,8 @@ Apply Stylelint to your project's CSS with the following steps:
```jsonc
{
"scripts": {
"lint:css:check": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config ../.stylelintrc.json",
"lint:css:fix": "stylelint \"../app/core//src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json --fix"
"lint:css:check": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json",
"lint:css:fix": "stylelint \"../app/core/src/main/**/*.css\" \"../app/proprietary/src/main/resources/static/css/*.css\" --config .stylelintrc.json --fix"
}
}
```
+1 -1
View File
@@ -132,7 +132,7 @@ Feel free to expand this plan or add notes as the work progresses.
| Stage | Tool / Command | Output |
| --- | --- | --- |
| 1. Collect PDFs | `python scripts/download_pdf_collection.py --output scripts/pdf-collection` (or drop your own PDFs anywhere) | Raw PDFs ready for harvesting |
| 1. Collect PDFs | `python scripts/download_pdf_samples.py --output-dir scripts/pdf-collection` (or drop your own PDFs anywhere) | Raw PDFs ready for harvesting |
| 2. Harvest signatures | `python scripts/harvest_type3_fonts.py --input scripts/pdf-collection --pretty` | Per-PDF dumps in `docs/type3/signatures/…` + global summary `docs/type3/harvest_report.json` |
| 3. Summarize backlog | `python scripts/summarize_type3_signatures.py` | `docs/type3/signature_inventory.md` (human checklist of aliases/signatures) |
| 4. Convert fonts | Either copy the upstream TTF/OTF for the font (DejaVu, CM, STIX, etc.) or run `scripts/type3_to_cff.py` against the harvested glyph JSON to synthesize one offline; store the result under `app/core/src/main/resources/type3/library/fonts/<family>/`. | Canonical font binaries |
+1 -2
View File
@@ -11,7 +11,6 @@
# production
/build
/dist
/dist-portal
/storybook-static
/editor/build
@@ -27,8 +26,8 @@
# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored
# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS
# mock env is injected via .storybook/main.ts, not a file).
!/portal/.env
!/editor/.env
!/editor/.env.proprietary
!/editor/.env.desktop
!/editor/.env.saas
+1 -2
View File
@@ -1,5 +1,4 @@
dist/
dist-portal/
editor/dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
@@ -9,7 +8,7 @@ editor/src-tauri/gen/
node_modules/
editor/public/vendor/
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
portal/public/mockServiceWorker.js
editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/src/core/data/ogImageMap.json
+15 -14
View File
@@ -6,16 +6,13 @@ import tsconfigPaths from "vite-tsconfig-paths";
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
* addon list is just the extras we want: theme switching + a11y auditing.
*
* Story files live next to their components in shared/, portal/src/, and
* editor/src/ — the design system is shared by BOTH apps, so both surface
* their stories here. MDX docs pages live in portal/src/docs/.
* Story files live next to their components under editor/src/ (which includes
* the portal layer at editor/src/portal/). MDX docs pages live in
* editor/src/portal/docs/.
*/
const config: StorybookConfig = {
stories: [
"../portal/src/**/*.mdx",
"../portal/src/**/*.stories.@(ts|tsx)",
"../shared/**/*.mdx",
"../shared/**/*.stories.@(ts|tsx)",
"../editor/src/portal/**/*.mdx",
"../editor/src/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
@@ -26,17 +23,21 @@ const config: StorybookConfig = {
typescript: {
reactDocgen: "react-docgen-typescript",
},
// Serve the MSW worker file from portal/public so Storybook can intercept
// network calls the same way the dev portal does.
staticDirs: ["../portal/public"],
// Serve the MSW worker file from the portal's public dir so Storybook can
// intercept network calls the same way the dev portal does.
staticDirs: ["../editor/public"],
viteFinal: async (config) => {
// Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
// portal story imports resolve without needing the portal's vite config.
// Wire the @portal/* alias directly on the Storybook bundler so portal
// story imports resolve without needing the portal's vite config.
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@portal": resolve(__dirname, "../portal/src"),
"@shared": resolve(__dirname, "../shared"),
"@portal": resolve(__dirname, "../editor/src/portal"),
// Direct layer aliases so .storybook config files (preview.tsx), which sit
// outside src/ and so aren't covered by tsconfigPaths, can import layer
// modules (e.g. the auth supabase client that moved into proprietary).
"@proprietary": resolve(__dirname, "../editor/src/proprietary"),
"@core": resolve(__dirname, "../editor/src/core"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
+3 -3
View File
@@ -19,11 +19,11 @@ import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import "@mantine/core/styles.css";
import "@shared/tokens/tokens.css";
import "@shared/tokens/base.css";
import "@core/tokens/tokens.css";
import "@core/tokens/base.css";
// Start MSW once. Storybook runs in a browser so this uses the service worker.
initialize({ onUnhandledRequest: "bypass" }, handlers);
+32
View File
@@ -0,0 +1,32 @@
###############################################################################
# Proprietary-build environment variables, layered on top of `.env` when Vite
# runs in `--mode proprietary` (so the core/OSS build never sees them). These
# back the admin portal mounted at /portal. Committed to Git, so no private keys;
# machine-specific overrides go in the uncommitted .env.proprietary.local /
# .env.local. (VITE_STRIPE_PUBLISHABLE_KEY lives in the base `.env` as it is
# shared across builds.)
###############################################################################
# Where the portal's "Editor" switcher / non-admin redirect points. "/" is
# correct: the editor serves the portal at /portal on the same origin. In dev,
# override in .env.local with your running editor's URL (e.g.
# VITE_EDITOR_URL=http://localhost:5173/).
VITE_EDITOR_URL=/
# Force the portal's MSW mocks on ("true") or off ("false"). Empty = default
# (on in dev, off in production builds). Set "false" to run against the real
# backend.
VITE_PORTAL_MOCKS=
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
# linking (both values are public). Set per deploy; absent -> the account-link
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
# project the local backend links against.
VITE_SAAS_SUPABASE_URL=
VITE_SAAS_SUPABASE_ANON_KEY=
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
# the editor's vite proxy). Absent -> wallet/billing surfaces stay on the mock.
VITE_SAAS_API_URL=
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
/**
* Editor cloud adapter over the shared {@code @shared/billing} spend-cap control:
* Editor cloud adapter over the shared {@code @app/billing} spend-cap control:
* supplies the i18n copy (the shared control is copy-agnostic) and the editor's
* {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
* onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import {
DEFAULT_CAP_PRESETS,
SpendCapControl as SharedSpendCapControl,
} from "@shared/billing";
} from "@app/billing";
// eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
@@ -7,7 +7,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useWallet, type Wallet } from "@app/hooks/useWallet";
import { currencySymbol, MeterBar, meterState } from "@shared/billing";
import { currencySymbol, MeterBar, meterState } from "@app/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
+2 -2
View File
@@ -57,10 +57,10 @@ import type {
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
} from "@shared/billing";
} from "@app/billing";
// ─── Public types ───────────────────────────────────────────────────────
// The wallet contract lives in @shared/billing (shared with the admin portal).
// The wallet contract lives in @app/billing (shared with the admin portal).
// Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
export type {
Wallet,
+2 -2
View File
@@ -7,10 +7,10 @@
"../../src/proprietary/*",
"../../src/core/*"
],
"@portal/*": ["../../src/portal/*"],
"@cloud/*": ["../../src/cloud/*"],
"@proprietary/*": ["../../src/proprietary/*"],
"@core/*": ["../../src/core/*"],
"@shared/*": ["../../../shared/*"]
"@core/*": ["../../src/core/*"]
}
},
"include": [
@@ -2,16 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
// Visual catalogue of the shared brand assets (shared/assets/brand) — the single
// source of truth for the Stirling logos used across the apps.
import modernMarkDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import modernMarkLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import modernBlack from "@shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg";
import modernWhite from "@shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg";
import modernGrey from "@shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg";
import classicMarkDark from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg";
import classicMarkLight from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg";
import classicBlack from "@shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg";
import classicWhite from "@shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
import classicGrey from "@shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
import modernMarkDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import modernMarkLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import modernBlack from "@app/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg";
import modernWhite from "@app/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg";
import modernGrey from "@app/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg";
import classicMarkDark from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg";
import classicMarkLight from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg";
import classicBlack from "@app/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg";
import classicWhite from "@app/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
import classicGrey from "@app/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
type Asset = { label: string; src: string; onDark?: boolean };
type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] };

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

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