Skip to content

[pull] main from Stirling-Tools:main - #329

Merged
pull[bot] merged 3 commits into
5474312:mainfrom
Stirling-Tools:main
Sep 2, 2026
Merged

[pull] main from Stirling-Tools:main#329
pull[bot] merged 3 commits into
5474312:mainfrom
Stirling-Tools:main

Conversation

@pull

@pull pull Bot commented Sep 2, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

ConnorYoh and others added 3 commits September 2, 2026 13:57
Needs the schema half: Stirling-Tools/Stirling-PDF-SaaS#327

## Current state

The PAYG free allowance is a one-time lifetime pool.
`pricing_policy.free_tier_units` is copied into
`payg_team_extensions.free_units_remaining` once, at team creation (V14
trigger, updated in V19), and the charge pipeline decrements it until it
reaches zero. Nothing ever puts it back.

## Problem

The product promises a monthly allowance the billing model does not
grant.

- The account-link connect dialog advertises "500 free per month". That
has **merged to main** (#7415), so the claim is live and unhonoured
until this lands.
- The wallet meter already read "Process 500 PDFs free, then $X/PDF",
which reads as an allowance-then-meter model.
- `SignupRequiredBootstrap`'s own doc comment described a "free
500-op/month allowance" while its copy said only "500 free operations".

Three separate comments asserted the opposite in code
(`billing/types.ts`, `WalletSnapshotResponse`, `TeamBillingContext`), so
the two halves of the repo disagreed about what a customer is owed.

## Solution

The grant now recurs each billing period, **for every team**. Paying
does not cost you the allowance: a subscribed team draws its grant first
each period and meters only the excess, which is what the meter's copy
always described. That also matches how the grant already worked at
charge time, where it reduced metered units regardless of subscription.

### The reset is lazy, with no scheduler

`payg_team_extensions` gains `free_units_period_start`: the period
`free_units_remaining` was last written for.

- A stamp older than the current period start, or absent as on every
existing row, means the reset is owed.
`TeamBillingService.remainingForPeriod` projects it to a full grant, so
the entitlement gate and the wallet both show it the instant the period
turns.
- `JobChargeService.consumeFreeGrant` persists it on the next charge,
under the pessimistic row lock that already makes the per-job free/paid
split exact.

One rule, both callers, so display and enforcement cannot drift onto
separate schedules. A team that runs nothing for a month has nothing to
write, and no job is needed to hand out the grant.

### One period definition

"Per period" is `TeamBillingContext.periodStart`: the Stripe
subscription's current period when subscribed, the calendar month
otherwise. It was already the only period notion in the system, so the
grant joined it rather than inventing its own:

- `InstanceEntitlement.periodCapUnits` is enforced over the same window.
- `localUsageService.currentPeriodUnsynced` already buckets a linked
instance's local usage by the `periodStart` it reads from the same
snapshot, and resets its counters on that boundary.

For an un-subscribed team, the only kind the grant gates, that window is
the calendar month, which is what the copy promises.

The period rule stays in Java by choice, not necessity: SQL could reach
the Stripe period through the sync engine, but restating the rule there
would give it a second home to drift from. Hence a nullable column and
no backfill in the migration — NULL already means "stale", so every
existing team reads as owed the current period's grant.

### Refunds

A refund landing after the period turned would have stacked last
period's units on top of the fresh grant.
`JobChargeService.restoreFreeGrant` now clamps the restore to one
period's grant, taking the same row lock, and the bulk-increment
`restoreFreeUnits` query is gone. Removing it also removed a `@Query`
string that no test would have parsed before application startup.

### Copy and comments

Every comment and user-facing string that asserted the lifetime model is
corrected. The strings that changed (code defaults and `en-US` TOML
updated together):

| Key | Now reads |
| --- | --- |
| `portal.billing.walletMeter.title` / `titleWithRate` | "500 free
credits every month, then $X per PDF" |
| `portal.billing.walletMeter.capSuffix` / `barAria` | "of 500 free
credits left this month" / "Free credits remaining" |
| `payg.free.hero.capSuffix` | "of 500 free PDFs left this month" |
| `plan.freeLimit.message` | "...this month. ... It resets next month,
or keep the momentum going now..." |
| `payg.signupRequired.body` | "500 free operations a month" |

Main rewrote these keys to "500 free credits to start" while this branch
was open. The merge keeps main's credits vocabulary and drops "to
start", which asserts the one-time grant this branch removes and which
main's own connect dialog already contradicts.

Also fixed in passing: `testing/compose/payg/saas-seed.sql` still
inserted `free_tier_units_per_cycle`, the pre-V19 column name, so that
INSERT had been failing since the rename.

## How to test

Backend:

```bash
STIRLING_FLAVOR=saas ./gradlew :saas:test spotlessCheck
```

Frontend:

```bash
task frontend:typecheck && task frontend:lint && task frontend:format:check
```

New coverage, 10 tests:

- `TeamBillingServiceMoreTest` — a past-period stamp reads as a fresh
grant, a current stamp reads the stored balance, an unstamped row reads
as a fresh grant, the grant follows the Stripe window rather than the
calendar month, plus the `remainingForPeriod` rule itself including a
future stamp and null/negative balances.
- `JobChargeServiceTest` — the first charge of a new period resets and
re-stamps, an unstamped row resets, a zero-grant policy still advances
the stamp, and a refund crossing a period boundary does not exceed the
grant.

Manually, against a team whose grant is spent: set
`free_units_period_start` back a month (or leave it NULL) and the
wallet, the sidebar meter and the entitlement gate should all show a
full grant before any job runs. The first billable job should then draw
from it and write the reset.

Three tests fail on a local Windows run and pass in CI, on files this
branch does not touch: `workbenchSession.test.ts`,
`notificationActions.test.tsx`, and `:proprietary`
`FolderIdentitiesTest.identityAgreesAcrossASymlinkedAliasOfTheDirectory`.
Nothing to do here — noted so a local run does not look like a
regression.

## Merge order

The migration is additive, and Hibernate `ddl-auto=update` will add the
column in a dev environment, so either order works locally. Beyond that
the schema goes first: Stirling-Tools/Stirling-PDF-SaaS#327 targets `v3`
(staging), so it needs to reach an environment before this lands there.
Description of Changes

Adds folder kinds so the file manager can work with real directories on
disk.

Desktop
- New folder is now a menu with two options: "Add local folder" and "New
folder on the server".
- Add local folder opens the native picker and mounts a directory. Files
are listed straight from disk, nothing is copied in.
- Subfolders show inside a mount and open like any folder. New folder
inside a mount creates a real directory on disk.
- Moving, dropping or uploading files into a mount writes them to the
directory. The app copy is only removed after the write succeeds. Name
clashes get a " (2)" suffix.
- Mounted files get thumbnails.
- Adding the same directory twice just returns the existing mount.
- Removing a mount never touches the disk.
- The server option is disabled in local mode with a sign in message.

Web + desktop
- Uploading or dropping files while inside a folder puts them in that
folder instead of Local.
- Files can be dragged onto folders in the grid and the tree to move
them.
- Folders show an origin badge (cloud or local).
- The Local view now means files that are not in any folder.

Follow ups for a future pr
- Mount listing cap: large directories currently show the 500 most
recent files with no notice. Will be removed as part of the
virtualisation/performance PR.
- Folders within folders need to be supported
- Symlinks in mounts: currently not listed. Behaviour to be decided
alongside the wider folder work.
# Description of Changes
Combine the Policies and Pipelines pages into one, so we have the new
concept of Policies as Pipelines that always run which the user cannot
disable. What used to be Policies are now referred to as Templates, and
they allow you to create a new Pipeline more easily with the simple UI.

There's followup work to be done here to improve the template UIs
because they've not been touched in a long time, but I've considered
that beyond the scope of this merge. The only real changes I've made to
them in this PR is that they have a toggle for whether they're policies,
they now have a "Customise" button to kick you into the full Pipeline
editor, and I've removed the source selection. Previously, they
supported selecting as many sources as you liked, but that feature never
worked and is incompatible with the backend as it stands now, which only
allows for one source. Because of that, I've made it so that they can
only run in editor unless you open them in the custom pipeline editor,
where you can switch out which source it will use.

There's also another bit of followup to rename and remove all the
previous Policies code. Now that they've been combined into one, we
don't need a lot of the Policies code anymore, but also there's about
300 files in the frontend referencing policies in text/comments which
need to be updated to say pipelines. This is way more work than is
reasonable to do in this PR so I'll just do it in a new PR.

## Limitations
This PR is about the merging of the old Policies and Pipelines and I'm
considering enforcing the new definition of a Policy where it's only
modifiable by admins beyond the scope of this PR.

<img width="756" height="395" alt="image"
src="https://github.com/user-attachments/assets/d31be5ce-f1c9-46b3-8e8d-866e63f89a81"
/>

<img width="1507" height="793" alt="image"
src="https://github.com/user-attachments/assets/9ba8875f-8be5-4881-91cf-40e0bc1076dc"
/>

<img width="1508" height="787" alt="image"
src="https://github.com/user-attachments/assets/3e1da77b-a0c0-4262-aad3-16650098db81"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
@pull pull Bot locked and limited conversation to collaborators Sep 2, 2026
@pull pull Bot added the ⤵️ pull label Sep 2, 2026
@pull
pull Bot merged commit aca0e40 into 5474312:main Sep 2, 2026
@github-actions github-actions Bot added Front End Issues or pull requests related to front-end development Java Pull requests that update Java code Translation Issues or pull requests related to translation Test Testing-related issues or pull requests Tauri Pull requests that update Tauri code labels Sep 2, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

⤵️ pull Front End Issues or pull requests related to front-end development Java Pull requests that update Java code Tauri Pull requests that update Tauri code Test Testing-related issues or pull requests Translation Issues or pull requests related to translation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants