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