From 3056e5ff44e6ef6e4f89cfb608629ff9e8bf6f53 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:57:56 +0000 Subject: [PATCH] Reset the PAYG free grant each billing period (#7709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../saas/payg/api/PaygWalletController.java | 23 +-- .../saas/payg/api/WalletSnapshotResponse.java | 20 +-- .../saas/payg/billing/TeamBillingContext.java | 28 ++-- .../saas/payg/billing/TeamBillingService.java | 61 ++++--- .../saas/payg/charge/JobChargeService.java | 71 ++++++-- .../payg/entitlement/EntitlementService.java | 14 +- .../saas/payg/policy/PaygTeamExtensions.java | 17 +- .../saas/payg/policy/PricingPolicy.java | 8 +- .../PaygTeamExtensionsRepository.java | 19 +-- .../saas/payg/shadow/PaygShadowCharge.java | 8 +- .../payg/stripe/StripeSubscriptionDao.java | 2 +- .../billing/TeamBillingServiceMoreTest.java | 129 +++++++++++++++ .../payg/billing/TeamBillingServiceTest.java | 2 + .../payg/charge/JobChargeServiceTest.java | 155 +++++++++++++++++- .../entitlement/EntitlementServiceTest.java | 5 +- .../public/locales/en-US/translation.toml | 20 +-- .../components/onboarding/saasFlowResolver.ts | 2 +- .../shared/FreeLimitReachedModal.tsx | 2 +- .../shared/config/configSections/PaygFree.tsx | 21 +-- .../config/configSections/UpgradeModal.tsx | 5 +- .../config/configSections/usageMeters.tsx | 22 +-- .../src/cloud/hooks/useFreeCreditsSummary.ts | 6 +- .../cloud/services/paygErrorInterceptor.ts | 2 +- .../billing/SpendThisMonthCard.stories.tsx | 2 +- .../portal/components/billing/WalletMeter.tsx | 10 +- .../components/billing/walletFixtures.ts | 2 +- .../src/portal/contexts/LinkContext.tsx | 2 +- .../hooks/useFreeCreditsSummary.test.tsx | 2 +- .../src/portal/hooks/useFreeCreditsSummary.ts | 5 +- .../editor/src/proprietary/billing/format.ts | 8 +- .../editor/src/proprietary/billing/types.ts | 6 +- .../components/SignupRequiredBootstrap.tsx | 2 +- testing/compose/payg/saas-seed.sql | 14 +- 33 files changed, 484 insertions(+), 211 deletions(-) diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index ffc4a056fc..00d4b20b63 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -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 primaryMembership(Long userId) { List rows = memberRepo.findPrimaryMembership(userId); return rows.isEmpty() ? Optional.empty() : Optional.of(rows.getFirst()); diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java index 8bf887a0ea..baf3081edc 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java @@ -9,7 +9,7 @@ import java.util.List; * breakdowns, recent activity) used by the PAYG Plan page. * *

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} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java index 55988fbe82..e880e6450c 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java @@ -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: - * - *

+ * 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 diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java index 8bca25758a..97643786e4 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java @@ -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. * - *

Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant): - * - *

+ *

The free grant and the spending cap are separate pools measured over one window: the Stripe + * subscription period when subscribed, the calendar month otherwise. * *

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 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. * *