Reset the PAYG free grant each billing period (#7709)

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.
This commit is contained in:
ConnorYoh
2026-09-02 13:57:56 +00:00
committed by GitHub
parent 798ba57f0b
commit 3056e5ff44
33 changed files with 484 additions and 211 deletions
@@ -132,10 +132,7 @@ public class PaygWalletController {
Objects.requireNonNull(prepaidBundleService, "prepaidBundleService");
}
// ---------------------------------------------------------------------------------------
// GET /wallet — the single FE fetch
// ---------------------------------------------------------------------------------------
/** The single wallet fetch the frontend makes; every figure on the Plan page comes from it. */
@GetMapping("/wallet")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
@@ -175,9 +172,8 @@ public class PaygWalletController {
: null;
// Per-state by construction (see EntitlementService.computeSnapshot): free team → spend is
// lifetime free used, cap is the grant size; subscribed → spend is this month's net
// billable
// docs, cap is the monthly paid-doc ceiling (null = uncapped).
// this period's free used, cap is the period grant size; subscribed → spend is this
// period's net billable docs, cap is the monthly paid-doc ceiling (null = uncapped).
int spend = clampToInt(snap.periodSpendUnits());
Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null;
@@ -328,10 +324,7 @@ public class PaygWalletController {
};
}
// ---------------------------------------------------------------------------------------
// PATCH /cap — leader-only, cap is application-layer, no Stripe call
// ---------------------------------------------------------------------------------------
/** Leader-only. The cap is enforced in the application layer; Stripe is never called. */
@PatchMapping("/cap")
@PreAuthorize("isAuthenticated()")
@Transactional
@@ -395,10 +388,6 @@ 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
@@ -421,10 +410,6 @@ public class PaygWalletController {
return ResponseEntity.noContent().build();
}
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
private Optional<TeamMembership> primaryMembership(Long userId) {
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.getFirst());
@@ -9,7 +9,7 @@ import java.util.List;
* breakdowns, recent activity) used by the PAYG Plan page.
*
* <p>Every number is real: the billing window is the Stripe subscription's current period (via Sync
* Engine) for subscribed teams, the one-time free grant size comes from {@code
* Engine) for subscribed teams, the per-period free grant size comes from {@code
* pricing_policy.free_tier_units} (live balance from {@code
* payg_team_extensions.free_units_remaining}), and the per-document rate comes from the
* subscription's Stripe Price. Fields that can't be resolved are {@code null} and the FE renders
@@ -26,15 +26,15 @@ import java.util.List;
* subscription period when subscribed, the calendar month otherwise.
* @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle.
* @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE. For a free
* team this is the lifetime free documents used so far ({@code freeAllowance freeRemaining});
* for a subscribed team it's this month's net billable documents.
* @param billableLimit the team's document ceiling for the matching window: the one-time free grant
* ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month for
* capped subscribed teams; {@code null} when subscribed with no cap (uncapped).
* @param freeAllowance the team's one-time free document grant size (the "N" in "X of N free").
* Never resets; survives subscribing. Applies to billable categories only.
* @param freeRemaining one-time free documents still available to the team ({@code
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* team this is the free documents used so far this period ({@code freeAllowance
* freeRemaining}); for a subscribed team it's this period's net billable documents.
* @param billableLimit the team's document ceiling for the matching window: this period's free
* grant ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month
* for capped subscribed teams; {@code null} when subscribed with no cap (uncapped).
* @param freeAllowance the team's free document grant size per period (the "N" in "X of N free").
* Resets each period. Applies to billable categories only.
* @param freeRemaining free documents still available to the team this period ({@code
* payg_team_extensions.free_units_remaining}). 0 = this period's grant is exhausted.
* @param pricePerDocMinor paid per-document rate in minor units of {@code currency} (may be
* fractional — Stripe supports sub-cent rates); {@code null} when the rate can't be resolved.
* @param currency lower-case ISO 4217 currency of the subscription's Stripe Price; {@code null}
@@ -4,28 +4,20 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One team's billing facts, composed by {@link TeamBillingService}. Two independent meters live
* here and must not be conflated:
*
* <ul>
* <li>the <b>one-time lifetime free grant</b> ({@link #freeGrantUnits} total, {@link
* #freeRemainingUnits} left) — gates an un-subscribed team and decides the free-vs-paid split
* of every job; never resets, survives subscribing;
* <li>the <b>monthly billing window</b> ({@link #periodStart}/{@link #periodEnd}) and the
* optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice
* + cap only.
* </ul>
* One team's billing facts, composed by {@link TeamBillingService}. The free grant and the spending
* cap are separate pools measured over one window.
*
* @param subscribed team has a live PAYG subscription — i.e. {@code payg_subscription_id} is set.
* Cleared by {@code payg_unlink_subscription} on cancellation, so a cancelled team reads false.
* @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free
* @param periodStart inclusive start of the monthly billing window — the Stripe subscription's
* current period when subscribed, calendar month otherwise
* @param periodEnd exclusive end of the monthly billing window
* @param freeGrantUnits the team's one-time free grant size (policy {@code free_tier_units}); the
* denominator for "used X of N free". Never resets.
* @param freeRemainingUnits one-time free documents still available ({@code
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* @param periodStart inclusive start of the billing window — the Stripe subscription's current
* period when subscribed, calendar month otherwise. Also the period the free grant resets on.
* @param periodEnd exclusive end of the billing window
* @param freeGrantUnits the team's free grant size per period (policy {@code free_tier_units}); the
* denominator for "used X of N free"
* @param freeRemainingUnits free documents still available in this period ({@code
* payg_team_extensions.free_units_remaining}, via {@code
* TeamBillingService.remainingForPeriod}). 0 = exhausted.
* @param perDocMinor paid per-document rate in minor units of {@link #currency()}; null when the
* rate can't be resolved (free team, price row unsynced) — display "unknown", never substitute
* @param currency lower-case ISO 4217 of the subscription's Price; null when unknown
@@ -30,17 +30,8 @@ import stirling.software.saas.payg.wallet.WalletPolicy;
* entitlement hot path and the wallet endpoint read from here, so what the customer sees is what
* the guard enforces.
*
* <p>Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant):
*
* <ul>
* <li><b>Free grant</b> — one-time, per team. Size from {@code pricing_policy.free_tier_units};
* live balance from the {@code payg_team_extensions.free_units_remaining} counter (maintained
* by the charge pipeline). Never resets, survives subscribing. Gates un-subscribed teams and
* drives the free-vs-paid split.
* <li><b>Monthly window + cap</b> — the Stripe subscription period (calendar month otherwise) and
* the optional money cap. Govern the subscribed invoice + spending cap only. The per-document
* rate is the synced {@code stripe.prices.unit_amount} (PAYG prices are plain per-unit).
* </ul>
* <p>The free grant and the spending cap are separate pools measured over one window: the Stripe
* subscription period when subscribed, the calendar month otherwise.
*
* <p>Cached per team for {@value #CACHE_TTL_SECONDS}s. {@code EntitlementService.invalidate}
* cascades into {@link #invalidate(Long)} so both caches drop together on cap edits / webhooks.
@@ -133,12 +124,6 @@ public class TeamBillingService {
// bug this guards against.
boolean subscribed = subscriptionId != null;
long freeGrant = resolveGrant(teamId);
long freeRemaining =
extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining)
.map(Long::longValue)
.orElse(0L);
Optional<SubscriptionBilling> billing =
subscriptionId != null
? subscriptionDao.findBilling(subscriptionId)
@@ -148,6 +133,19 @@ public class TeamBillingService {
billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()})
.orElseGet(TeamBillingService::calendarMonthWindow);
long freeGrant = resolveGrant(teamId);
// The reset is persisted lazily by the charge pipeline, so the raw counter still reads as
// last period's for a team that has run nothing since the boundary.
long freeRemaining =
extOpt.map(
ext ->
remainingForPeriod(
ext.getFreeUnitsPeriodStart(),
ext.getFreeUnitsRemaining(),
freeGrant,
window[0]))
.orElse(0L);
BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null);
String currency = billing.map(SubscriptionBilling::currency).orElse(null);
@@ -184,7 +182,6 @@ public class TeamBillingService {
monthlyCapDocUnits);
}
/** The policy grant size — the "N" denominator for display; the counter is the live balance. */
private long resolveGrant(Long teamId) {
try {
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
@@ -198,8 +195,8 @@ public class TeamBillingService {
/**
* The subscribed monthly paid-document ceiling; {@code null} = uncapped or not subscribed. The
* one-time free grant is NOT added here — it's a separate lifetime pool consumed at charge
* time. The cap purely limits how many paid documents the team will fund per billing period.
* free grant is NOT added here — it's a separate per-period pool consumed at charge time, ahead
* of the meter. The cap purely limits how many paid documents the team will fund per period.
*
* <ul>
* <li>not subscribed → null (the free grant, not a money cap, is what bounds them);
@@ -250,7 +247,7 @@ public class TeamBillingService {
/**
* Documents a hypothetical monthly money cap would buy: {@code floor(capMinor / rate)}. Used by
* the cap editor's live preview and the {@code PATCH /cap} derived write. The free grant is NOT
* added — it's a separate one-time pool. Empty when the rate is unknown.
* added — it's a separate per-period pool. Empty when the rate is unknown.
*/
public Optional<Long> docCapForMoney(TeamBillingContext ctx, long capMinor) {
if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) {
@@ -279,6 +276,28 @@ public class TeamBillingService {
.orElse(null);
}
/**
* The team's free balance for the period starting at {@code currentPeriodStart}: a full grant
* when the counter is stale, the counter otherwise. Shared with the decrement in {@code
* JobChargeService} so displayed and enforced balances cannot diverge.
*/
public static long remainingForPeriod(
LocalDateTime stampedPeriodStart,
Long storedRemaining,
long grant,
LocalDateTime currentPeriodStart) {
if (isStale(stampedPeriodStart, currentPeriodStart)) {
return Math.max(0L, grant);
}
return storedRemaining == null ? 0L : Math.max(0L, storedRemaining);
}
public static boolean isStale(
LocalDateTime stampedPeriodStart, LocalDateTime currentPeriodStart) {
return currentPeriodStart != null
&& (stampedPeriodStart == null || stampedPeriodStart.isBefore(currentPeriodStart));
}
/**
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
* used when there's no Stripe subscription period to anchor on.
@@ -17,6 +17,8 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.bundle.PrepaidBundleService;
import stirling.software.saas.payg.docs.DocumentClassifier;
import stirling.software.saas.payg.docs.DocumentMetrics;
@@ -70,6 +72,7 @@ public class JobChargeService {
private final PaygMeterReportingService meterReportingService;
private final WalletLedgerRepository ledgerRepository;
private final PrepaidBundleService prepaidBundleService;
private final TeamBillingService teamBillingService;
public JobChargeService(
JobService jobService,
@@ -80,7 +83,8 @@ public class JobChargeService {
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService,
WalletLedgerRepository ledgerRepository,
PrepaidBundleService prepaidBundleService) {
PrepaidBundleService prepaidBundleService,
TeamBillingService teamBillingService) {
this.jobService = Objects.requireNonNull(jobService, "jobService");
this.policyService = Objects.requireNonNull(policyService, "policyService");
this.classifier = Objects.requireNonNull(classifier, "classifier");
@@ -93,6 +97,7 @@ public class JobChargeService {
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
this.prepaidBundleService =
Objects.requireNonNull(prepaidBundleService, "prepaidBundleService");
this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService");
}
/**
@@ -208,13 +213,13 @@ public class JobChargeService {
}
/**
* Draw this job's free portion from the team's one-time lifetime grant, atomically, and return
* the units taken (0..{@code units}); the remainder is the paid portion that will be metered to
* Stripe. Runs inside {@code openProcess}'s transaction with a pessimistic row lock so
* concurrent same-team charges split the grant exactly — no two jobs can both claim the last
* free unit. The grant is a soft floor: it never goes below 0, and the single job that crosses
* the boundary takes whatever's left (its remaining units bill). Skipped for non-billable /
* team-less calls (BYPASSED never reaches openProcess; guarded defensively).
* Units of {@code units} drawn from the team's grant for the current period; the remainder is
* metered to Stripe. The grant is a soft floor, so the job crossing the boundary takes what is
* left and bills the rest.
*
* <p>Also the only writer of the period reset. Both happen under {@code openProcess}'s row
* lock, against the balance on the locked row rather than the cached context, so concurrent
* same-team charges cannot both claim the last free unit.
*/
private int consumeFreeGrant(ChargeContext ctx, int units) {
BillingCategory category = ctx.billingCategory();
@@ -227,15 +232,51 @@ public class JobChargeService {
return 0;
}
PaygTeamExtensions ext = extOpt.get();
long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining();
TeamBillingContext billing = teamBillingService.forTeam(ctx.ownerTeamId());
LocalDateTime periodStart = billing.periodStart();
boolean periodRolled =
TeamBillingService.isStale(ext.getFreeUnitsPeriodStart(), periodStart);
long remaining =
TeamBillingService.remainingForPeriod(
ext.getFreeUnitsPeriodStart(),
ext.getFreeUnitsRemaining(),
billing.freeGrantUnits(),
periodStart);
int freeUsed = (int) Math.min(units, Math.max(0L, remaining));
if (freeUsed > 0) {
if (periodRolled || freeUsed > 0) {
// A roll-over writes even when nothing is drawn, so the stamp stops reading as stale.
ext.setFreeUnitsRemaining(remaining - freeUsed);
ext.setFreeUnitsPeriodStart(periodStart);
teamExtensionsRepository.save(ext);
}
return freeUsed;
}
/**
* Return {@code units} to the team's free grant, capped at one period's grant. The cap only
* bites when a refund lands after its charge's period ended, where the balance has already
* reset and adding the old units would over-credit the team. Locks rather than incrementing
* blindly because the cap applies against the balance as it stands.
*/
private void restoreFreeGrant(Long teamId, int units) {
Optional<PaygTeamExtensions> extOpt = teamExtensionsRepository.findByIdForUpdate(teamId);
if (extOpt.isEmpty()) {
return;
}
PaygTeamExtensions ext = extOpt.get();
TeamBillingContext billing = teamBillingService.forTeam(teamId);
long grant = billing.freeGrantUnits();
long remaining =
TeamBillingService.remainingForPeriod(
ext.getFreeUnitsPeriodStart(),
ext.getFreeUnitsRemaining(),
grant,
billing.periodStart());
ext.setFreeUnitsRemaining(Math.min(grant, remaining + Math.max(0, units)));
ext.setFreeUnitsPeriodStart(billing.periodStart());
teamExtensionsRepository.save(ext);
}
/**
* Draw this job's prepaid portion from the team's bundles — the tier after the free grant and
* before the meter — returning the units taken (0..{@code units}). Same guard as {@link
@@ -405,13 +446,11 @@ public class JobChargeService {
refund.setPolicyId(row.getPolicyId());
refund.setBillingCategory(category);
ledgerRepository.save(refund);
// Hand back the free units this job consumed (first-step failures are
// pre-meter, so nothing was billed to Stripe — only the grant moved). Exactly
// what was taken at charge time, so the counter can't drift above the grant.
// First-step failures are pre-meter: nothing was billed, only the grant moved.
int freeConsumed =
row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
if (freeConsumed > 0 && row.getTeamId() != null) {
teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed);
restoreFreeGrant(row.getTeamId(), freeConsumed);
}
// Return the prepaid units this job drew to the team's pools (best-effort — see
// PrepaidBundleService.restore).
@@ -553,9 +592,7 @@ public class JobChargeService {
return;
}
// Paid portion = units beyond the team's one-time free grant, fixed at charge time. The
// free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the
// free units were already withheld when this row's free_units_consumed was set.
// Free units are withheld app-side at charge time; Stripe's Prices carry no free tier.
int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
int bundleConsumed =
row.getBundleUnitsConsumed() == null ? 0 : row.getBundleUnitsConsumed();
@@ -134,8 +134,8 @@ public class EntitlementService {
if (billing.subscribed()) {
// Subscribed: gate on the monthly spending cap. Spend = this period's net billable
// documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The one-time
// free grant doesn't gate a paying team — it only reduced what they were metered.
// documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The free
// grant doesn't gate a paying team — it only reduced what they were metered.
long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd);
long periodSpend = signedNet < 0 ? -signedNet : 0L;
Long cap = billing.monthlyCapDocUnits();
@@ -157,14 +157,8 @@ public class EntitlementService {
snapshotSpend = periodSpend;
snapshotCap = cap;
} else {
// Unsubscribed: gate on the one-time lifetime free grant, then on a prepaid pool. While
// the free grant has balance, evaluate the warn/degrade band on used-of-grant. Once the
// free grant is spent, a live prepaid pool keeps the team fully entitled — paid-for
// capacity is usable on its own merit, independent of any metered subscription (the
// pool
// is drawn in JobChargeService; only the metered remainder stays gated on the sub).
// Only
// when BOTH the free grant and prepaid are exhausted do billable categories hard-stop.
// A prepaid pool outranks an exhausted grant: paid-for capacity is usable on its own
// merit, with no subscription. Only with both gone do billable categories hard-stop.
long grant = billing.freeGrantUnits();
long remaining = billing.freeRemainingUnits();
long used = Math.max(0L, grant - remaining);
@@ -72,15 +72,22 @@ public class PaygTeamExtensions implements Serializable {
private String paygSubscriptionId;
/**
* Remaining one-time free documents for this team (the lifetime grant). Seeded from the
* effective pricing policy's {@code free_tier_units} when this row is created (V14 trigger,
* updated in V19); decremented by the charge pipeline when a billable charge is written and
* restored on a first-step refund. Never replenishes; survives subscribing. This counter — not
* the wallet ledger — is the source of truth for the grant, so old ledger rows can be pruned.
* Free documents left in the team's current billing period, reset to the policy's {@code
* free_tier_units} at each boundary (see {@link #freeUnitsPeriodStart}). This counter, not the
* wallet ledger, is the source of truth for the grant.
*/
@Column(name = "free_units_remaining", nullable = false)
private Long freeUnitsRemaining = 0L;
/**
* The billing period {@link #freeUnitsRemaining} was last reset for, always a {@code
* TeamBillingContext.periodStart}. {@code null} or older than the current period start means
* the counter is stale and reads as a full grant. Written only by the app, which owns the
* period rule.
*/
@Column(name = "free_units_period_start")
private LocalDateTime freeUnitsPeriodStart;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@@ -74,11 +74,9 @@ public class PricingPolicy implements Serializable {
private Integer fileUnitCap = 1000;
/**
* One-time lifetime free document grant handed to a team on creation. {@code 0} (default) means
* no free grant. NOT per-cycle: it never replenishes and a team keeps any unused portion after
* subscribing. The value is copied into {@code payg_team_extensions.free_units_remaining} when
* the team's sidecar row is created (V14 trigger, updated in V19); from then on the per-team
* counter is authoritative and this column is only the seed for new teams.
* Free document grant a team gets each billing period; {@code 0} (default) means none. The
* size, not the balance: {@code payg_team_extensions.free_units_remaining} is reset to it at
* each period boundary and does not carry over.
*/
@Column(name = "free_tier_units", nullable = false)
private Long freeTierUnits = 0L;
@@ -4,7 +4,6 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -19,24 +18,10 @@ public interface PaygTeamExtensionsRepository extends JpaRepository<PaygTeamExte
Optional<PaygTeamExtensions> findByStripeCustomerId(String stripeCustomerId);
/**
* Pessimistic-write load of the sidecar row, used by the charge pipeline to deduct the one-time
* free grant atomically. The lock serialises concurrent charges <em>for the same team</em> so
* the per-job {@code free_units_consumed} split (and therefore the metered paid portion) is
* exact — two simultaneous jobs can't both believe they drew from the same remaining unit.
* Different teams never contend; the lock is held only for the {@code openProcess} transaction.
* Serialises concurrent charges for one team so the per-job {@code free_units_consumed} split
* is exact: without the lock two simultaneous jobs both draw the same remaining free unit.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM PaygTeamExtensions e WHERE e.teamId = :teamId")
Optional<PaygTeamExtensions> findByIdForUpdate(@Param("teamId") Long teamId);
/**
* Atomically returns {@code freeUnitsConsumed} to the team's grant on a refund. Increment is
* commutative so no lock is needed; the amount restored is exactly what the job consumed, so it
* can never exceed the original grant.
*/
@Modifying
@Query(
"UPDATE PaygTeamExtensions e SET e.freeUnitsRemaining = e.freeUnitsRemaining + :units"
+ " WHERE e.teamId = :teamId")
int restoreFreeUnits(@Param("teamId") Long teamId, @Param("units") long units);
}
@@ -59,10 +59,10 @@ public class PaygShadowCharge implements Serializable {
private Integer paygUnits;
/**
* How many of {@link #paygUnits} were drawn from the team's one-time free grant at charge time.
* The paid (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores
* this many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19
* rows and for jobs that consumed no free units (team's grant already exhausted).
* How many of {@link #paygUnits} were drawn from the team's free grant at charge time. The paid
* (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores this
* many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19 rows
* and for jobs that drew no free units.
*/
@Column(name = "free_units_consumed", nullable = false)
private Integer freeUnitsConsumed = 0;
@@ -24,7 +24,7 @@ import lombok.extern.slf4j.Slf4j;
* {@code stirling_pdf} (money lives in Stripe).
*
* <p>PAYG prices are plain {@code per_unit} metered prices, so {@code stripe.prices.unit_amount}
* carries the rate directly. The free grant is deliberately NOT in Stripe - it's the one-time
* carries the rate directly. The free grant is deliberately NOT in Stripe - it's the per-period
* {@code pricing_policy.free_tier_units} pool, applied app-side (free units are never metered),
* because un-subscribed teams get the same grant and have no Stripe Price at all.
*
@@ -64,6 +64,7 @@ class TeamBillingServiceMoreTest {
e.setTeamId(TEAM_ID);
e.setPaygSubscriptionId(subscriptionId);
e.setFreeUnitsRemaining(freeRemaining);
e.setFreeUnitsPeriodStart(TeamBillingService.calendarMonthWindow()[0]);
return e;
}
@@ -360,4 +361,132 @@ class TeamBillingServiceMoreTest {
assertThat(window[1]).isEqualTo(YearMonth.now().plusMonths(1).atDay(1).atStartOfDay());
}
}
@Nested
@DisplayName("compute: recurring free grant")
class RecurringFreeGrant {
private static final long GRANT = 500L;
private PaygTeamExtensions stamped(LocalDateTime stamp, long remaining) {
PaygTeamExtensions e = new PaygTeamExtensions();
e.setTeamId(TEAM_ID);
e.setFreeUnitsRemaining(remaining);
e.setFreeUnitsPeriodStart(stamp);
return e;
}
@Test
@DisplayName("a counter stamped with a past period reads as a fresh grant")
void staleStampReadsAsFreshGrant() {
stubGrant(GRANT);
// Nothing has persisted the reset yet, so the read has to show it anyway.
when(extensionsRepository.findById(TEAM_ID))
.thenReturn(
Optional.of(
stamped(
LocalDateTime.now().minusMonths(2).withDayOfMonth(1),
0L)));
TeamBillingContext ctx = service.forTeam(TEAM_ID);
assertThat(ctx.freeGrantUnits()).isEqualTo(GRANT);
assertThat(ctx.freeRemainingUnits()).isEqualTo(GRANT);
}
@Test
@DisplayName("a counter stamped with the current period reads as the stored balance")
void currentStampReadsStoredBalance() {
stubGrant(GRANT);
when(extensionsRepository.findById(TEAM_ID))
.thenReturn(
Optional.of(
stamped(TeamBillingService.calendarMonthWindow()[0], 120L)));
assertThat(service.forTeam(TEAM_ID).freeRemainingUnits()).isEqualTo(120L);
}
@Test
@DisplayName(
"an unstamped row — written before the grant recurred — reads as a fresh grant")
void nullStampReadsAsFreshGrant() {
stubGrant(GRANT);
when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(stamped(null, 0L)));
assertThat(service.forTeam(TEAM_ID).freeRemainingUnits()).isEqualTo(GRANT);
}
@Test
@DisplayName("the grant resets on the Stripe window, not the calendar month")
void subscribedGrantFollowsTheStripeWindow() {
stubGrant(GRANT);
// Stamped for the calendar month, but this team's period is Stripe-anchored and starts
// mid-month, so the stamp belongs to the previous period and the grant resets.
LocalDateTime stripeStart = LocalDateTime.of(2026, 6, 10, 0, 0);
when(extensionsRepository.findById(TEAM_ID))
.thenReturn(Optional.of(subscribedRow(LocalDateTime.of(2026, 6, 1, 0, 0))));
when(subscriptionDao.findBilling("sub_1"))
.thenReturn(
Optional.of(
new SubscriptionBilling(
stripeStart,
stripeStart.plusMonths(1),
"price_1",
"active",
"usd",
new BigDecimal("2"))));
TeamBillingContext ctx = service.forTeam(TEAM_ID);
assertThat(ctx.periodStart()).isEqualTo(stripeStart);
assertThat(ctx.freeRemainingUnits()).isEqualTo(GRANT);
}
private PaygTeamExtensions subscribedRow(LocalDateTime stamp) {
PaygTeamExtensions e = stamped(stamp, 0L);
e.setPaygSubscriptionId("sub_1");
return e;
}
}
@Nested
@DisplayName("remainingForPeriod")
class RemainingForPeriodRule {
private final LocalDateTime period = LocalDateTime.of(2026, 8, 1, 0, 0);
@Test
@DisplayName("stale stamp yields the full grant; current stamp yields the stored balance")
void staleVersusCurrent() {
assertThat(
TeamBillingService.remainingForPeriod(
period.minusMonths(1), 0L, 500L, period))
.isEqualTo(500L);
assertThat(TeamBillingService.remainingForPeriod(period, 0L, 500L, period)).isZero();
assertThat(TeamBillingService.remainingForPeriod(period, 42L, 500L, period))
.isEqualTo(42L);
}
@Test
@DisplayName("a stamp in the future is never read as another grant")
void futureStampKeepsTheStoredBalance() {
assertThat(TeamBillingService.remainingForPeriod(period.plusDays(1), 7L, 500L, period))
.isEqualTo(7L);
}
@Test
@DisplayName("null stored balance and negative values floor at zero")
void nullAndNegativeBalances() {
assertThat(TeamBillingService.remainingForPeriod(period, null, 500L, period)).isZero();
assertThat(TeamBillingService.remainingForPeriod(period, -5L, 500L, period)).isZero();
assertThat(TeamBillingService.remainingForPeriod(null, 0L, -1L, period)).isZero();
}
@Test
@DisplayName("an unknown current period leaves the counter alone")
void nullCurrentPeriod() {
assertThat(TeamBillingService.isStale(null, null)).isFalse();
assertThat(TeamBillingService.remainingForPeriod(null, 3L, 500L, null)).isEqualTo(3L);
}
}
}
@@ -58,12 +58,14 @@ class TeamBillingServiceTest {
when(pricingPolicyService.getEffectivePolicy(TEAM_ID)).thenReturn(policy);
}
/** Stamped with the current period, so {@code freeRemaining} reads as the live balance. */
private PaygTeamExtensions ext(String subscriptionId, String customerId, long freeRemaining) {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(TEAM_ID);
ext.setPaygSubscriptionId(subscriptionId);
ext.setStripeCustomerId(customerId);
ext.setFreeUnitsRemaining(freeRemaining);
ext.setFreeUnitsPeriodStart(TeamBillingService.calendarMonthWindow()[0]);
return ext;
}
@@ -31,6 +31,8 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.bundle.PrepaidBundleService;
import stirling.software.saas.payg.docs.DocumentClassifier;
import stirling.software.saas.payg.docs.DocumentMetrics;
@@ -72,8 +74,13 @@ class JobChargeServiceTest {
private PaygMeterReportingService meterReporter;
private WalletLedgerRepository ledgerRepo;
private PrepaidBundleService prepaidBundleService;
private TeamBillingService teamBillingService;
private JobChargeService service;
private static final LocalDateTime PERIOD_START = LocalDateTime.of(2026, 8, 1, 0, 0);
private static final long GRANT = 500L;
@BeforeEach
void setUp() {
jobService = Mockito.mock(JobService.class);
@@ -90,6 +97,8 @@ class JobChargeServiceTest {
// findByIdForUpdate defaults to Optional.empty() (Mockito) → no free grant consumed unless
// a test stubs the sidecar row. The free split is decided at openProcess time now, not at
// close, so the meter tests just set free_units_consumed on the shadow row directly.
teamBillingService = Mockito.mock(TeamBillingService.class);
when(teamBillingService.forTeam(Mockito.anyLong())).thenReturn(billingContext(GRANT));
service =
new JobChargeService(
jobService,
@@ -100,7 +109,22 @@ class JobChargeServiceTest {
teamExtRepo,
meterReporter,
ledgerRepo,
prepaidBundleService);
prepaidBundleService,
teamBillingService);
}
private static TeamBillingContext billingContext(long grant) {
return new TeamBillingContext(
false,
null,
PERIOD_START,
PERIOD_START.plusMonths(1),
grant,
grant,
null,
null,
null,
null);
}
@AfterEach
@@ -365,6 +389,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(10L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
@@ -381,6 +406,96 @@ class JobChargeServiceTest {
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_firstChargeOfNewPeriod_resetsGrantAndRestamps(@TempDir Path tmp)
throws IOException {
// Grant exhausted last period, nothing run since. This charge persists the reset: counter
// back to the full grant, drawn from, and re-stamped so the next charge reads the balance.
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 4));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START.minusMonths(1));
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(4);
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT - 4);
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_unstampedRow_resetsToGrantAndStamps(@TempDir Path tmp) throws IOException {
// An unstamped row must read as owed a reset, not as an exhausted pool.
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 1));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(1);
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT - 1);
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
}
@Test
void openProcess_zeroGrantRollover_stampsWithoutDrawing(@TempDir Path tmp) throws IOException {
// A zero grant still advances the stamp, or every later charge re-evaluates a stale row.
when(teamBillingService.forTeam(100L)).thenReturn(billingContext(0L));
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 3));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getFreeUnitsConsumed()).isZero();
assertThat(ext.getFreeUnitsRemaining()).isZero();
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_grantStraddle_drawsRemainderFreeAndBillsTheRest(@TempDir Path tmp)
throws IOException {
@@ -396,6 +511,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(3L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
@@ -426,6 +542,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
@@ -544,8 +661,8 @@ class JobChargeServiceTest {
assertThat(refund.getReferenceId()).isEqualTo(jobId.toString());
assertThat(refund.getPolicyId()).isEqualTo(7L);
assertThat(refund.getBillingCategory()).isEqualTo(BillingCategory.API);
// This row consumed no free units, so the grant counter is left alone.
verify(teamExtRepo, never()).restoreFreeUnits(eq(100L), Mockito.anyLong());
// No free units consumed, so the grant counter is never loaded.
verify(teamExtRepo, never()).findByIdForUpdate(100L);
}
@Test
@@ -556,10 +673,35 @@ class JobChargeServiceTest {
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(GRANT - 3);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.markFirstStepFailed(jobId, "first-step-5xx:503");
verify(teamExtRepo).restoreFreeUnits(100L, 3L);
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT);
verify(teamExtRepo).save(ext);
}
@Test
void markFirstStepFailed_refundAfterPeriodTurned_doesNotExceedTheGrant() {
// The charge's period is over and the grant already reset, so the restore is capped.
UUID jobId = UUID.randomUUID();
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START.minusMonths(1));
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.markFirstStepFailed(jobId, "first-step-5xx:503");
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT);
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
}
@Test
@@ -886,6 +1028,7 @@ class JobChargeServiceTest {
ext.setStripeCustomerId("cus_x");
ext.setPaygSubscriptionId("sub_x");
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
@@ -930,6 +1073,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(teamId);
ext.setFreeUnitsRemaining(50L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
@@ -974,6 +1118,7 @@ class JobChargeServiceTest {
ext.setStripeCustomerId("cus_x");
ext.setPaygSubscriptionId("sub_x");
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
@@ -1032,8 +1177,6 @@ class JobChargeServiceTest {
return row;
}
// --- helpers --------------------------------------------------------------------------------
private static PricingPolicy stubPolicy(int minCharge, Map<JobSource, Integer> stepLimits) {
PricingPolicy p = new PricingPolicy();
p.setId(42L);
@@ -26,8 +26,8 @@ import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.wallet.WalletPolicy;
/**
* Unit tests for {@link EntitlementService}. Two branches (design 2026-06-11 — the free allowance
* is a one-time lifetime grant):
* Unit tests for {@link EntitlementService}. Two branches (the free allowance is a per-period
* grant, projected onto the current period by {@code TeamBillingService} before it gets here):
*
* <ul>
* <li><b>Unsubscribed</b> — gated by the grant. Cap = grant size, spend = {@code grant
@@ -96,7 +96,6 @@ class EntitlementServiceTest {
assertThat(snap.periodCapUnits()).isEqualTo(2000L);
assertThat(snap.periodSpendUnits()).isEqualTo(500L);
// 500/2000 = 25% — FULL
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
}
@@ -6097,7 +6097,7 @@ freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs remaining"
capSuffix = "of {{limit}} free PDFs left"
capSuffix = "of {{limit}} free PDFs left this month"
metaCategories = "Automation · AI · API requests"
[payg.free.member]
@@ -6163,7 +6163,7 @@ leader = "Team owner"
member = "Member"
[payg.signupRequired]
body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
body = "Stirling PDF gives every signed-up account 500 free operations a month, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
cancel = "Not now"
cta = "Sign up free"
subtext = "Creating an account is free and takes a few seconds. No credit card required."
@@ -6849,7 +6849,7 @@ name = "Free"
[plan.freeLimit]
cta = "View Processor Plan"
dismiss = "Maybe Later"
message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day."
message = "That's your whole free allowance for automation, AI and the API this month. Seriously impressive! It resets next month, or keep the momentum going now for just pennies a day."
title = "Woah, {{total}} PDFs Processed!"
[plan.highlights]
@@ -7431,17 +7431,17 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs remaining"
capSuffix_one = "of {{allowance}} free PDF left"
capSuffix_other = "of {{allowance}} free PDFs left"
barAria = "Free credits remaining"
capSuffix_one = "of {{allowance}} free credit left this month"
capSuffix_other = "of {{allowance}} free credits left this month"
eyebrow = "Processor trial"
statusLabel_one = "{{used}} used"
statusLabel_other = "{{used}} used"
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
title_one = "{{allowance}} free credit to start"
title_other = "{{allowance}} free credits to start"
titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF"
titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF"
title_one = "{{allowance}} free credit every month"
title_other = "{{allowance}} free credits every month"
titleWithRate_one = "{{allowance}} free credit every month, then {{rate}} per PDF"
titleWithRate_other = "{{allowance}} free credits every month, then {{rate}} per PDF"
[portal.components.billingUnit]
approval = "approval"
@@ -5,7 +5,7 @@ import {
} from "@app/components/onboarding/onboardingSlideTypes";
export interface SaasFlowInputs {
/** Free-tier wallet with one-time allowance remaining — show the usage meter. */
/** Free-tier wallet with allowance remaining this period — show the usage meter. */
showUsageSlide: boolean;
/** Team leaders only — invited members and anonymous guests skip the team slide. */
showTeamSlide: boolean;
@@ -138,7 +138,7 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t(
"plan.freeLimit.message",
"That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day.",
"That's your whole free allowance for automation, AI and the API this month. Seriously impressive! It resets next month, or keep the momentum going now for just pennies a day.",
)}
</div>
</div>
@@ -9,14 +9,13 @@
* watermarks, compression — are unmetered, no matter where they're triggered
* from. The distinction is the <em>type of work</em> (manual tool vs
* automation / AI / API), not where the click happens, because automation and
* AI also have UI surfaces. The one-time free grant (default 500) applies
* <em>only</em> to the three billable categories — it is a lifetime allowance,
* not a monthly one, and a team keeps any unused portion after subscribing.
* AI also have UI surfaces. The free grant applies <em>only</em> to the three
* billable categories, and resets each billing period.
*
* <p>Layout: a slim <b>Editor plan</b> card (always-free tools only — no dates,
* no metered split) on top, then a single <b>Processor plan</b> card that
* two-columns the upgrade pitch + benefits (left) against the one-time free
* meter stacked over the call-to-action (right).
* two-columns the upgrade pitch + benefits (left) against the free-grant meter
* stacked over the call-to-action (right).
*
* <p>Two variants:
* - {@link PaygFreeLeader} — the right column's CTA opens the upgrade modal.
@@ -47,8 +46,6 @@ import {
type FreeSnapshot,
} from "@app/components/shared/config/configSections/usageMeters";
// ─── Editor plan card (always-free tools only) ────────────────────────────
interface EditorPlanCardProps {
/** Role pill text on the right. */
pill: string;
@@ -58,8 +55,8 @@ interface EditorPlanCardProps {
/**
* The top card: the free Editor plan. Manual tools only, no billing window —
* the one-time grant lives in the Processor card below, so there's no period
* to show here.
* the metered grant lives in the Processor card below, so there's no period to
* show here.
*/
function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
const { t } = useTranslation();
@@ -93,8 +90,6 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
);
}
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
interface ProcessorCardProps {
snap: FreeSnapshot;
/** Leaders get the live CTA; members get the ask-owner note. */
@@ -207,8 +202,6 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
);
}
// ─── Free LEADER ──────────────────────────────────────────────────────────
export interface PaygFreeLeaderProps {
/**
* Called when the user finishes the {@link UpgradeModal} checkout flow.
@@ -265,8 +258,6 @@ function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) {
);
}
// ─── Free MEMBER ──────────────────────────────────────────────────────────
function PaygFreeMemberInner() {
useRenderCount("PaygFreeMember");
const { t } = useTranslation();
@@ -69,10 +69,9 @@ interface UpgradeModalProps {
/** ISO 4217 currency code for the cap input. Default USD. */
currency?: "USD" | "EUR" | "GBP";
/**
* The team's one-time free grant in documents — the real {@code
* The team's free grant in documents per billing period — the real {@code
* wallet.freeAllowance}, threaded from the free-leader view so the step copy
* quotes the backend's number instead of a hardcoded one. A lifetime grant,
* not a monthly one.
* quotes the backend's number instead of a hardcoded one.
*/
freeLimit: number;
/**
@@ -17,12 +17,10 @@ import {
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
// ─── One-time free grant meter ──────────────────────────────────────────────
export interface FreeSnapshot {
/** One-time free documents used so far (grant remaining). */
/** Free documents used so far this period (grant remaining). */
billableUsed: number;
/** The team's one-time free grant size in documents. */
/** The team's free grant size in documents, per billing period. */
billableLimit: number;
}
@@ -64,9 +62,11 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
figure={remaining.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
limit: snap.billableLimit.toLocaleString(),
})}
capSuffix={t(
"payg.free.hero.capSuffix",
"of {{limit}} free PDFs left this month",
{ limit: snap.billableLimit.toLocaleString() },
)}
statusLabel={stateLabel}
meta={
<span>
@@ -77,8 +77,6 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
);
}
// ─── Monthly spend-cap meter ────────────────────────────────────────────────
export interface SpendCapSnapshot {
/** Money spent so far this billing period, in major currency units. */
spent: number;
@@ -106,8 +104,8 @@ export function spendCapSnapshotFromWallet(
}
/**
* Sibling of {@link FreeMeterPanel} for the money cap rather than the one-time
* free grant. Shares the same bar/status styling and the cap-state labels
* Sibling of {@link FreeMeterPanel} for the money cap rather than the free
* grant. Shares the same bar/status styling and the cap-state labels
* ({@code payg.state.*}) used by the Plan hero, so it reads as the same meter.
*/
export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
@@ -149,8 +147,6 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
);
}
// ─── Prepaid bundle capacity meter ──────────────────────────────────────────
export interface PrepaidSnapshot {
/** Prepaid units still available across the team's in-term pools. */
remaining: number;
@@ -13,10 +13,8 @@ function toCredits(
freeRemaining: number,
freeAllowance: number,
): CachedCredits {
// Free teams only. The grant is a lifetime pool that survives subscribing, so
// a paying team would otherwise sit on a permanent "0 of 500" in red while
// nothing is wrong. Plan draws the same line — subscribed teams get the
// spend-vs-cap meter there, and admins get usage in the processor.
// Free teams only: a payer's headline number is spend against cap, and a
// draining free meter beside a live invoice reads as a problem.
if (status === "subscribed") return null;
return { remaining: freeRemaining, total: freeAllowance };
}
@@ -4,7 +4,7 @@
*
* <ul>
* <li>{@code 402 FEATURE_DEGRADED} — an authenticated (JWT/web) team hit a
* billable feature it no longer has: a free team that spent its one-time
* billable feature it no longer has: a free team that spent this period's
* allowance, or a subscribed team over its monthly spending cap. Which
* one is told by the {@code subscribed} field on the body.</li>
* <li>{@code 402 PAYG_LIMIT_REACHED} — same situation reached via an API key
@@ -14,7 +14,7 @@ type Story = StoryObj<typeof SpendThisMonthCard>;
/** Actual spend + the Enterprise upsell tacked onto the foot. */
export const Default: Story = { args: { wallet: subscribedWallet } };
/** Subscribed but still holding leftover lifetime free grant — shows the free-remaining note. */
/** Subscribed with this period's free grant part-spent — shows the free-remaining note. */
export const WithFreeRemaining: Story = {
args: { wallet: { ...subscribedWallet, freeRemaining: 380 } },
};
@@ -15,7 +15,7 @@ interface Props {
}
/**
* The free Processor-trial meter — "X of N free PDFs left" against the one-time
* The free Processor-trial meter — "X of N free PDFs left" against this period's
* grant, with what has been used alongside as the status badge. The bar shows what
* is left, so it drains towards empty as the grant is spent.
* Uses the shared {@link MeterBar} (same `paygf-meter` structure as the
@@ -41,7 +41,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
rate != null
? t(
"portal.billing.walletMeter.titleWithRate",
"Process {{allowance}} PDFs free, then {{rate}}/PDF",
"{{allowance}} free credits every month, then {{rate}} per PDF",
{
count: wallet.freeAllowance,
allowance: wallet.freeAllowance.toLocaleString(),
@@ -50,7 +50,7 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
)
: t(
"portal.billing.walletMeter.title",
"Process {{allowance}} PDFs free",
"{{allowance}} free credits every month",
{
count: wallet.freeAllowance,
allowance: wallet.freeAllowance.toLocaleString(),
@@ -80,12 +80,12 @@ export function WalletMeter({ wallet, unsynced, action }: Props) {
pct={pct}
barLabel={t(
"portal.billing.walletMeter.barAria",
"Free PDFs remaining",
"Free credits remaining",
)}
figure={remaining.toLocaleString()}
capSuffix={t(
"portal.billing.walletMeter.capSuffix",
"of {{allowance}} free PDFs left",
"of {{allowance}} free credits left this month",
{
count: wallet.freeAllowance,
allowance: wallet.freeAllowance.toLocaleString(),
@@ -1,6 +1,6 @@
import type { Wallet } from "@portal/api/billing";
/** Linked, on the one-time free grant — leader view. Override per story. */
/** Linked, on the free monthly grant — leader view. Override per story. */
export const freeWallet: Wallet = {
teamId: 42,
status: "free",
@@ -15,7 +15,7 @@ import {
*
* - `unlinked` — no SaaS account linked. Billable features render a
* "link to unlock" affordance.
* - `linked-free` — linked, running on the one-time free grant (500 PDFs).
* - `linked-free` — linked, running on the per-period free grant.
* - `linked-subscribed` — linked with a live PAYG subscription.
*
* The portal admin establishes the link by signing in to the SaaS Supabase
@@ -55,7 +55,7 @@ describe("useFreeCreditsSummary (self-hosted) — wallet behind the link gate",
});
it("hides the meter once the team subscribes", async () => {
// The grant is a lifetime pool that survives subscribing, so a paying team
// A paying team's headline number is spend against its cap, so a paying team
// would otherwise sit on a spent meter forever.
fetchWallet.mockResolvedValue({
status: "subscribed",
@@ -26,9 +26,8 @@ import { type NavFooterCredits } from "@app/components/shared/navFooter/NavFoote
* since the wallet lives in the cloud even when the instance doesn't. Gated on
* linkage: an unlinked instance has no wallet to read.
*
* Free teams only, matching the editor and the Plan page. The grant is a
* lifetime pool that survives subscribing, so a paying team would otherwise sit
* on a permanent "0 of 500" in red; their usage lives on Usage & Billing.
* Free teams only, matching the editor and the Plan page: a payer's headline
* number is spend against cap, and their usage lives on Usage & Billing.
*/
export function useFreeCreditsSummary(): NavFooterCredits | null {
const { isLinked } = useLink();
@@ -50,9 +50,9 @@ export function formatMoneyMajor(
/**
* Paid PDFs a monthly cap buys — mirror of the backend's {@code docCapForMoney}:
* floor(capMinor / rate). The one-time free grant is a separate lifetime pool and
* is NOT added here. Returns null when there's no cap or no resolvable rate (the
* caller hides the estimate).
* floor(capMinor / rate). The free grant is a separate per-period pool and is NOT
* added here. Returns null when there's no cap or no resolvable rate (the caller
* hides the estimate).
*/
export function docCapForMoney(
capUsdMajor: number | null,
@@ -88,8 +88,6 @@ export function formatPeriodDate(
}
}
// ─── Prepaid bundle pricing (run-based brain) ───────────────────────────────
/**
* "12 months for the price of 10" — months granted vs paid. Mirrors the Stripe
* coupon (10/12 off), so the calculator's live estimate matches the amount charged.
@@ -40,13 +40,13 @@ export interface Wallet {
/** ISO yyyy-mm-dd. Stripe period when subscribed; calendar month when free. */
billingPeriodStart: string;
billingPeriodEnd: string;
/** Free grant used (free teams) or documents processed this period (subscribed). */
/** Free grant used this period (free teams) or documents processed this period (subscribed). */
billableUsed: number;
/** Document ceiling for the window; null when subscribed-uncapped. */
billableLimit: number | null;
/** One-time free grant size — a lifetime pool that survives subscribing. */
/** Free grant size per billing period; unused units don't carry over. */
freeAllowance: number;
/** Free grant still available; 0 = exhausted. */
/** Free grant left in this period; 0 = exhausted. */
freeRemaining: number;
/** Paid per-document rate in minor units (may be fractional); null = unknown (render "unknown", never substitute). */
pricePerDocMinor: number | null;
@@ -93,7 +93,7 @@ export default function SignupRequiredBootstrap() {
<Text>
{t(
"payg.signupRequired.body",
"Stirling PDF gives every signed-up account 500 free operations enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
"Stirling PDF gives every signed-up account 500 free operations a month, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
)}
</Text>
<Text size="sm" c="dimmed">
+8 -6
View File
@@ -13,14 +13,16 @@
-- no row has is_default = TRUE. Explicit timestamps because Hibernate's
-- @CreationTimestamp is application-side; direct INSERTs bypass it.
-- ---------------------------------------------------------------------------
-- NOTE: free_tier_units_per_cycle is supplied explicitly because the cucumber
-- harness disables Flyway (see docker-compose-saas.yml). Hibernate's DDL
-- emits NOT NULL without a SQL DEFAULT (the JPA field default `= 0L` is
-- JVM-side only), so the column would otherwise reject this INSERT.
-- 500 matches the launch free-tier (PAYG_DESIGN §3.10 revised).
-- NOTE: free_tier_units is supplied explicitly because the cucumber harness
-- disables Flyway (see docker-compose-saas.yml). Hibernate's DDL emits NOT
-- NULL without a SQL DEFAULT (the JPA field default `= 0L` is JVM-side only),
-- so the column would otherwise reject this INSERT. 500 matches the launch
-- free tier, now granted per billing period rather than once per team.
-- (Named free_tier_units_per_cycle here until the rename in V19; the harness
-- builds its schema from Hibernate, so the old name made the INSERT fail.)
INSERT INTO stirling_pdf.pricing_policy (
version, effective_from, doc_pages_per_unit, doc_bytes_per_unit,
min_charge_units, file_unit_cap, free_tier_units_per_cycle, is_default,
min_charge_units, file_unit_cap, free_tier_units, is_default,
notes, created_by, created_at
)
SELECT