PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)

## Summary

Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing
stays free forever; only **automation, AI, and API** usage is metered.
Every team gets a **one-time lifetime free grant** (default 500 PDFs)
before any billing; past that, a team adds a card and pays per metered
document, with a self-set monthly spending cap.

This branch combines and supersedes the in-flight BE (#6574) and FE
(#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on
`v3`), hardened into a single reviewable feature after a pre-merge
dead-code/security review.

## Billing model

- **Always free:** manual / JWT web-tool usage is `BYPASSED` — never
metered, no matter where it's triggered.
- **Billable categories:** `AUTOMATION`, `AI`, `API`.
- **One-time lifetime free grant** (`pricing_policy.free_tier_units`,
default 500): never resets, survives subscribing. It gates unsubscribed
teams (billable API calls hard-stop with a 402 once exhausted) and
decides the free-vs-paid split of every job.
- **Subscribed:** paid documents (beyond the grant) are metered to a
Stripe Billing Meter; an optional monthly spending cap degrades billable
categories when reached.
- **Dedup:** the same file pushed through several steps within a
workflow window counts **once** (lineage join), so API/AI chaining on
one file isn't double-charged.

## What's included

**Database** — Flyway migrations `V11`→`V21` with matching Supabase
twins: pricing policy + per-team sidecar (`payg_team_extensions`:
subscription id, Stripe customer, free-grant counter), append-only
`wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit
logs (`V15`), billing category (`V16`), one-time lifetime free grant
(`V19`), launch-grant seed (`V20`), drop of the unused
`wallet_category_summary` view (`V21`).

**Charge pipeline** — `PaygChargeInterceptor` (open/join a process,
split the free grant, write the ledger DEBIT), `JobChargeService`
(consume the grant under a row lock, restore it on a first-step refund,
meter only the paid portion on completion), `StaleJobCloser` fallback
(idempotent close → meter).

**Entitlement** — `EntitlementService` (per-team cached snapshot:
grant-gated for free teams, monthly-cap-gated for subscribed) +
`EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` /
`PAYG_LIMIT_REACHED`).

**Metering** — `PaygMeterReportingService` writes a durable
`payg_meter_event_log` row around every POST to the `meter-payg-units`
edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries
unposted events under the same idempotency key inside Stripe's 24h dedup
window.

**Billing facts** — `TeamBillingService` reads the synced `stripe.*`
mirror (subscription window, per-document rate; the unsubscribed-team
estimate resolves the rate by Price `lookup_key = plan:processor`).

**Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`,
`PATCH /api/v1/payg/cap`.

**Frontend** — PAYG Plan page (two-card free layout + subscribed views),
`useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and
a shared `SpendCapControl`, customer-portal link, 402/401 interceptor
toast, en-GB i18n. (Per-member usage shows each teammate's spend; the
activity feed is behind a flag until polished.)

**SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) —
`create-checkout-session`, `create-payg-team-subscription`,
`create-customer-portal-session`, `meter-payg-units`,
`payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync
`migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no
`STRIPE_PAYG_PRICE_ID_*` env vars).

## Release prerequisites (prod)

1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration
twins.
2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped**
backfill — `product`, `price`, `customer`, `subscription` only (not
`all`, which rate-limits).
3. Register 2 PAYG webhook endpoints (each its own signing secret):
`stripe-sync` (product/price/customer/subscription `.*`) and
`payg-subscription-webhook` (`customer.subscription.created`/`.deleted`
drive state; `.updated` + `invoice.*` observed). Keep the legacy
`stripe-webhook` only if credits/self-hosted flows still run.
4. Stripe Billing Meter: `event_name = payg_doc_units`, value key
`processed_documents`.
5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET`
(backend); the webhook signing secrets (edge fns). The default pricing
policy must point at the PAYG Stripe Price(s); `V20` seeds
`free_tier_units = 500`.

## Testing

- `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests
green, FE saas typecheck clean (the remaining errors are pre-existing
`proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode
suite + CI workflow included.

## Pre-merge review

An independent dead-code/security pass came back **clean on security**
(team-derived authz / no IDOR, leader-only cap mutation, no
billing-category downgrade, dev/mock hooks gated to
`import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open
metering by design). The dead/unwired code it flagged has been removed
in this branch (unenforced sub-cap control, an unused JDBC DAO + its
view, dead methods).

## Follow-ups (tracked, not blocking)

- **Enforce per-member sub-caps** — the control was removed because it
read for display but never gated a request; the per-member usage display
and `cap_units` column are retained for when enforcement is wired.
- **API/AI chaining billing model + `ProcessType` enum** — confirm
same-file dedup covers API chaining; define per-tool AI charging; decide
whether the unused enum values stay.
- **Activity feed** — hidden behind a flag until the meter-event surface
is polished.

---------

Co-authored-by: Reece <[email protected]>
This commit is contained in:
ConnorYoh
2026-06-11 15:56:01 +01:00
committed by GitHub
co-authored by Reece
parent 11ab762f57
commit cf513c255b
89 changed files with 11389 additions and 1034 deletions
@@ -43,6 +43,8 @@ import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
import stirling.software.saas.ai.service.AiCreateProxyService;
import stirling.software.saas.ai.service.AiCreateSessionService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
@@ -54,6 +56,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
@Tag(name = "AI")
@Hidden
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateController {
@@ -26,6 +26,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.model.AiCreateSessionStatus;
import stirling.software.saas.ai.service.AiCreateSessionService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
@RestController
@Profile("saas")
@@ -33,6 +35,7 @@ import stirling.software.saas.ai.service.AiCreateSessionService;
@Tag(name = "AI")
@Hidden
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateInternalController {
@@ -28,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.service.AiProxyService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
@@ -36,6 +38,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai")
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Tag(name = "AI")
@Hidden
@Slf4j
@@ -9,8 +9,10 @@ import lombok.RequiredArgsConstructor;
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits
// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back.
@Configuration
@Profile("saas")
@Profile("saas & legacy-credits")
@RequiredArgsConstructor
public class CreditInterceptorConfig implements WebMvcConfigurer {
@@ -25,8 +25,9 @@ import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.CreditService.CreditSummary;
import stirling.software.saas.util.LogRedactionUtils;
// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile.
@RestController
@Profile("saas")
@Profile("saas & legacy-credits")
@RequestMapping("/api/v1/credits")
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
@RequiredArgsConstructor
@@ -39,8 +39,10 @@ import stirling.software.saas.util.CreditHeaderUtils;
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
* exception flow.
*/
// Legacy credit-billing error advice. PAYG handles its own error semantics via
// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
@Profile("saas")
@Profile("saas & legacy-credits")
@Slf4j
@Order(1)
public class CreditErrorAdvice {
@@ -27,8 +27,10 @@ import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
import stirling.software.saas.util.CreditHeaderUtils;
// Legacy credit-billing success advice. PAYG writes its own ledger entries via
// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed.
@RestControllerAdvice
@Profile("saas")
@Profile("saas & legacy-credits")
@Slf4j
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
@@ -32,8 +32,10 @@ import stirling.software.saas.service.SaasUserExtensionService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled
// by default in saas, activate legacy-credits profile to bring it back.
@Component
@Profile("saas")
@Profile("saas & legacy-credits")
@Slf4j
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
@@ -0,0 +1,56 @@
package stirling.software.saas.payg.api;
/**
* Cap conversion between the dollar amount the leader edits in the UI and the unit count stored on
* {@code wallet_policy.cap_units}.
*
* <p>The cap is application-layer only — Stripe stays on a flat-priced single meter, so the
* conversion rate here doesn't need to match any Stripe price. It only needs to be stable: a leader
* who set "$25" should read back "$25" on the next page load.
*
* <p>V1 rate: {@value #UNITS_PER_USD} units = $1. This anchors the in-app cap representation to the
* same unit count the ledger writes, so the "X of Y units used" widget in the FE lines up against
* the cap. A future iteration can read this from {@code pricing_policy} once the per-policy money
* conversion lands.
*
* <p>Both directions floor: $24.50 → 2450 units, 2450 units → $24. The FE only sends whole-dollar
* inputs (the cap-edit field is an integer text box) so the floor on the read path is the only
* place rounding ever shows up, and only when an admin set a non-multiple via SQL.
*/
public final class CapMoneyUnits {
/**
* Doc-units per USD. {@code 100} = "1 cent per unit" at the in-app display layer. Tied to the
* unit-count meter the engine writes to the ledger; not tied to Stripe pricing.
*/
public static final int UNITS_PER_USD = 100;
/** Smallest currency unit per USD (always 100 cents in USD; explicit for clarity). */
public static final int CENTS_PER_USD = 100;
private CapMoneyUnits() {}
/** Convert a dollar cap entered by the leader to doc-units for {@code cap_units}. */
public static long usdToUnits(int capUsd) {
if (capUsd < 0) {
throw new IllegalArgumentException("capUsd must be >= 0");
}
return (long) capUsd * UNITS_PER_USD;
}
/** Convert {@code cap_units} back to dollars for the response payload. Floor on read. */
public static int unitsToUsd(long capUnits) {
if (capUnits < 0L) {
return 0;
}
return (int) (capUnits / UNITS_PER_USD);
}
/** Convert a dollar cap to smallest-currency-unit cents for {@code cap_source_money}. */
public static long usdToCents(int capUsd) {
if (capUsd < 0) {
throw new IllegalArgumentException("capUsd must be >= 0");
}
return (long) capUsd * CENTS_PER_USD;
}
}
@@ -0,0 +1,424 @@
package stirling.software.saas.payg.api;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.model.TeamMembership;
import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow;
import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown;
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.entitlement.EntitlementService;
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.WalletLedgerRepository;
import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
import stirling.software.saas.payg.wallet.WalletPolicy;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Read + cap-mutation surface backing the FE PAYG Plan page.
*
* <p>{@code GET /api/v1/payg/wallet} is the single fetch the {@code useWallet} hook calls. Returns
* a fully-populated {@link WalletSnapshotResponse} — derived from {@link EntitlementService} (for
* spend / cap / period), {@link PaygTeamExtensions} (for subscription state), and {@link
* WalletLedgerRepository} (for the per-category breakdown widget). Leader callers also get a roster
* of team members + their per-member usage; member callers see an empty roster.
*
* <p>{@code PATCH /api/v1/payg/cap} updates {@code wallet_policy.cap_units} (no Stripe call — the
* cap is enforced application-side via the entitlement guard) and invalidates the team's snapshot
* cache so the next read reflects the change immediately. Only leaders may call this; the team is
* derived from the caller, so we authorise inside the method rather than via {@code @PreAuthorize}
* — the team id never appears on the path or query string.
*
* <p>Subscription state is sourced from {@code payg_team_extensions.payg_subscription_id} (added in
* V14): {@code stripeSubscriptionId} echoes it via {@link TeamBillingService}, and a team reads as
* {@link #STATUS_SUBSCRIBED} once {@code billing.subscribed()} is true — i.e. it has a subscription
* id, or a Stripe customer id as the pre-webhook bridge for a just-completed checkout whose
* subscription-created webhook hasn't landed yet (see {@code TeamBillingService.compute}).
*/
@Slf4j
@Hidden
@RestController
@RequestMapping("/api/v1/payg")
@Profile("saas")
public class PaygWalletController {
static final String STATUS_FREE = "free";
static final String STATUS_SUBSCRIBED = "subscribed";
static final String ROLE_LEADER = "leader";
static final String ROLE_MEMBER = "member";
/**
* Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a
* membership — shouldn't happen post-migration). Teams always get the live {@code
* pricing_policy.free_tier_units} grant via {@link TeamBillingService}.
*/
private static final int FREE_TIER_LIMIT_UNITS_FALLBACK = 500;
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
private final EntitlementService entitlementService;
private final TeamBillingService billingService;
private final TeamMembershipRepository memberRepo;
private final PaygTeamExtensionsRepository extRepo;
private final WalletPolicyRepository policyRepo;
private final WalletLedgerRepository ledgerRepo;
private final PaygShadowChargeRepository shadowRepo;
private final UserRepository userRepository;
public PaygWalletController(
EntitlementService entitlementService,
TeamBillingService billingService,
TeamMembershipRepository memberRepo,
PaygTeamExtensionsRepository extRepo,
WalletPolicyRepository policyRepo,
WalletLedgerRepository ledgerRepo,
PaygShadowChargeRepository shadowRepo,
UserRepository userRepository) {
this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService");
this.billingService = Objects.requireNonNull(billingService, "billingService");
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
this.policyRepo = Objects.requireNonNull(policyRepo, "policyRepo");
this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo");
this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
}
// ---------------------------------------------------------------------------------------
// GET /wallet — the single FE fetch
// ---------------------------------------------------------------------------------------
@GetMapping("/wallet")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
// SecurityException maps to 401 per the existing controller convention.
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<TeamMembership> primary = primaryMembership(user.getId());
if (primary.isEmpty()) {
// Authenticated user without a team — shouldn't happen post-migration, but we don't
// want to 500. Return a free-tier-shaped empty snapshot so the FE renders the gated UI
// rather than blowing up on a null body.
return ResponseEntity.ok(emptySnapshot());
}
TeamMembership membership = primary.get();
Long teamId = membership.getTeam().getId();
boolean isLeader = membership.getRole() == TeamRole.LEADER;
// Billing facts (window, free allowance, per-doc rate, doc cap) and the entitlement
// snapshot (period spend over that window) share the same composition service, so what
// the customer sees here is exactly what the 402 guard enforces.
TeamBillingContext billing = billingService.forTeam(teamId);
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
String status = billing.subscribed() ? STATUS_SUBSCRIBED : STATUS_FREE;
boolean noCap = billing.subscribed() && billing.capMoneyMinor() == null;
Integer capMajor =
billing.capMoneyMinor() != null
? Math.toIntExact(billing.capMoneyMinor() / 100)
: 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).
int spend = clampToInt(snap.periodSpendUnits());
Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null;
CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd());
// Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was
// already netted out at charge time, so this is the metered total, not spend grant.
long periodPaid = shadowRepo.sumPaidUnits(teamId, snap.periodStart(), snap.periodEnd());
Long estimatedBill = billingService.estimateBillMinor(billing, periodPaid).orElse(null);
List<MemberRow> members =
isLeader
? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd())
: List.of();
WalletSnapshotResponse body =
new WalletSnapshotResponse(
teamId,
status,
isLeader ? ROLE_LEADER : ROLE_MEMBER,
ISO_DATE.format(snap.periodStart().toLocalDate()),
ISO_DATE.format(snap.periodEnd().toLocalDate()),
spend,
limit,
clampToInt(billing.freeGrantUnits()),
clampToInt(billing.freeRemainingUnits()),
billing.perDocMinor(),
billing.currency(),
estimatedBill,
capMajor,
noCap,
billing.subscriptionId(),
spend,
breakdown,
members,
buildActivity(teamId));
return ResponseEntity.ok(body);
}
private CategoryBreakdown buildBreakdown(
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
Map<BillingCategory, Long> byCategory = new HashMap<>();
for (Object[] row :
ledgerRepo.sumPeriodAmountByCategory(
teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) {
if (row.length >= 2
&& row[0] instanceof BillingCategory cat
&& row[1] instanceof Number n) {
byCategory.put(cat, n.longValue());
}
}
return new CategoryBreakdown(
clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)),
clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)),
clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L)));
}
/**
* Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as
* credits-back; system entries without a category render as {@code other}.
*/
private List<ActivityRow> buildActivity(Long teamId) {
List<ActivityRow> out = new ArrayList<>();
for (WalletLedgerEntry e : ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)) {
BillingCategory category = e.getBillingCategory();
String kind = category != null ? category.name().toLowerCase(Locale.ROOT) : "other";
String categoryLabel = category != null ? categoryDisplayName(category) : "Document";
String label =
e.getEntryType() == LedgerEntryType.REFUND
? "Refund — " + categoryLabel
: categoryLabel + " usage";
int docUnits = e.getAmountUnits() == null ? 0 : Math.abs(e.getAmountUnits());
out.add(
new ActivityRow(
e.getId(),
kind,
label,
e.getOccurredAt() != null ? e.getOccurredAt().toString() : "",
docUnits));
}
return out;
}
private static String categoryDisplayName(BillingCategory category) {
return switch (category) {
case API -> "API";
case AI -> "AI";
case AUTOMATION -> "Automation";
case BYPASSED -> "Manual";
};
}
// ---------------------------------------------------------------------------------------
// PATCH /cap — leader-only, cap is application-layer, no Stripe call
// ---------------------------------------------------------------------------------------
@PatchMapping("/cap")
@PreAuthorize("isAuthenticated()")
@Transactional
public ResponseEntity<Void> updateCap(
@Valid @RequestBody UpdateCapRequest req, Authentication auth) {
User user;
try {
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<TeamMembership> primary = primaryMembership(user.getId());
if (primary.isEmpty()) {
// No team → can't have a wallet to cap.
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
TeamMembership membership = primary.get();
if (membership.getRole() != TeamRole.LEADER) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Long teamId = membership.getTeam().getId();
WalletPolicy policy =
policyRepo
.findByTeamId(teamId)
.orElseGet(
() -> {
WalletPolicy created = new WalletPolicy();
created.setTeamId(teamId);
return created;
});
if (req.noCap()) {
policy.setCapUnits(null);
policy.setCapSourceMoney(null);
} else {
long capMinor = CapMoneyUnits.usdToCents(req.capUsd());
policy.setCapSourceMoney(capMinor);
// Derived document allowance (design §10: store both the money intent and the unit
// translation). The live snapshot recomputes from cap_source_money + current rate;
// this stored value is the enforcement fallback when the rate is unreachable.
TeamBillingContext billing = billingService.forTeam(teamId);
Optional<Long> docCap = billingService.docCapForMoney(billing, capMinor);
if (docCap.isPresent()) {
policy.setCapUnits(docCap.get());
} else {
// Rate unknown (price-info fn unconfigured / Stripe blip): keep the legacy
// money-as-units conversion so the cap still binds rather than silently lifting.
log.warn(
"Per-document rate unavailable for team {}; storing legacy cap_units"
+ " conversion.",
teamId);
policy.setCapUnits(CapMoneyUnits.usdToUnits(req.capUsd()));
}
}
policyRepo.save(policy);
entitlementService.invalidate(teamId);
return ResponseEntity.noContent().build();
}
/** Request body for {@link #updateCap}. */
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
private Optional<TeamMembership> primaryMembership(Long userId) {
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}
private List<MemberRow> buildMemberRows(
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
List<TeamMembership> all = memberRepo.findByTeamId(teamId);
if (all.isEmpty()) {
return List.of();
}
LocalDateTime[] window = {periodStart, periodEnd};
List<MemberRow> out = new ArrayList<>(all.size());
for (TeamMembership tm : all) {
User u = tm.getUser();
if (u == null) {
continue;
}
// We could batch these; team sizes are small (FE design assumes ≤ ~20 members per
// team on the Plan page) so a per-member sum is fine. If teams grow we'd switch to
// a single GROUP BY actor_user_id query.
long spend = 0L; // sumPeriodAmountForMember stores signed debits (negative); negate.
try {
spend = -memberSpend(teamId, u.getId(), window[0], window[1]);
} catch (RuntimeException e) {
log.warn(
"buildMemberRows: per-member spend lookup failed for user {}",
u.getId(),
e);
}
String displayName =
Optional.ofNullable(u.getUsername())
.orElse(Optional.ofNullable(u.getEmail()).orElse(""));
out.add(
new MemberRow(
Long.toString(u.getId()),
displayName,
Optional.ofNullable(u.getEmail()).orElse(""),
clampToInt(spend)));
}
return out;
}
/**
* Per-member period spend in signed ledger units (debits are negative). Helper so the test
* slice can override without standing up a real database, and so the controller doesn't inline
* the negation arithmetic at every call site.
*/
long memberSpend(Long teamId, Long userId, LocalDateTime start, LocalDateTime end) {
return ledgerRepo.sumPeriodAmountForMember(
teamId, userId, LedgerEntryType.DEBIT, start, end);
}
private static LocalDateTime[] currentMonthWindow() {
java.time.YearMonth ym = java.time.YearMonth.now();
LocalDateTime start = ym.atDay(1).atStartOfDay();
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
return new LocalDateTime[] {start, end};
}
private static int clampToInt(long v) {
if (v <= 0) return 0;
if (v >= Integer.MAX_VALUE) return Integer.MAX_VALUE;
return (int) v;
}
private WalletSnapshotResponse emptySnapshot() {
LocalDateTime[] window = currentMonthWindow();
return new WalletSnapshotResponse(
null, // teamId — unknown when the caller has no team membership
STATUS_FREE,
ROLE_MEMBER,
ISO_DATE.format(window[0].toLocalDate()),
ISO_DATE.format(window[1].toLocalDate()),
0,
FREE_TIER_LIMIT_UNITS_FALLBACK,
FREE_TIER_LIMIT_UNITS_FALLBACK,
FREE_TIER_LIMIT_UNITS_FALLBACK,
null,
null,
null,
null,
false,
null,
0,
new CategoryBreakdown(0, 0, 0),
List.of(),
Collections.emptyList());
}
}
@@ -0,0 +1,101 @@
package stirling.software.saas.payg.api;
import java.math.BigDecimal;
import java.util.List;
/**
* JSON payload returned by {@code GET /api/v1/payg/wallet}. Mirrors the {@code Wallet} type the
* frontend {@code useWallet} hook consumes, plus the leader-only fields ({@code members},
* 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
* 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
* "unknown" — never a substituted default.
*
* @param teamId the caller's primary team_id. Needed by the frontend so it can pass it to the
* Supabase edge functions that create Stripe Checkout / portal sessions — those run outside
* Spring Security and have no other way to resolve the caller's team.
* @param status {@code "free"} when the team has no Stripe subscription; {@code "subscribed"} once
* a card is on file and the engine bills meter events.
* @param role the current caller's role within their team — {@code "leader"} or {@code "member"}.
* Controls which UI variant the frontend renders.
* @param billingPeriodStart inclusive ISO date (yyyy-MM-dd) for the current cycle — the Stripe
* 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.
* @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}
* when unknown (free teams, unresolved rate).
* @param estimatedBillMinor estimated charges so far this period in minor units of {@code
* currency}: paid (Stripe-metered) documents this period × {@code pricePerDocMinor}. The free
* portion was already netted out at charge time. Informational — the Stripe invoice is
* authoritative. {@code null} when the rate is unknown.
* @param capUsd the leader's monthly spending cap in major currency units; {@code null} when free
* or when the leader has opted into no-cap. (Field name predates multi-currency; the FE pairs
* it with {@code currency} for the symbol.)
* @param noCap {@code true} when the leader has explicitly disabled the cap. Only meaningful when
* subscribed.
* @param stripeSubscriptionId Stripe subscription id from {@code
* payg_team_extensions.payg_subscription_id}; {@code null} when status is free.
* @param spendUnitsThisPeriod documents debited this cycle across billable categories.
* @param categoryBreakdown per-category spend slice over the same billing window.
* @param members leader-only roster of team members + their per-member sub-caps. Empty for member
* callers.
* @param recent latest wallet-ledger entries (newest first) for the activity feed.
*/
public record WalletSnapshotResponse(
Long teamId,
String status,
String role,
String billingPeriodStart,
String billingPeriodEnd,
int billableUsed,
Integer billableLimit,
int freeAllowance,
int freeRemaining,
BigDecimal pricePerDocMinor,
String currency,
Long estimatedBillMinor,
Integer capUsd,
boolean noCap,
String stripeSubscriptionId,
int spendUnitsThisPeriod,
CategoryBreakdown categoryBreakdown,
List<MemberRow> members,
List<ActivityRow> recent) {
/** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */
public record CategoryBreakdown(int api, int ai, int automation) {}
/**
* One row of the team-members table on the leader's Plan page — display-only per-member usage.
* (Per-member sub-caps aren't enforced yet; see the follow-ups note. When they ship, a cap
* field returns here.)
*/
public record MemberRow(String userId, String name, String email, int spendUnits) {}
/**
* One wallet-ledger entry shaped for the FE activity feed.
*
* @param id ledger entry id (stable React key)
* @param kind lower-case billing category ({@code api} / {@code ai} / {@code automation}) or
* {@code other} for system entries
* @param label human line, e.g. {@code "API usage"} or {@code "Refund — API"}
* @param ts ISO-8601 local timestamp of the entry
* @param docUnits absolute document count of the entry
*/
public record ActivityRow(long id, String kind, String label, String ts, int docUnits) {}
}
@@ -0,0 +1,47 @@
package stirling.software.saas.payg.billing;
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>
*
* @param subscribed team has an active PAYG subscription (or a Stripe customer awaiting its
* subscription-created webhook)
* @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 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
* @param capMoneyMinor leader-set monthly spending cap in minor units ({@code
* wallet_policy.cap_source_money}); null = no cap configured
* @param monthlyCapDocUnits the subscribed monthly paid-document ceiling — {@code floor(capMoney /
* perDocRate)}; null = uncapped, or the team is not subscribed
*/
public record TeamBillingContext(
boolean subscribed,
String subscriptionId,
LocalDateTime periodStart,
LocalDateTime periodEnd,
long freeGrantUnits,
long freeRemainingUnits,
BigDecimal perDocMinor,
String currency,
Long capMoneyMinor,
Long monthlyCapDocUnits) {}
@@ -0,0 +1,267 @@
package stirling.software.saas.payg.billing;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.stripe.StripeSubscriptionDao;
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate;
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling;
import stirling.software.saas.payg.wallet.WalletPolicy;
/**
* Single composition point for "what does billing look like for this team right now." Both the
* 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>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.
* Note the cached context's {@code freeRemainingUnits} is a 30s-stale read of the counter — the
* authoritative decrement happens in {@code JobChargeService} against the row directly; this cache
* is for display + the entitlement gate, where 30s staleness is the accepted cap-evaluation floor.
*/
@Slf4j
@Service
@Profile("saas")
public class TeamBillingService {
static final int CACHE_TTL_SECONDS = 30;
private static final int CACHE_MAX_SIZE = 10_000;
/**
* In-app display/estimate currency. The app prices in dollars; Stripe handles real currency
* selection at checkout. Used to pick the right Price for un-subscribed teams.
*/
private static final String DISPLAY_CURRENCY = "usd";
/**
* Stripe Price {@code lookup_key} for the PAYG per-document price. The stable handle we resolve
* an un-subscribed team's rate from (the default policy carries no price ids in the seed).
*/
private static final String PAYG_LOOKUP_KEY = "plan:processor";
private final PaygTeamExtensionsRepository extensionsRepository;
private final WalletPolicyRepository walletPolicyRepository;
private final PricingPolicyService pricingPolicyService;
private final StripeSubscriptionDao subscriptionDao;
private final Cache<Long, TeamBillingContext> cache;
public TeamBillingService(
PaygTeamExtensionsRepository extensionsRepository,
WalletPolicyRepository walletPolicyRepository,
PricingPolicyService pricingPolicyService,
StripeSubscriptionDao subscriptionDao) {
this.extensionsRepository =
Objects.requireNonNull(extensionsRepository, "extensionsRepository");
this.walletPolicyRepository =
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
this.pricingPolicyService =
Objects.requireNonNull(pricingPolicyService, "pricingPolicyService");
this.subscriptionDao = Objects.requireNonNull(subscriptionDao, "subscriptionDao");
this.cache =
Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
.build();
}
public TeamBillingContext forTeam(Long teamId) {
Objects.requireNonNull(teamId, "teamId");
return cache.get(teamId, this::compute);
}
/** Drop {@code teamId}'s entry after cap edits / subscription webhooks / grant consumption. */
public void invalidate(Long teamId) {
if (teamId != null) {
cache.invalidate(teamId);
}
}
private TeamBillingContext compute(Long teamId) {
Optional<PaygTeamExtensions> extOpt = extensionsRepository.findById(teamId);
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null);
// payg_subscription_id is the designed switch; stripe_customer_id presence is the
// pre-webhook stand-in kept so a team whose checkout completed but whose
// subscription-created webhook hasn't landed yet still renders as subscribed.
boolean subscribed =
subscriptionId != null
|| extOpt.map(PaygTeamExtensions::getStripeCustomerId)
.filter(s -> !s.isBlank())
.isPresent();
long freeGrant = resolveGrant(teamId);
long freeRemaining =
extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining)
.map(Long::longValue)
.orElse(0L);
Optional<SubscriptionBilling> billing =
subscriptionId != null
? subscriptionDao.findBilling(subscriptionId)
: Optional.empty();
LocalDateTime[] window =
billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()})
.orElseGet(TeamBillingService::calendarMonthWindow);
BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null);
String currency = billing.map(SubscriptionBilling::currency).orElse(null);
// Un-subscribed teams have no Stripe subscription to read a rate from, but the cap
// estimate (the upgrade flow's "≈ N paid PDFs/month") still needs one. Resolve it from
// the default policy's USD Price — Stripe hasn't assigned the team a currency yet, and
// the whole app prices in dollars. Display-only: resolveMonthlyCap stays gated on
// `subscribed`, so this never starts enforcing a cap on a free team.
if (!subscribed && perDocMinor == null) {
Optional<PriceRate> rate =
subscriptionDao.findRateByLookupKey(PAYG_LOOKUP_KEY, DISPLAY_CURRENCY);
if (rate.isPresent()) {
perDocMinor = rate.get().perDocMinor();
currency = rate.get().currency();
}
}
Long capMoneyMinor = walletPolicyOpt.map(WalletPolicy::getCapSourceMoney).orElse(null);
Long legacyCapUnits = walletPolicyOpt.map(WalletPolicy::getCapUnits).orElse(null);
Long monthlyCapDocUnits =
resolveMonthlyCap(subscribed, capMoneyMinor, legacyCapUnits, perDocMinor);
return new TeamBillingContext(
subscribed,
subscriptionId,
window[0],
window[1],
freeGrant,
freeRemaining,
perDocMinor,
currency,
capMoneyMinor,
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);
Long grant = policy.getFreeTierUnits();
return grant == null ? 0L : grant;
} catch (RuntimeException e) {
log.warn("No effective pricing policy for team {}: {}", teamId, e.getMessage());
return 0L;
}
}
/**
* 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.
*
* <ul>
* <li>not subscribed → null (the free grant, not a money cap, is what bounds them);
* <li>subscribed, no money cap → uncapped (null), unless an admin set raw {@code cap_units};
* <li>subscribed, money cap + known rate → {@code floor(capMoney / perDocRate)};
* <li>subscribed, money cap but rate unknown → stored {@code cap_units} fallback (WARN).
* </ul>
*/
private Long resolveMonthlyCap(
boolean subscribed, Long capMoneyMinor, Long legacyCapUnits, BigDecimal perDocMinor) {
if (!subscribed) {
return null;
}
if (capMoneyMinor == null) {
return legacyCapUnits; // admin-set unit cap (source money null) still applies
}
if (perDocMinor != null && perDocMinor.signum() > 0) {
return BigDecimal.valueOf(capMoneyMinor)
.divide(perDocMinor, 0, RoundingMode.FLOOR)
.longValue();
}
log.warn(
"Per-document rate unavailable; enforcing stored cap_units fallback ({}).",
legacyCapUnits);
return legacyCapUnits;
}
/**
* Estimated charges for the current period in minor units of {@link
* TeamBillingContext#currency()}: the paid (metered) documents this period at the per-document
* rate. Informational — the Stripe invoice is authoritative. Empty when the rate is unknown.
*
* @param paidUnitsThisPeriod metered documents this period ({@code payg_units
* free_units_consumed} summed over the period's charged jobs)
*/
public Optional<Long> estimateBillMinor(TeamBillingContext ctx, long paidUnitsThisPeriod) {
if (ctx.perDocMinor() == null) {
return Optional.empty();
}
long paid = Math.max(0, paidUnitsThisPeriod);
BigDecimal bill =
ctx.perDocMinor()
.multiply(BigDecimal.valueOf(paid))
.setScale(0, RoundingMode.HALF_UP);
return Optional.of(bill.longValue());
}
/**
* 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.
*/
public Optional<Long> docCapForMoney(TeamBillingContext ctx, long capMinor) {
if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) {
return Optional.empty();
}
return Optional.of(
BigDecimal.valueOf(capMinor)
.divide(ctx.perDocMinor(), 0, RoundingMode.FLOOR)
.longValue());
}
/**
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
* used when there's no Stripe subscription period to anchor on.
*/
static LocalDateTime[] calendarMonthWindow() {
return calendarMonthWindow(LocalDateTime.now());
}
/** Test seam — accepts a clock value so tests don't race the calendar boundary. */
static LocalDateTime[] calendarMonthWindow(LocalDateTime now) {
java.time.YearMonth ym = java.time.YearMonth.from(now);
return new LocalDateTime[] {
ym.atDay(1).atStartOfDay(), ym.plusMonths(1).atDay(1).atStartOfDay()
};
}
}
@@ -0,0 +1,104 @@
package stirling.software.saas.payg.cap;
import java.util.List;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
/**
* Pure-compute cap evaluation. Given a team's (or member's) spend, cap, and warn / degrade
* thresholds, returns the {@link EntitlementState}, the {@link FeatureSet} that should be in
* effect, and the corresponding enabled {@link FeatureGate}s. No DB access, no caches — the caller
* (entitlement service) supplies the inputs.
*
* <p>State transitions (matching {@code notes/PAYG_DESIGN.md} §3.6):
*
* <ul>
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally.
* <li>{@code spend / cap &lt; warnPct} → {@code FULL}.
* <li><b>MINIMAL semantics:</b> under DEGRADED+MINIMAL manual server-side tools (gated by {@link
* FeatureGate#OFFSITE_PROCESSING}) and client-side tools still work; only {@link
* FeatureGate#AUTOMATION} and {@link FeatureGate#AI_SUPPORT} are blocked.
* <li>{@code warnPct ≤ spend / cap &lt; degradePct} → {@code WARNED}; feature set still {@link
* FeatureSet#FULL} — the warn band is a notification trigger, not a degradation.
* <li>{@code spend / cap ≥ degradePct} → {@code DEGRADED}; feature set drops to the policy's
* configured {@code degradedFeatureSet} (default {@link FeatureSet#MINIMAL}).
* </ul>
*
* <p>The percentage compare is integer math. We multiply spend by 100 before dividing — this keeps
* the precision and avoids floating-point on the hot path. Spend × 100 can overflow long at 9.2e16
* units, which is not a realistic value (would represent quintillions of charged documents); we
* don't guard against it.
*/
public final class CapEvaluator {
private CapEvaluator() {}
/**
* Snapshot of one cap evaluation. The caller persists this into the appropriate {@code
* wallet_entitlement_snapshot} row (team-wide or per-member).
*/
public record Evaluation(
EntitlementState state, FeatureSet featureSet, List<FeatureGate> enabledGates) {}
public static Evaluation evaluate(
long spendUnits,
Long capUnits,
int warnAtPct,
int degradeAtPct,
FeatureSet degradedFeatureSet) {
if (capUnits == null || capUnits <= 0) {
return full();
}
if (warnAtPct < 0 || degradeAtPct <= 0 || degradeAtPct < warnAtPct) {
// Defensive: misconfigured thresholds → treat as no-cap-effect to avoid surprise
// degradation. The admin endpoints that set the policy should validate; this
// protects the hot path from a bad row sneaking through.
return full();
}
// pct = floor((spend * 100) / cap). Integer arithmetic on the hot path.
long pct = (spendUnits * 100L) / capUnits;
if (pct >= degradeAtPct) {
FeatureSet effective =
degradedFeatureSet != null ? degradedFeatureSet : FeatureSet.MINIMAL;
return new Evaluation(EntitlementState.DEGRADED, effective, gatesFor(effective));
}
if (pct >= warnAtPct) {
// Warn band: still FULL feature set, but state flag is set so the FE can show a
// banner / send a notification. The wallet service emits a
// WalletEntitlementChanged event when state transitions; subscribers (email
// reminder, SSE to FE) act on that.
return new Evaluation(
EntitlementState.WARNED, FeatureSet.FULL, gatesFor(FeatureSet.FULL));
}
return full();
}
/**
* Default enabled gates for a given feature set. Kept in sync with the design doc §3.7 mapping
* table.
*/
public static List<FeatureGate> gatesFor(FeatureSet set) {
if (set == null) {
return List.of();
}
return switch (set) {
case FULL ->
List.of(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE);
case MINIMAL -> List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
case CLIENT_ONLY -> List.of(FeatureGate.CLIENT_SIDE);
};
}
private static Evaluation full() {
return new Evaluation(EntitlementState.FULL, FeatureSet.FULL, gatesFor(FeatureSet.FULL));
}
}
@@ -0,0 +1,46 @@
package stirling.software.saas.payg.cap;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import stirling.software.saas.payg.model.FeatureGate;
/**
* Declares which {@link FeatureGate}(s) a controller method requires. Read at request time by
* {@code EntitlementGuard}; if any required gate is not in the team's currently-enabled gates the
* request is rejected with HTTP 402.
*
* <p>The annotation is <em>not required</em> on every endpoint. The guard's default rule is:
*
* <ul>
* <li>{@code @RequiresFeature} present → use exactly those gates.
* <li>No annotation, but the method has {@code @AutoJobPostMapping} → assume {@link
* FeatureGate#OFFSITE_PROCESSING}.
* <li>Neither → skip (admin endpoints, info, config — these don't accrue charges and shouldn't
* degrade).
* </ul>
*
* <p>So the only endpoints that need this annotation explicitly are those whose gate is
* <em>different</em> from the default {@code OFFSITE_PROCESSING} — chiefly {@code
* PipelineController} ({@link FeatureGate#AUTOMATION}) and the AI proxy layer ({@link
* FeatureGate#AI_SUPPORT}). Per-tool proliferation of the annotation is intentional non-goal.
*
* <p>Multiple gates declared = ALL must be enabled (AND, not OR). Realistic usage is single-gate;
* the array form is here for future combinations (e.g. an AI workflow inside a pipeline that needs
* both {@code AUTOMATION} and {@code AI_SUPPORT}).
*
* <pre>{@code
* @RequiresFeature(FeatureGate.AUTOMATION)
* @AutoJobPostMapping("/pipeline")
* public ResponseEntity<...> runPipeline(@ModelAttribute PipelineRequest req) { ... }
* }</pre>
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresFeature {
/** One or more gates that must all be enabled for the request to proceed. */
FeatureGate[] value();
}
@@ -1,5 +1,6 @@
package stirling.software.saas.payg.charge;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
@@ -8,9 +9,18 @@ import stirling.software.saas.payg.model.ProcessType;
* kind of process this is. Does NOT carry policy fields — the charge service resolves the effective
* policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from
* the live policy.
*
* <p>{@code billingCategory} is the analytics axis for ledger + shadow rows and is determined by
* the interceptor before this context is built. Manual UI tools never reach {@code openProcess}
* (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore
* carries one of {@code API}, {@code AI}, or {@code AUTOMATION}.
*/
public record ChargeContext(
Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) {
Long ownerUserId,
Long ownerTeamId,
JobSource source,
ProcessType processType,
BillingCategory billingCategory) {
public ChargeContext {
if (ownerUserId == null) {
@@ -22,5 +32,8 @@ public record ChargeContext(
if (processType == null) {
throw new IllegalArgumentException("processType is required");
}
if (billingCategory == null) {
throw new IllegalArgumentException("billingCategory is required");
}
}
}
@@ -11,6 +11,8 @@ import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
@@ -21,14 +23,23 @@ import stirling.software.saas.payg.job.JobContext;
import stirling.software.saas.payg.job.JobService;
import stirling.software.saas.payg.job.JoinOrOpenResult;
import stirling.software.saas.payg.job.ProcessingJob;
import stirling.software.saas.payg.meter.PaygMeterReportingService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.JobStatus;
import stirling.software.saas.payg.model.LedgerBucket;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.model.ReferenceType;
import stirling.software.saas.payg.model.ShadowChargeStatus;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.policy.PricingPolicy;
import stirling.software.saas.payg.policy.PricingPolicyService;
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.ProcessingJobRepository;
import stirling.software.saas.payg.repository.WalletLedgerRepository;
import stirling.software.saas.payg.shadow.PaygShadowCharge;
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
/**
* Orchestrates a tool call's open-process decision: look up the team's effective policy, resolve
@@ -55,18 +66,29 @@ public class JobChargeService {
private final DocumentClassifier classifier;
private final PaygShadowChargeRepository shadowRepository;
private final ProcessingJobRepository jobRepository;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final PaygMeterReportingService meterReportingService;
private final WalletLedgerRepository ledgerRepository;
public JobChargeService(
JobService jobService,
PricingPolicyService policyService,
DocumentClassifier classifier,
PaygShadowChargeRepository shadowRepository,
ProcessingJobRepository jobRepository) {
ProcessingJobRepository jobRepository,
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService,
WalletLedgerRepository ledgerRepository) {
this.jobService = Objects.requireNonNull(jobService, "jobService");
this.policyService = Objects.requireNonNull(policyService, "policyService");
this.classifier = Objects.requireNonNull(classifier, "classifier");
this.shadowRepository = Objects.requireNonNull(shadowRepository, "shadowRepository");
this.jobRepository = Objects.requireNonNull(jobRepository, "jobRepository");
this.teamExtensionsRepository =
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
this.meterReportingService =
Objects.requireNonNull(meterReportingService, "meterReportingService");
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
}
/**
@@ -104,11 +126,70 @@ public class JobChargeService {
int units = computeUnits(inputs, policy);
result.job().setDocUnits(units);
recordShadowRow(ctx, result.job().getId(), policy.getId(), units);
int freeUsed = consumeFreeGrant(ctx, units);
recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed);
recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units);
return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED);
}
/**
* 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).
*/
private int consumeFreeGrant(ChargeContext ctx, int units) {
BillingCategory category = ctx.billingCategory();
if (category == null || category == BillingCategory.BYPASSED || ctx.ownerTeamId() == null) {
return 0;
}
Optional<PaygTeamExtensions> extOpt =
teamExtensionsRepository.findByIdForUpdate(ctx.ownerTeamId());
if (extOpt.isEmpty()) {
return 0;
}
PaygTeamExtensions ext = extOpt.get();
long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining();
int freeUsed = (int) Math.min(units, Math.max(0L, remaining));
if (freeUsed > 0) {
ext.setFreeUnitsRemaining(remaining - freeUsed);
teamExtensionsRepository.save(ext);
}
return freeUsed;
}
/**
* The live spend record. Everything the customer-facing side reads — the wallet endpoint's
* {@code spendUnitsThisPeriod}, the per-category breakdown ({@code wallet_category_summary}
* view), and the cap evaluator's period sum — derives from {@code wallet_ledger} DEBITs. Shadow
* rows are the comparison audit trail; this row is what actually counts.
*
* <p>Sign convention: debits are stored NEGATIVE (the entitlement snapshot negates the sum).
* Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed.
*/
private void recordLedgerDebit(
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
BillingCategory category = ctx.billingCategory();
if (category == null || category == BillingCategory.BYPASSED) {
return;
}
WalletLedgerEntry entry = new WalletLedgerEntry();
entry.setTeamId(ctx.ownerTeamId());
entry.setActorUserId(ctx.ownerUserId());
entry.setEntryType(LedgerEntryType.DEBIT);
entry.setBucket(LedgerBucket.CYCLE);
entry.setAmountUnits(-units);
entry.setReferenceType(ReferenceType.JOB);
entry.setReferenceId(jobId.toString());
entry.setPolicyId(policyId);
entry.setBillingCategory(category);
ledgerRepository.save(entry);
}
private int resolveStepLimit(PricingPolicy policy, JobSource source) {
Integer fromPolicy =
policy.getStepLimits() == null ? null : policy.getStepLimits().get(source);
@@ -142,17 +223,28 @@ public class JobChargeService {
}
private void recordShadowRow(
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
ChargeContext ctx,
java.util.UUID jobId,
Long policyId,
int units,
int freeUnitsConsumed) {
PaygShadowCharge row = new PaygShadowCharge();
row.setTeamId(ctx.ownerTeamId());
row.setJobId(jobId);
row.setPolicyId(policyId);
row.setPaygUnits(units);
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
// and a refund restores freeUnitsConsumed to the team's grant.
row.setFreeUnitsConsumed(freeUnitsConsumed);
// No legacy comparison yet — wired when the shadow path is connected to the legacy
// CreditService in the follow-up PR. Until then, diff stays at 0.
row.setLegacyCreditsCharged(0);
row.setDiffPct(0);
row.setStatus(ShadowChargeStatus.CHARGED);
// PAYG analytics axis + caller surface — copied from ctx so the row stays self-describing
// after processing_job is pruned. Never affects what Stripe meters (single flat meter).
row.setBillingCategory(ctx.billingCategory());
row.setJobSource(ctx.source());
shadowRepository.save(row);
}
@@ -182,6 +274,31 @@ public class JobChargeService {
row.setRefundedAt(now);
row.setRefundReason(trimReason(refundReason));
shadowRepository.save(row);
// Compensate the live ledger DEBIT written at openProcess so the period spend
// nets to zero for the failed work. Positive amount mirrors the negative debit;
// same JOB reference ties the pair together. The idempotency guard above (only
// on the CHARGED→REFUNDED transition) prevents double-credits on re-invocation.
BillingCategory category = row.getBillingCategory();
if (category != null && category != BillingCategory.BYPASSED) {
WalletLedgerEntry refund = new WalletLedgerEntry();
refund.setTeamId(row.getTeamId());
refund.setEntryType(LedgerEntryType.REFUND);
refund.setBucket(LedgerBucket.CYCLE);
refund.setAmountUnits(row.getPaygUnits());
refund.setReferenceType(ReferenceType.JOB);
refund.setReferenceId(jobId.toString());
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.
int freeConsumed =
row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
if (freeConsumed > 0 && row.getTeamId() != null) {
teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed);
}
}
}
}
@@ -197,6 +314,142 @@ public class JobChargeService {
}
}
/**
* Closes a process and — as a fallback — meters its usage. The primary meter trigger is the
* charge interceptor's {@code afterCompletion} on a successful request (see {@link
* #meterJobUsage(UUID)}); this close-time meter exists to catch processes that were never
* cleanly completed (request thread died before {@code afterCompletion}) and are swept up later
* by {@code StaleJobCloser}. The deterministic idempotency key means a job already metered at
* completion is deduped here at Stripe, so the two paths never double-bill.
*
* <p>Idempotent w.r.t. process state (delegates to {@link JobService#close(UUID)}, which
* silently no-ops on an already-closed row). The meter POST runs in an {@code afterCommit} hook
* so a failed POST does not roll back the close; the reconciliation backfill (separate chunk)
* is the durability mechanism.
*/
@Transactional
public ProcessingJob close(UUID jobId) {
Objects.requireNonNull(jobId, "jobId");
ProcessingJob closed = jobService.close(jobId);
// The afterCommit hook only fires if there's an active transaction (Spring's
// @Transactional ensures that). If we're called outside one — e.g. a test using the raw
// bean — fall through with a debug log: the close() above already happened in a
// sub-transaction created by JobService, but the surrounding scope has no synchronization.
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
log.debug("close({}): no active synchronization; skipping meter POST", jobId);
return closed;
}
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
try {
meterJobUsage(jobId);
} catch (RuntimeException e) {
// PaygMeterReportingService should already swallow; defence in depth so
// a thrown exception out of afterCommit doesn't leak past the
// synchronization boundary and bubble into the caller.
log.warn(
"afterCommit meter post for job {} threw unexpectedly: {}",
jobId,
e.getMessage());
}
}
});
return closed;
}
/**
* Post this job's billable usage to Stripe. The primary caller is the charge interceptor's
* {@code afterCompletion} on a successful OPENED request — i.e. the moment the work finishes —
* so the meter moves promptly. {@link #close(UUID)} also calls this from its {@code
* afterCommit} hook as the fallback for processes that were never cleanly completed (e.g. the
* request thread died); the deterministic idempotency key ({@code process:<id>:close}) makes
* the two paths dedup at Stripe, so a job metered at completion isn't billed again when it's
* later stale-closed.
*
* <p>Safe to call outside a transaction: it only reads (the job's openProcess DEBIT is already
* committed by the time either caller runs) and the POST is best-effort. Never throws — see
* {@link PaygMeterReportingService}.
*
* <p>Skips: no shadow row (not PAYG-tracked), REFUNDED row (first-step failure — never billed),
* BYPASSED/uncategorised, zero units, free-tier team (no Stripe customer), or usage still
* within the app-side free allowance.
*/
public void meterJobUsage(UUID jobId) {
Optional<PaygShadowCharge> rowOpt = shadowRepository.findFirstByJobIdOrderByIdAsc(jobId);
if (rowOpt.isEmpty()) {
// No shadow row → not a PAYG-tracked job; nothing to meter.
return;
}
PaygShadowCharge row = rowOpt.get();
if (row.getStatus() == ShadowChargeStatus.REFUNDED) {
// Refunded rows are zero-net charges; do not emit a meter event.
return;
}
BillingCategory category = row.getBillingCategory();
if (category == null || category == BillingCategory.BYPASSED) {
// Defensive: BYPASSED rows shouldn't exist (interceptor short-circuits before
// openProcess), but tolerate if a future caller writes one.
log.debug("close({}): shadow row category={} → no meter event", jobId, category);
return;
}
Integer units = row.getPaygUnits();
if (units == null || units <= 0) {
return;
}
Long teamId = row.getTeamId();
if (teamId == null) {
return;
}
PaygTeamExtensions ext = teamExtensionsRepository.findById(teamId).orElse(null);
if (ext == null) {
return;
}
// payg_subscription_id is the single switch that says "this team is billed" (see
// PaygTeamExtensions). Gate on it directly now that V14 ships the column: a team with a
// Stripe customer but no live subscription — e.g. the brief window after checkout but
// before the subscription-created webhook lands — must not post meter events against a
// subscription that doesn't exist. A job finishing in that window is still metered later
// via the stale-close fallback, once the subscription has landed (same idempotency key).
String subscriptionId = ext.getPaygSubscriptionId();
if (subscriptionId == null || subscriptionId.isBlank()) {
log.debug(
"close({}): team {} has no active subscription → no meter event",
jobId,
teamId);
return;
}
String stripeCustomerId = ext.getStripeCustomerId();
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
// Subscribed but no customer id is a data inconsistency — we can't address the event.
log.warn(
"close({}): team {} has a subscription but no stripeCustomerId → cannot meter",
jobId,
teamId);
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.
int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
int paidUnits = units - freeConsumed;
if (paidUnits <= 0) {
log.debug(
"close({}): all {} units came from the free grant → no meter event",
jobId,
units);
return;
}
String idempotencyKey = "process:" + jobId + ":close";
meterReportingService.recordUsage(
teamId, stripeCustomerId, paidUnits, category, idempotencyKey, jobId);
}
/**
* Mid-chain 5xx on a JOINED step: return the step slot. The {@code lastStepAt} timestamp stays
* advanced (workflow window intentionally remains active for the next retry). No shadow-row
@@ -0,0 +1,347 @@
package stirling.software.saas.payg.entitlement;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.util.AuthenticationUtils;
/**
* Hot-path entitlement check. Runs after {@code PaygChargeInterceptor} in the MVC chain and short-
* circuits the request before any handler work happens when the team's snapshot is missing one of
* the gates the route declared via {@link RequiresFeature}.
*
* <p>Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping}
* (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated
* routes). Admin / info / config endpoints are excluded by the path-pattern in {@code
* PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non-
* billable infra never trips the guard.
*
* <p>Decision matrix:
*
* <table>
* <tr><th>auth</th><th>required gates</th><th>snapshot enabled?</th><th>outcome</th></tr>
* <tr><td>anonymous</td><td>AUTOMATION or AI_SUPPORT</td><td>n/a</td><td>401 SIGNUP_REQUIRED</td></tr>
* <tr><td>anonymous</td><td>OFFSITE_PROCESSING / CLIENT_SIDE</td><td>n/a</td><td>200 (pass through)</td></tr>
* <tr><td>authenticated</td><td>required ⊆ enabled</td><td>yes</td><td>200</td></tr>
* <tr><td>authenticated</td><td>required ⊄ enabled</td><td>no</td><td>402 FEATURE_DEGRADED</td></tr>
* </table>
*
* <p>Fail-open: any unexpected exception is logged at WARN and the request passes through. The cap
* pipeline must never block a customer because the guard tripped on a transient DB error.
*/
@Slf4j
@Component
@Profile("saas")
public class EntitlementGuard implements HandlerInterceptor {
private static final FeatureGate[] DEFAULT_REQUIRED_GATES = {FeatureGate.OFFSITE_PROCESSING};
private final EntitlementService entitlementService;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
private final Counter passCounter;
private final Counter deniedDegradedCounter;
private final Counter deniedPaygLimitCounter;
private final Counter deniedSignupRequiredCounter;
private final Counter errorsCounter;
private final Counter skippedNoAnnotationCounter;
public EntitlementGuard(
EntitlementService entitlementService,
UserRepository userRepository,
MeterRegistry meterRegistry) {
this.entitlementService = entitlementService;
this.userRepository = userRepository;
this.objectMapper = new ObjectMapper();
this.passCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "pass")
.register(meterRegistry);
this.deniedDegradedCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "denied_degraded")
.register(meterRegistry);
this.deniedPaygLimitCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "denied_payg_limit")
.register(meterRegistry);
this.deniedSignupRequiredCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "denied_signup_required")
.register(meterRegistry);
this.skippedNoAnnotationCounter =
Counter.builder("payg.entitlement.guard")
.tag("outcome", "skipped")
.register(meterRegistry);
this.errorsCounter =
Counter.builder("payg.entitlement.guard.errors")
.description("EntitlementGuard internal failures (fail-open)")
.register(meterRegistry);
}
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod hm)) {
return true;
}
// Scope: AutoJobPostMapping routes (multipart tool POSTs) OR routes that explicitly
// declare @RequiresFeature (e.g. AI controllers — JSON-bodied, no AutoJobPostMapping).
// Admin / info / config endpoints carry neither annotation and never trip the guard.
boolean hasAutoJobPostMapping =
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|| AnnotationUtils.findAnnotation(
hm.getBeanType(), AutoJobPostMapping.class)
!= null;
boolean hasRequiresFeature =
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|| AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class)
!= null;
if (!hasAutoJobPostMapping && !hasRequiresFeature) {
skippedNoAnnotationCounter.increment();
return true;
}
FeatureGate[] required = resolveRequiredGates(hm);
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean anonymous = isAnonymous(auth);
boolean billable = isBillable(required);
if (anonymous) {
if (billable) {
return write401SignupRequired(response, required);
}
// Anonymous user calling a manual / OFFSITE-only tool — let it through; PAYG only
// charges authenticated requests.
passCounter.increment();
return true;
}
Long teamId;
try {
teamId = resolveTeamId(auth);
} catch (RuntimeException e) {
log.warn("EntitlementGuard resolveTeamId failed; passing through", e);
errorsCounter.increment();
return true;
}
if (teamId == null) {
// Defensive: authenticated principal with no team — shouldn't happen post-migration,
// but we don't want to lock those users out. PaygChargeInterceptor short-circuits the
// same shape upstream.
passCounter.increment();
return true;
}
EntitlementSnapshot snapshot;
try {
snapshot = entitlementService.getSnapshot(teamId);
} catch (RuntimeException e) {
log.warn("EntitlementGuard getSnapshot failed for team {}; passing through", teamId, e);
errorsCounter.increment();
return true;
}
// API-key calls are always billable usage (BillingCategory.API) — there is no "free
// manual" path for a programmatic client the way there is for a JWT/web user, whose
// everyday tool calls are BYPASSED and never reach a gate. So once the team is over its
// free allowance / spending cap (DEGRADED), every API-key call hard-stops, regardless of
// which gate the route declares. The gate loop below would otherwise wave through an API
// call to a plain server tool (it needs only OFFSITE_PROCESSING, which survives DEGRADED),
// letting an unsubscribed team keep consuming the API for free past its allowance.
if (auth instanceof ApiKeyAuthenticationToken && snapshot.isDegraded()) {
return write402PaygLimitReached(response, snapshot);
}
List<FeatureGate> enabled = snapshot.enabledGates();
for (FeatureGate gate : required) {
if (enabled == null || !enabled.contains(gate)) {
return write402FeatureDegraded(response, required, snapshot);
}
}
passCounter.increment();
return true;
}
static FeatureGate[] resolveRequiredGates(HandlerMethod hm) {
RequiresFeature ann = AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class);
if (ann == null) {
ann = AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class);
}
if (ann != null && ann.value().length > 0) {
return ann.value();
}
return DEFAULT_REQUIRED_GATES;
}
private static boolean isAnonymous(Authentication auth) {
if (auth == null || !auth.isAuthenticated()) {
return true;
}
// Spring's anonymous filter installs a token whose name is "anonymousUser".
return "anonymousUser".equals(auth.getName());
}
private static boolean isBillable(FeatureGate[] required) {
for (FeatureGate g : required) {
if (g == FeatureGate.AUTOMATION || g == FeatureGate.AI_SUPPORT) {
return true;
}
}
return false;
}
private Long resolveTeamId(Authentication auth) {
if (auth instanceof ApiKeyAuthenticationToken
&& auth.getPrincipal() instanceof User apiUser) {
return apiUser.getTeam() == null ? null : apiUser.getTeam().getId();
}
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
if (supabaseId == null) {
return null;
}
UUID supabaseUuid;
try {
supabaseUuid = UUID.fromString(supabaseId);
} catch (IllegalArgumentException e) {
// Username-style principals (legacy local accounts) — no Supabase ID to look up. Skip.
return null;
}
return userRepository
.findBySupabaseId(supabaseUuid)
.map(u -> u.getTeam() == null ? null : u.getTeam().getId())
.orElse(null);
}
private boolean write401SignupRequired(HttpServletResponse response, FeatureGate[] required) {
deniedSignupRequiredCounter.increment();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "SIGNUP_REQUIRED");
body.put("category", inferCategory(required));
writeJson(response, HttpStatus.UNAUTHORIZED, body);
return false;
}
/**
* 402 for a billable API-key call once the team is over its allowance / cap. The message is
* tailored by subscription state: an un-subscribed team is told to subscribe (their free
* allowance is spent); a subscribed team is told it hit its own spending cap. Programmatic
* clients get a stable {@code error} code plus the spend/cap numbers so they can surface
* something actionable.
*/
private boolean write402PaygLimitReached(
HttpServletResponse response, EntitlementSnapshot snapshot) {
deniedPaygLimitCounter.increment();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "PAYG_LIMIT_REACHED");
body.put("subscribed", snapshot.subscribed());
body.put(
"message",
snapshot.subscribed()
? "Your team has reached its monthly spending cap. Raise the cap to"
+ " continue, or wait for it to reset next billing period."
: "Your team has used its free document allowance."
+ " Subscribe to continue using the API.");
body.put("state", snapshot.state().name());
body.put("spendUnits", snapshot.periodSpendUnits());
body.put("capUnits", snapshot.periodCapUnits());
body.put(
"periodEnd",
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
return false;
}
private boolean write402FeatureDegraded(
HttpServletResponse response, FeatureGate[] required, EntitlementSnapshot snapshot) {
deniedDegradedCounter.increment();
Map<String, Object> body = new LinkedHashMap<>();
body.put("error", "FEATURE_DEGRADED");
body.put("missingGates", missingGates(required, snapshot.enabledGates()));
body.put("state", snapshot.state().name());
body.put(
"periodEnd",
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
body.put("capUnits", snapshot.periodCapUnits());
body.put("spendUnits", snapshot.periodSpendUnits());
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
return false;
}
private static List<String> missingGates(FeatureGate[] required, List<FeatureGate> enabled) {
List<FeatureGate> enabledOrEmpty = enabled == null ? Collections.emptyList() : enabled;
return Arrays.stream(required)
.filter(g -> !enabledOrEmpty.contains(g))
.map(Enum::name)
.toList();
}
private static String inferCategory(FeatureGate[] required) {
// Mirrors PaygChargeInterceptor.determineCategory precedence: AUTOMATION dominates AI.
for (FeatureGate g : required) {
if (g == FeatureGate.AUTOMATION) {
return "AUTOMATION";
}
}
for (FeatureGate g : required) {
if (g == FeatureGate.AI_SUPPORT) {
return "AI";
}
}
return "OFFSITE_PROCESSING";
}
private void writeJson(
HttpServletResponse response, HttpStatus status, Map<String, Object> body) {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
try {
byte[] payload = objectMapper.writeValueAsBytes(body);
response.setHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(payload.length));
response.getOutputStream().write(payload);
response.getOutputStream().flush();
} catch (IOException e) {
// Container will fall back to its default error page — we did set the status code,
// so the client still sees the right HTTP code even if the body fails to write.
log.warn("EntitlementGuard write response body failed", e);
errorsCounter.increment();
}
}
}
@@ -0,0 +1,182 @@
package stirling.software.saas.payg.entitlement;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.cap.CapEvaluator;
import stirling.software.saas.payg.cap.CapEvaluator.Evaluation;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureSet;
import stirling.software.saas.payg.repository.WalletLedgerRepository;
import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.wallet.WalletPolicy;
/**
* Hot-path entitlement lookup. Returns the {@link EntitlementSnapshot} for a team: the billing
* facts (window, free allowance, document cap) come from {@link TeamBillingService}; this service
* layers the period spend (ledger SUM over that window) and the warn/degrade evaluation on top.
*
* <p>Backed by a per-team Caffeine cache with {@value #CACHE_TTL_SECONDS}s TTL and {@value
* #CACHE_MAX_SIZE}-entry cap. The TTL is the correctness floor — a cap change becomes visible on
* every instance within that window without coordination. Mutators (wallet policy admin updates,
* subscription webhook handlers) call {@link #invalidate(Long)} to drop a single team's entry
* immediately on the originating instance.
*/
@Slf4j
@Service
@Profile("saas")
public class EntitlementService {
static final int CACHE_TTL_SECONDS = 30;
private static final int CACHE_MAX_SIZE = 10_000;
private static final int WARN_AT_PCT = 80;
private static final int DEGRADE_AT_PCT = 100;
private final TeamBillingService teamBillingService;
private final WalletPolicyRepository walletPolicyRepository;
private final WalletLedgerRepository ledgerRepository;
private final Cache<Long, EntitlementSnapshot> snapshotCache;
public EntitlementService(
TeamBillingService teamBillingService,
WalletPolicyRepository walletPolicyRepository,
WalletLedgerRepository ledgerRepository) {
this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService");
this.walletPolicyRepository =
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
this.snapshotCache =
Caffeine.newBuilder()
.maximumSize(CACHE_MAX_SIZE)
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
.recordStats()
.build();
}
/**
* Returns the entitlement snapshot for {@code teamId}. Caches per-team for {@value
* #CACHE_TTL_SECONDS}s — burst requests share a single SUM query against the ledger.
*
* <p>{@code null} teamId throws — the guard short-circuits team-less requests upstream so a
* null reach here is a programming error.
*/
public EntitlementSnapshot getSnapshot(Long teamId) {
Objects.requireNonNull(teamId, "teamId");
return snapshotCache.get(teamId, this::computeSnapshot);
}
/**
* Drops {@code teamId}'s cache entry. Call after subscription state changes (webhook handlers),
* cap edits, or manual ledger adjustments so the next read recomputes immediately rather than
* waiting out the TTL. Also drops the underlying billing context so window/cap facts recompute
* together with the spend.
*/
public void invalidate(Long teamId) {
if (teamId != null) {
snapshotCache.invalidate(teamId);
teamBillingService.invalidate(teamId);
}
}
/** Visible for tests. */
long cacheSize() {
return snapshotCache.estimatedSize();
}
@Transactional(readOnly = true)
EntitlementSnapshot computeSnapshot(Long teamId) {
TeamBillingContext billing = teamBillingService.forTeam(teamId);
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
FeatureSet degradedSet =
walletPolicyOpt.map(WalletPolicy::getDegradedFeatureSet).orElse(FeatureSet.MINIMAL);
int warnAtPct =
walletPolicyOpt
.map(WalletPolicy::getWarnAtPct)
.filter(Objects::nonNull)
.orElse(WARN_AT_PCT);
int degradeAtPct =
walletPolicyOpt
.map(WalletPolicy::getDegradeAtPct)
.filter(Objects::nonNull)
.orElse(DEGRADE_AT_PCT);
// Subscription-anchored window when subscribed; calendar month otherwise. Used for the
// subscribed monthly cap + the displayed billing period.
LocalDateTime periodStart = billing.periodStart();
LocalDateTime periodEnd = billing.periodEnd();
Evaluation eval;
long snapshotSpend;
Long snapshotCap;
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.
long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd);
long periodSpend = signedNet < 0 ? -signedNet : 0L;
Long cap = billing.monthlyCapDocUnits();
eval = CapEvaluator.evaluate(periodSpend, cap, warnAtPct, degradeAtPct, degradedSet);
snapshotSpend = periodSpend;
snapshotCap = cap;
} else {
// Unsubscribed: gate on the one-time lifetime free grant. Exhausted (remaining ≤ 0, or
// no grant configured) → DEGRADED so billable categories hard-stop; otherwise evaluate
// the warn/degrade band on used-of-grant.
long grant = billing.freeGrantUnits();
long remaining = billing.freeRemainingUnits();
long used = Math.max(0L, grant - remaining);
if (remaining <= 0L) {
eval =
new Evaluation(
EntitlementState.DEGRADED,
degradedSet,
CapEvaluator.gatesFor(degradedSet));
} else {
eval = CapEvaluator.evaluate(used, grant, warnAtPct, degradeAtPct, degradedSet);
}
snapshotSpend = used;
snapshotCap = grant;
}
return new EntitlementSnapshot(
eval.state(),
eval.featureSet(),
List.copyOf(eval.enabledGates()),
snapshotSpend,
snapshotCap,
periodStart,
periodEnd,
billing.subscribed());
}
/**
* Inclusive-start / exclusive-end window for the calendar-month period. Test seam — takes a
* clock value so tests don't race the calendar boundary. The live snapshot window comes from
* {@link TeamBillingService}; this remains for the forthcoming {@code BILLING_CYCLE} work.
*/
static LocalDateTime[] currentMonthWindow(LocalDateTime now) {
YearMonth ym = YearMonth.from(now);
LocalDateTime start = ym.atDay(1).atStartOfDay();
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
return new LocalDateTime[] {start, end};
}
}
@@ -0,0 +1,46 @@
package stirling.software.saas.payg.entitlement;
import java.time.LocalDateTime;
import java.util.List;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
/**
* Immutable snapshot of a team's entitlement state as of a single point in time. Returned by {@link
* EntitlementService#getSnapshot(Long)} and consumed by {@code EntitlementGuard}.
*
* <p>Contrast with {@link WalletEntitlementSnapshot}: the JPA entity is the <em>persisted</em>
* snapshot that the recompute path writes (one row per team, optionally per member). This record is
* the <em>computed-now</em> view the hot-path guard reads — backed by a 30s Caffeine cache so a
* request burst doesn't hammer the ledger SUM.
*
* @param state aggregate state — FULL, WARNED, or DEGRADED.
* @param featureSet bundle name in effect (FULL on no-cap / warn band; degraded set on DEGRADED).
* @param enabledGates the gates the guard checks against — request proceeds only if every required
* gate is in this list.
* @param periodSpendUnits sum of debited units in {@code [periodStart, periodEnd)}, in canonical
* doc-units (positive).
* @param periodCapUnits the cap applied — free-tier units for un-subscribed teams, {@code
* wallet_policy.cap_units} for subscribed teams. {@code null} means uncapped.
* @param periodStart inclusive start of the current cap period.
* @param periodEnd exclusive end of the current cap period.
* @param subscribed whether the team has an active PAYG subscription. Drives the messaging when a
* billable call is hard-stopped: an un-subscribed team is told to subscribe; a subscribed team
* that hit its self-set spending cap is told to raise it.
*/
public record EntitlementSnapshot(
EntitlementState state,
FeatureSet featureSet,
List<FeatureGate> enabledGates,
long periodSpendUnits,
Long periodCapUnits,
LocalDateTime periodStart,
LocalDateTime periodEnd,
boolean subscribed) {
public boolean isDegraded() {
return state == EntitlementState.DEGRADED;
}
}
@@ -12,6 +12,7 @@ import java.util.Optional;
import java.util.UUID;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
@@ -37,11 +38,14 @@ import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.charge.ChargeContext;
import stirling.software.saas.payg.charge.ChargeOutcome;
import stirling.software.saas.payg.charge.JobChargeService;
import stirling.software.saas.payg.charge.JobInput;
import stirling.software.saas.payg.job.JobService;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.JobStepStatus;
import stirling.software.saas.payg.model.ProcessType;
@@ -52,10 +56,13 @@ import stirling.software.saas.util.AuthenticationUtils;
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
* work hashing inputs.
*
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping}, reads the parsed multipart parts,
* materialises each input to a {@code TempFile}, and asks {@link JobChargeService#openProcess} to
* open (or join) a process. The resulting {@link ChargeOutcome} plus input temp-files are stashed
* as request attributes for {@code afterCompletion}.
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
* parsed multipart parts, materialises each input to a {@code TempFile}, and asks {@link
* JobChargeService#openProcess} to open (or join) a process. The resulting {@link ChargeOutcome}
* plus input temp-files are stashed as request attributes for {@code afterCompletion}. Routes
* without multipart inputs short-circuit inside {@code doPreHandle} without touching the charge
* service.
*
* <p>{@code afterCompletion}: branches on HTTP status — 2xx hashes the response body for OUTPUT
* lineage; 4xx records a step append for audit; 5xx triggers refund-and-close (OPENED) or
@@ -105,6 +112,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
private final Counter callsOpened;
private final Counter callsJoined;
private final Counter callsShortCircuit;
private final Counter callsBypassed;
private final Counter refundsCounter;
/** preHandle wall-clock per request. Separate from afterCompletion — different populations. */
@@ -144,6 +152,11 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
Counter.builder("payg.filter.calls")
.tag("disposition", "SHORT_CIRCUIT")
.register(meterRegistry);
this.callsBypassed =
Counter.builder("payg.filter.bypassed")
.description(
"Manual UI tool calls that skipped openProcess (BillingCategory.BYPASSED)")
.register(meterRegistry);
this.refundsCounter =
Counter.builder("payg.filter.refunds")
.description("First-step 5xx refunds applied to shadow rows")
@@ -168,13 +181,39 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
if (!properties.isEnabled()) {
return true;
}
if (!(handler instanceof HandlerMethod hm)
|| hm.getMethodAnnotation(AutoJobPostMapping.class) == null) {
if (!(handler instanceof HandlerMethod hm)) {
callsShortCircuit.increment();
return true;
}
// In-scope when the handler carries @AutoJobPostMapping (multipart tool POSTs) OR
// @RequiresFeature (AI controllers, future non-multipart gated routes). Without one of
// these the interceptor short-circuits — admin / info / static routes never run
// determineCategory.
boolean hasAutoJobPostMapping =
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|| AnnotationUtils.findAnnotation(
hm.getBeanType(), AutoJobPostMapping.class)
!= null;
boolean hasRequiresFeature =
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|| AnnotationUtils.findAnnotation(
hm.getBeanType(), RequiresFeature.class)
!= null;
if (!hasAutoJobPostMapping && !hasRequiresFeature) {
callsShortCircuit.increment();
return true;
}
// Bypass fast-path: determine the BillingCategory BEFORE any multipart
// materialisation or openProcess call. Manual UI tool calls (BYPASSED) skip the
// entire ledger/shadow pipeline — no temp files, no DB writes.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
BillingCategory category = determineCategory(hm, request, auth);
if (category == BillingCategory.BYPASSED) {
callsBypassed.increment();
return true;
}
try {
doPreHandle(request);
doPreHandle(request, auth, category);
} catch (RuntimeException e) {
log.warn("PAYG preHandle failed; passing through unbilled", e);
errorsCounter.increment();
@@ -187,8 +226,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
}
}
private void doPreHandle(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
private void doPreHandle(
HttpServletRequest request, Authentication auth, BillingCategory category) {
User currentUser = resolveUser(auth);
if (currentUser == null) {
callsShortCircuit.increment();
@@ -248,7 +287,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
currentUser.getId(),
currentUser.getTeam() == null ? null : currentUser.getTeam().getId(),
determineSource(request, auth),
ProcessType.SINGLE_TOOL);
ProcessType.SINGLE_TOOL,
category);
ChargeOutcome outcome;
try {
@@ -331,12 +371,38 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
}
if (status >= 400) {
// 4xx: customer paid for the attempt. No OUTPUT recording, no refund.
// Still a successful-from-billing-standpoint OPENED process — meter it below.
meterIfOpened(jobId, disposition);
return;
}
// Success: this is the moment the billable work finished, so this is when we tell Stripe.
// Only the OPENED request meters — JOINED follow-up steps (chained tools on the same
// document) added no units and must not re-meter. The process stays OPEN for further
// lineage joins; StaleJobCloser closing it later is a no-op at Stripe thanks to the shared
// idempotency key. metering is best-effort and must never break the response teardown.
meterIfOpened(jobId, disposition);
recordOutputs(request, response, jobId);
}
/**
* Fire the Stripe meter for a just-finished process, but only when this request OPENED it. Runs
* on the request-teardown thread (the response is already flushed to the client); {@code
* meterJobUsage} is best-effort and swallows its own failures, but we still guard here so a
* meter hiccup can't disturb lineage/cleanup that follows.
*/
private void meterIfOpened(UUID jobId, ChargeOutcome.Disposition disposition) {
if (disposition != ChargeOutcome.Disposition.OPENED) {
return;
}
try {
chargeService.meterJobUsage(jobId);
} catch (RuntimeException e) {
log.warn("Meter-on-completion failed for job {}: {}", jobId, e.getMessage());
errorsCounter.increment();
}
}
private void recordOutputs(
HttpServletRequest request, HttpServletResponse response, UUID jobId) {
PaygResponseBodyWrapper wrapper =
@@ -445,6 +511,46 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
return JobSource.WEB;
}
/**
* Resolve the {@link BillingCategory} for this request. Precedence: {@code
* X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION;
* {@code @RequiresFeature(AI_SUPPORT)} → AI; API-key auth → API; otherwise BYPASSED (manual UI
* tool — short-circuited in {@link #preHandle}).
*
* <p>Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION
* dominates AI within a single annotation.
*/
private static BillingCategory determineCategory(
HandlerMethod handler, HttpServletRequest request, Authentication auth) {
String automationHeader = request.getHeader(AUTOMATION_HEADER);
if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) {
return BillingCategory.AUTOMATION;
}
RequiresFeature ann =
AnnotationUtils.findAnnotation(handler.getMethod(), RequiresFeature.class);
if (ann == null) {
ann = AnnotationUtils.findAnnotation(handler.getBeanType(), RequiresFeature.class);
}
if (ann != null) {
boolean ai = false;
for (FeatureGate gate : ann.value()) {
if (gate == FeatureGate.AUTOMATION) {
return BillingCategory.AUTOMATION;
}
if (gate == FeatureGate.AI_SUPPORT) {
ai = true;
}
}
if (ai) {
return BillingCategory.AI;
}
}
if (auth instanceof ApiKeyAuthenticationToken) {
return BillingCategory.API;
}
return BillingCategory.BYPASSED;
}
/**
* Resolves the {@code tool_id} value stored on {@code processing_job_step}. Prefers the route
* pattern (e.g. {@code /api/v1/security/add-password}) over the raw URI so audit rollups
@@ -9,6 +9,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.saas.payg.entitlement.EntitlementGuard;
/**
* Wires the PAYG filter + interceptor into Spring MVC. Two registrations:
*
@@ -28,6 +30,7 @@ import lombok.RequiredArgsConstructor;
public class PaygWebMvcConfig implements WebMvcConfigurer {
private final PaygChargeInterceptor paygChargeInterceptor;
private final EntitlementGuard entitlementGuard;
@Bean
public FilterRegistrationBean<PaygResponseBodyWrapperFilter>
@@ -46,6 +49,16 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
*/
public static final int INTERCEPTOR_ORDER = 1000;
/**
* The entitlement guard runs AFTER the charge interceptor so cap-rejected requests still leave
* the charge interceptor's preHandle state in the consistent open-or-bypassed shape (and so the
* guard's 402 reaches the client without the charge interceptor needing to know about it).
* Spring runs interceptors in registration order on the way in, reverse order on the way out;
* the guard's preHandle short-circuit ({@code return false}) prevents the handler from running
* but Spring still invokes the charge interceptor's afterCompletion to clean up temp files.
*/
public static final int ENTITLEMENT_GUARD_ORDER = 1100;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(paygChargeInterceptor)
@@ -56,5 +69,14 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
"/api/v1/info/**",
"/api/v1/admin/**")
.order(INTERCEPTOR_ORDER);
registry.addInterceptor(entitlementGuard)
.addPathPatterns("/api/**")
.excludePathPatterns(
"/api/v1/credits/**",
"/api/v1/config/**",
"/api/v1/info/**",
"/api/v1/admin/**")
.order(ENTITLEMENT_GUARD_ORDER);
}
}
@@ -1,34 +1,69 @@
package stirling.software.saas.payg.job;
import java.util.List;
import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.charge.JobChargeService;
/**
* Auto-closes {@code OPEN} jobs whose {@code last_step_at} is older than the workflow window. Runs
* every minute. API users never have to call {@code close()} explicitly — this scheduler is the
* safety net.
* safety net, and (for metered teams) the point at which the Stripe meter event is posted.
*
* <p>Each stale job is closed individually through {@link JobChargeService#close(java.util.UUID)}
* rather than {@code JobService.closeStale()} (a bulk status flip). That routing matters: {@code
* JobChargeService.close} registers the {@code afterCommit} hook that posts the billable usage to
* Stripe via {@code PaygMeterReportingService}. A bulk flip would close the rows but never meter
* them — usage would accrue in the wallet ledger yet never reach the customer's invoice.
*
* <p>Per-job transactions + failure isolation: each {@code chargeService.close(id)} runs in its own
* transaction (cross-bean proxied call from this non-transactional scheduled method), so the
* afterCommit meter POST fires once per job and one job's failure can't abort the rest of the
* sweep. The meter event's idempotency key ({@code process:<id>:close}) makes a re-run on the next
* tick safe even if a close half-completed.
*
* <p>Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other
* {@code @Scheduled} tasks in {@code :saas} (none of them are guarded against multi-pod
* double-fires today either). Multi-pod cluster-correctness for all schedulers is tracked in design
* § 9 as a separate cleanup. The underlying {@code closeStale()} call is idempotent — duplicate
* firings read an empty stale set on the second pod, no data corruption risk.
* {@code @Scheduled} tasks in {@code :saas}. Multi-pod cluster-correctness for all schedulers is
* tracked in design § 9 as a separate cleanup; the per-job close + meter idempotency key mean a
* double-fire across pods reads a shrinking stale set and never double-bills.
*/
@Component
@Profile("saas")
@RequiredArgsConstructor
@Slf4j
public class StaleJobCloser {
private final JobService jobService;
private final JobChargeService chargeService;
public StaleJobCloser(JobService jobService, JobChargeService chargeService) {
this.jobService = jobService;
this.chargeService = chargeService;
}
@Scheduled(fixedRateString = "${payg.job.stale-close-interval-ms:60000}")
public void closeStale() {
int closed = jobService.closeStale();
List<ProcessingJob> stale = jobService.findStale();
if (stale.isEmpty()) {
return;
}
int closed = 0;
for (ProcessingJob job : stale) {
try {
// Routes through the charge service so the afterCommit meter hook fires for
// metered teams. Idempotent: a job already closed by a racing tick no-ops.
chargeService.close(job.getId());
closed++;
} catch (RuntimeException e) {
// Isolate per job — a single bad row (or a transient meter-path issue) must not
// strand the rest of the stale set open. Next tick retries.
log.warn("StaleJobCloser failed to close job {}: {}", job.getId(), e.getMessage());
}
}
if (closed > 0) {
log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed);
}
@@ -0,0 +1,69 @@
package stirling.software.saas.payg.meter;
import java.time.LocalDateTime;
import java.util.UUID;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Backend-side audit row for one Stripe meter-event POST attempt ({@code payg_meter_event_log},
* V15). A row is written <em>pending</em> ({@code posted_to_stripe_at} NULL) just before the POST
* and stamped on success; a failed POST leaves it unposted with the Stripe error captured. Rows
* still unposted after a short delay are retried by {@link PaygMeterReconcileScheduler} — this is
* the durability mechanism behind the fail-open meter path, so a Stripe blip never silently
* under-bills.
*
* <p>{@code idempotency_key} is UNIQUE and identical to the key sent to Stripe ({@code
* process:<jobId>:close}); the unique constraint gives safe at-least-once semantics across the dual
* meter triggers (completion + stale-close) and reconcile retries.
*/
@Entity
@Table(name = "payg_meter_event_log")
@Getter
@Setter
@NoArgsConstructor
public class PaygMeterEventLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "event_id")
private Long eventId;
@Column(name = "team_id", nullable = false)
private Long teamId;
@Column(name = "job_id")
private UUID jobId;
@Column(name = "idempotency_key", nullable = false, unique = true, length = 128)
private String idempotencyKey;
@Column(name = "units", nullable = false)
private Integer units;
/**
* Insert time; the DB column defaults to {@code CURRENT_TIMESTAMP} (set by {@code
* insertPending}).
*/
@Column(name = "occurred_at", nullable = false, insertable = false, updatable = false)
private LocalDateTime occurredAt;
/** NULL while pending; stamped when the meter-payg-units edge fn returns success. */
@Column(name = "posted_to_stripe_at")
private LocalDateTime postedToStripeAt;
@Column(name = "stripe_error_code", length = 64)
private String stripeErrorCode;
@Column(name = "stripe_error_body", columnDefinition = "text")
private String stripeErrorBody;
}
@@ -0,0 +1,139 @@
package stirling.software.saas.payg.meter;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
/**
* Retries PAYG meter events that were logged but never confirmed posted to Stripe — the durability
* half of the fail-open meter path. {@link PaygMeterReportingService} writes a pending {@code
* payg_meter_event_log} row before each POST and stamps it on success; anything left unposted
* (Stripe blip, pod crash between POST and stamp, edge-fn outage) is picked up here and re-sent
* under the <em>same</em> idempotency key, so Stripe dedups rather than double-charging.
*
* <p>Only retries rows inside Stripe's 24h idempotency window — past that a same-key retry is no
* longer guaranteed to dedup, so stuck rows are logged for manual reconciliation rather than
* risking a double charge. Skips teams that have since unsubscribed (nothing to bill). Like {@link
* stirling.software.saas.payg.lineage.LineagePruneScheduler} it is not {@code @SchedulerLock}'d: a
* duplicate firing on a multi-pod deploy re-sends the same keys, which dedup at Stripe —
* idempotent, wasted IO at worst.
*/
@Component
@Profile("saas")
@Slf4j
public class PaygMeterReconcileScheduler {
/** Stripe's meter-event idempotency window — a same-key retry past this may double-charge. */
private static final Duration STRIPE_IDEMPOTENCY_WINDOW = Duration.ofHours(24);
private final PaygMeterEventLogRepository eventLogRepository;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final PaygMeterReportingService meterReportingService;
private final boolean enabled;
private final Duration retryDelay;
private final int batchSize;
private final Counter retriedCounter;
public PaygMeterReconcileScheduler(
PaygMeterEventLogRepository eventLogRepository,
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService,
@Value("${payg.meter.reconcile.enabled:true}") boolean enabled,
@Value("${payg.meter.reconcile.retry-delay:PT5M}") Duration retryDelay,
@Value("${payg.meter.reconcile.batch-size:100}") int batchSize,
MeterRegistry meterRegistry) {
this.eventLogRepository = Objects.requireNonNull(eventLogRepository, "eventLogRepository");
this.teamExtensionsRepository =
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
this.meterReportingService =
Objects.requireNonNull(meterReportingService, "meterReportingService");
this.enabled = enabled;
this.retryDelay = Objects.requireNonNull(retryDelay, "retryDelay");
this.batchSize = batchSize > 0 ? batchSize : 100;
this.retriedCounter =
Counter.builder("payg.meter.reconcile.retried")
.description("PAYG meter events re-posted to Stripe by the reconcile job")
.register(meterRegistry);
}
@Scheduled(cron = "${payg.meter.reconcile-cron:0 */15 * * * *}", zone = "UTC")
public void reconcile() {
if (!enabled) {
return;
}
LocalDateTime now = LocalDateTime.now();
// Give the live POST a moment to land before retrying; stay inside the 24h dedup window.
LocalDateTime cutoff = now.minus(retryDelay);
LocalDateTime floor = now.minus(STRIPE_IDEMPOTENCY_WINDOW);
List<PaygMeterEventLog> retryable =
eventLogRepository.findRetryable(cutoff, floor, PageRequest.of(0, batchSize));
// Batch-fetch this page's team extensions in one query (keyed by team id) rather than a
// findById per row — avoids an N+1 when the page spans several teams.
List<Long> teamIds =
retryable.stream().map(PaygMeterEventLog::getTeamId).distinct().toList();
Map<Long, PaygTeamExtensions> extById =
teamExtensionsRepository.findAllById(teamIds).stream()
.collect(Collectors.toMap(PaygTeamExtensions::getTeamId, ext -> ext));
int retried = 0;
for (PaygMeterEventLog row : retryable) {
PaygTeamExtensions ext = extById.get(row.getTeamId());
if (ext == null) {
continue;
}
String subscriptionId = ext.getPaygSubscriptionId();
String stripeCustomerId = ext.getStripeCustomerId();
if (subscriptionId == null
|| subscriptionId.isBlank()
|| stripeCustomerId == null
|| stripeCustomerId.isBlank()) {
// Team unsubscribed since the event was logged — nothing to bill; leave the row.
continue;
}
// Same idempotency key → Stripe dedups if the original actually landed. recordUsage
// re-inserts pending as a no-op, re-POSTs, and stamps the row on success. Category is
// not re-derived (analytics metadata only); units + key are what bill.
meterReportingService.recordUsage(
row.getTeamId(),
stripeCustomerId,
row.getUnits() == null ? 0 : row.getUnits(),
null,
row.getIdempotencyKey(),
row.getJobId());
retried++;
}
if (retried > 0) {
retriedCounter.increment(retried);
log.info("PaygMeterReconcileScheduler retried {} unposted meter event(s).", retried);
}
long stuck = eventLogRepository.countStuck(floor);
if (stuck > 0) {
log.warn(
"{} PAYG meter event(s) stuck unposted past Stripe's {}h idempotency window —"
+ " manual reconciliation needed.",
stuck,
STRIPE_IDEMPOTENCY_WINDOW.toHours());
}
}
}
@@ -0,0 +1,221 @@
package stirling.software.saas.payg.meter;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
/**
* POSTs PAYG billable usage to the Supabase {@code meter-payg-units} edge function. Called from
* {@code JobChargeService.close()} in an {@code afterCommit} hook, so the wallet ledger DEBIT (the
* customer's authoritative bill) is already durable before we tell Stripe about it.
*
* <p>Stripe is on a single flat-priced meter forever. {@link BillingCategory} ships as metadata for
* analytics — pricing never reads it. Free-tier teams (no Stripe subscription) skip this call
* entirely; the ledger entry is the only record needed.
*
* <p>Failure mode: we owe Stripe an event but the customer's bill via the ledger is correct. Log
* WARN, bump {@code payg.meter.errors}, and swallow — durability comes from the {@code
* payg_meter_event_log} row written around every attempt (pending → posted/failed) and {@link
* PaygMeterReconcileScheduler}, which retries unposted rows, not from retries here. Caller's {@code
* close()} must not roll back because Stripe wobbled.
*
* <p>Both config keys default to empty so unit tests / local dev never crash on missing env. When
* blank, this service no-ops at WARN-debug level — useful for SaaS smoke tests that don't want to
* touch the real edge function.
*/
@Service
@Profile("saas")
@Slf4j
public class PaygMeterReportingService {
private final String endpoint;
private final String authToken;
private final RestTemplate restTemplate;
private final PaygMeterEventLogRepository eventLogRepository;
private final Counter errorsCounter;
/** Stripe error bodies can be large; the column is TEXT but we cap to keep rows sane. */
private static final int MAX_ERROR_BODY = 4000;
public PaygMeterReportingService(
@Value("${payg.meter.endpoint:}") String endpoint,
@Value("${payg.meter.auth-token:}") String authToken,
RestTemplate saasRestTemplate,
PaygMeterEventLogRepository eventLogRepository,
MeterRegistry meterRegistry) {
this.endpoint = endpoint;
this.authToken = authToken;
this.restTemplate = saasRestTemplate;
this.eventLogRepository = eventLogRepository;
this.errorsCounter =
Counter.builder("payg.meter.errors")
.description("Failures POSTing PAYG meter events to Supabase edge function")
.register(meterRegistry);
}
/**
* Best-effort POST of a single billable event, wrapped in a durable audit row. Idempotency on
* the Supabase side is keyed on {@code idempotency_key} — supply a deterministic value (e.g.
* {@code "process:<uuid>:close"}) so a retry, a reconcile replay, or a double-fire from two
* pods never charges twice.
*
* <p>Flow: write a pending {@code payg_meter_event_log} row (idempotent), POST to the edge fn,
* then stamp the row posted or record the Stripe error. The row is what {@link
* PaygMeterReconcileScheduler} retries, so a failed POST is recoverable rather than silently
* dropped.
*
* <p>Never throws. The wallet ledger entry is the source of truth for what the customer is
* billed; if the POST fails the only loss is that Stripe doesn't see this event until reconcile
* retries it.
*/
public void recordUsage(
Long teamId,
String stripeCustomerId,
int units,
BillingCategory category,
String idempotencyKey,
UUID jobId) {
if (endpoint == null || endpoint.isBlank()) {
log.debug(
"payg.meter.endpoint not configured; skipping meter event for team {} key {}",
teamId,
idempotencyKey);
return;
}
if (units <= 0) {
// Zero-unit events would inflate event count without changing the bill — defensive.
log.debug(
"Skipping meter event with units={} for team {} key {}",
units,
teamId,
idempotencyKey);
return;
}
// Durable pending row before the POST so a failure leaves a record the reconcile scheduler
// can retry. Idempotent insert (ON CONFLICT DO NOTHING) — the completion + stale-close
// triggers and reconcile retries all share the key. Best-effort: a logging failure must
// never stop us from actually metering.
try {
eventLogRepository.insertPending(teamId, jobId, idempotencyKey, units);
} catch (Exception e) {
log.warn(
"payg_meter_event_log pending insert failed for key {} (still metering): {}",
idempotencyKey,
e.getMessage());
}
PostOutcome outcome =
postToStripe(teamId, stripeCustomerId, units, category, idempotencyKey);
try {
if (outcome.success()) {
eventLogRepository.markPosted(idempotencyKey);
} else {
eventLogRepository.markFailed(
idempotencyKey, outcome.errorCode(), outcome.errorBody());
}
} catch (Exception e) {
log.warn(
"payg_meter_event_log result update failed for key {}: {}",
idempotencyKey,
e.getMessage());
}
}
/**
* POST the event to the edge fn. Never throws; returns success / the captured Stripe error.
* Increments {@code payg.meter.errors} on any non-2xx or exception (unchanged metric contract).
*/
private PostOutcome postToStripe(
Long teamId,
String stripeCustomerId,
int units,
BillingCategory category,
String idempotencyKey) {
try {
HttpHeaders headers = new HttpHeaders();
if (authToken != null && !authToken.isBlank()) {
headers.setBearerAuth(authToken);
}
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body =
Map.of(
"team_id",
// JSON number — the edge fn type-checks and ignores strings.
teamId == null ? -1L : teamId,
"stripe_customer_id",
stripeCustomerId == null ? "" : stripeCustomerId,
"units",
units,
"idempotency_key",
idempotencyKey,
"metadata",
Map.of("category", category == null ? "UNKNOWN" : category.name()));
ResponseEntity<String> response =
restTemplate.exchange(
endpoint,
HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
if (response.getStatusCode().is2xxSuccessful()) {
return PostOutcome.ok();
}
log.warn(
"Meter event POST returned {} for team {} key {}: {}",
response.getStatusCode(),
teamId,
idempotencyKey,
response.getBody());
errorsCounter.increment();
return PostOutcome.error(
String.valueOf(response.getStatusCode().value()), response.getBody());
} catch (Exception e) {
// Catch-all by design: this method MUST NOT propagate. The customer's bill via the
// ledger is correct; we just owe Stripe an event the reconcile scheduler will retry.
log.warn(
"Meter event POST failed for team {} key {}: {}",
teamId,
idempotencyKey,
e.getMessage());
errorsCounter.increment();
return PostOutcome.error("exception", e.getMessage());
}
}
/** Outcome of one edge-fn POST attempt. */
private record PostOutcome(boolean success, String errorCode, String errorBody) {
static PostOutcome ok() {
return new PostOutcome(true, null, null);
}
static PostOutcome error(String code, String body) {
String trimmedCode = code != null && code.length() > 64 ? code.substring(0, 64) : code;
String trimmedBody =
body != null && body.length() > MAX_ERROR_BODY
? body.substring(0, MAX_ERROR_BODY)
: body;
return new PostOutcome(false, trimmedCode, trimmedBody);
}
}
}
@@ -0,0 +1,18 @@
package stirling.software.saas.payg.model;
/**
* Analytics / in-app breakdown axis stamped on every billable ledger entry and shadow charge. PAYG
* stays on a single flat-priced Stripe meter — category is metadata only, never affects pricing.
*
* <p>Precedence at translation time (interceptor): AUTOMATION → AI → API → BYPASSED. {@link
* #BYPASSED} is the default for manual UI tool calls that never hit a billable code path.
*
* <p>Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream
* relies on {@code ordinal()}.
*/
public enum BillingCategory {
BYPASSED,
API,
AI,
AUTOMATION
}
@@ -71,6 +71,16 @@ public class PaygTeamExtensions implements Serializable {
@Column(name = "payg_subscription_id", unique = true, length = 128)
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.
*/
@Column(name = "free_units_remaining", nullable = false)
private Long freeUnitsRemaining = 0L;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@@ -74,18 +74,14 @@ public class PricingPolicy implements Serializable {
private Integer fileUnitCap = 1000;
/**
* Free-tier allowance — doc units a team on this policy can consume per cycle before they must
* add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their
* very first tool call until they pay. New-signup defaults will set this to a sensible positive
* value; the special launch policy used by the day-1 legacy migration script can override with
* a different size if product wants the original 10 customers to feel special.
*
* <p>Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code
* payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass
* this check entirely.
* 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.
*/
@Column(name = "free_tier_units_per_cycle", nullable = false)
private Long freeTierUnitsPerCycle = 0L;
@Column(name = "free_tier_units", nullable = false)
private Long freeTierUnits = 0L;
/**
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
@@ -0,0 +1,81 @@
package stirling.software.saas.payg.repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.saas.payg.meter.PaygMeterEventLog;
@Repository
public interface PaygMeterEventLogRepository extends JpaRepository<PaygMeterEventLog, Long> {
/**
* Idempotent pending insert. The two meter triggers (charge-completion + stale-close) share the
* idempotency key, and a reconcile retry re-runs the same path, so {@code ON CONFLICT DO
* NOTHING} keeps the audit row at exactly one per key. {@code occurred_at} defaults to {@code
* CURRENT_TIMESTAMP} at the DB; {@code posted_to_stripe_at} stays NULL until success.
* Transactional because it's called from the non-transactional meter-reporting path.
*/
@Transactional
@Modifying
@Query(
value =
"INSERT INTO payg_meter_event_log (team_id, job_id, idempotency_key, units)"
+ " VALUES (:teamId, :jobId, :key, :units)"
+ " ON CONFLICT (idempotency_key) DO NOTHING",
nativeQuery = true)
void insertPending(
@Param("teamId") Long teamId,
@Param("jobId") UUID jobId,
@Param("key") String key,
@Param("units") int units);
/** Stamp an event posted; clears any prior error. No-op if already posted. */
@Transactional
@Modifying
@Query(
"UPDATE PaygMeterEventLog e"
+ " SET e.postedToStripeAt = CURRENT_TIMESTAMP,"
+ " e.stripeErrorCode = NULL, e.stripeErrorBody = NULL"
+ " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL")
int markPosted(@Param("key") String key);
/** Record the latest Stripe error against a still-pending event. */
@Transactional
@Modifying
@Query(
"UPDATE PaygMeterEventLog e"
+ " SET e.stripeErrorCode = :code, e.stripeErrorBody = :body"
+ " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL")
int markFailed(
@Param("key") String key, @Param("code") String code, @Param("body") String body);
/**
* Events still unposted whose attempt is older than {@code cutoff} (give the live POST time to
* land first) but within {@code floor} — Stripe's 24h idempotency window — so a retry under the
* same key safely dedups rather than risking a double charge. Oldest first.
*/
@Query(
"SELECT e FROM PaygMeterEventLog e"
+ " WHERE e.postedToStripeAt IS NULL"
+ " AND e.occurredAt < :cutoff AND e.occurredAt >= :floor"
+ " ORDER BY e.occurredAt ASC")
List<PaygMeterEventLog> findRetryable(
@Param("cutoff") LocalDateTime cutoff,
@Param("floor") LocalDateTime floor,
Pageable pageable);
/** Events stuck unposted past the safe retry window — surfaced for manual reconciliation. */
@Query(
"SELECT COUNT(e) FROM PaygMeterEventLog e"
+ " WHERE e.postedToStripeAt IS NULL AND e.occurredAt < :floor")
long countStuck(@Param("floor") LocalDateTime floor);
}
@@ -29,4 +29,20 @@ public interface PaygShadowChargeRepository extends JpaRepository<PaygShadowChar
* defensively if a duplicate ever appears.
*/
Optional<PaygShadowCharge> findFirstByJobIdOrderByIdAsc(UUID jobId);
/**
* Paid (Stripe-metered) documents for a team in a period: {@code SUM(payg_units
* free_units_consumed)} over CHARGED rows. This is exactly what was reported to Stripe in the
* window, so the wallet's "estimated bill so far" is the metered total × rate. REFUNDED rows
* are excluded.
*/
@Query(
"SELECT COALESCE(SUM(s.paygUnits - s.freeUnitsConsumed), 0) FROM PaygShadowCharge s"
+ " WHERE s.teamId = :teamId"
+ " AND s.status = stirling.software.saas.payg.model.ShadowChargeStatus.CHARGED"
+ " AND s.occurredAt >= :from AND s.occurredAt < :to")
long sumPaidUnits(
@Param("teamId") Long teamId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
}
@@ -3,12 +3,40 @@ package stirling.software.saas.payg.repository;
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;
import jakarta.persistence.LockModeType;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
@Repository
public interface PaygTeamExtensionsRepository extends JpaRepository<PaygTeamExtensions, Long> {
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.
*/
@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);
}
@@ -14,7 +14,30 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry;
@Repository
public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry, Long> {
List<WalletLedgerEntry> findByTeamIdOrderByOccurredAtDesc(Long teamId);
/** Most recent entries for the Plan page activity feed. */
List<WalletLedgerEntry> findTop20ByTeamIdOrderByIdDesc(Long teamId);
/**
* Per-category debit totals over an arbitrary window, as positive units. Replaces the
* calendar-month {@code wallet_category_summary} view on the wallet endpoint — subscribed
* teams' billing windows are anchored to the Stripe subscription period, not month starts. Rows
* with {@code NULL} category (system entries) are excluded; BYPASSED never reaches the ledger
* by construction.
*/
@Query(
"SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units"
+ " FROM WalletLedgerEntry e"
+ " WHERE e.teamId = :teamId"
+ " AND e.entryType = :entryType"
+ " AND e.billingCategory IS NOT NULL"
+ " AND e.occurredAt >= :periodStart"
+ " AND e.occurredAt < :periodEnd"
+ " GROUP BY e.billingCategory")
List<Object[]> sumPeriodAmountByCategory(
@Param("teamId") Long teamId,
@Param("entryType") LedgerEntryType entryType,
@Param("periodStart") LocalDateTime periodStart,
@Param("periodEnd") LocalDateTime periodEnd);
/** Sum of signed amounts over a team's entries — the wallet's current balance in units. */
@Query(
@@ -34,6 +57,24 @@ public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry,
@Param("periodStart") LocalDateTime periodStart,
@Param("periodEnd") LocalDateTime periodEnd);
/**
* Net signed period balance over billable entries (DEBIT negative + REFUND positive). Negate
* for positive spend. Unlike {@link #sumPeriodAmount} (DEBIT only) this nets refunds, so a
* refunded job no longer reads as spent — the headline period-spend figure for the subscribed
* monthly bill + cap.
*/
@Query(
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
+ " WHERE e.teamId = :teamId"
+ " AND e.entryType IN (stirling.software.saas.payg.model.LedgerEntryType.DEBIT,"
+ " stirling.software.saas.payg.model.LedgerEntryType.REFUND)"
+ " AND e.occurredAt >= :periodStart"
+ " AND e.occurredAt < :periodEnd")
long sumPeriodNetBillable(
@Param("teamId") Long teamId,
@Param("periodStart") LocalDateTime periodStart,
@Param("periodEnd") LocalDateTime periodEnd);
/** Per-member period spend (only when the member has a sub-cap configured). */
@Query(
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
@@ -19,6 +19,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ShadowChargeStatus;
/**
@@ -56,6 +58,15 @@ public class PaygShadowCharge implements Serializable {
@Column(name = "payg_units", nullable = false)
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).
*/
@Column(name = "free_units_consumed", nullable = false)
private Integer freeUnitsConsumed = 0;
@Column(name = "legacy_credits_charged", nullable = false)
private Integer legacyCreditsCharged;
@@ -75,6 +86,24 @@ public class PaygShadowCharge implements Serializable {
@Column(name = "refund_reason", length = 128)
private String refundReason;
/**
* PAYG analytics axis copied from the request that produced this shadow row. {@code null} for
* pre-V16 rows; populated by the charge interceptor going forward. Never affects what Stripe
* would meter — the flat-meter assumption holds, this is breakdown metadata.
*/
@Enumerated(EnumType.STRING)
@Column(name = "billing_category", length = 16)
private BillingCategory billingCategory;
/**
* Caller surface that originated the job (copied from {@code processing_job.source} at write
* time so the row stays self-describing after the job table is pruned). {@code null} for
* pre-V16 rows backfilled when {@code processing_job} is no longer present.
*/
@Enumerated(EnumType.STRING)
@Column(name = "job_source", length = 32)
private JobSource jobSource;
@CreationTimestamp
@Column(name = "occurred_at", nullable = false, updatable = false)
private LocalDateTime occurredAt;
@@ -0,0 +1,215 @@
package stirling.software.saas.payg.stripe;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import lombok.extern.slf4j.Slf4j;
/**
* Read-only accessor for the Stripe Sync Engine schema ({@code stripe.*}). Gives the PAYG layer the
* team's real billing window and the per-document rate of the Price its subscription bills against
* — both live in Stripe, mirrored into Postgres by the sync engine, and are NOT duplicated in
* {@code stirling_pdf} (design §10: 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
* {@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.
*
* <p>Defensive by construction: the {@code stripe} schema only exists where the sync engine has run
* (dev + prod Supabase, not unit-test H2). Any {@link DataAccessException} — missing schema,
* missing row, connectivity blip — degrades to {@link Optional#empty()} with a WARN so callers fall
* back to calendar-month windows rather than 500ing the wallet endpoint.
*/
@Slf4j
@Repository
@Profile("saas")
public class StripeSubscriptionDao {
/**
* Billing window + per-document rate of one subscription. Epoch seconds come back from sync
* engine as INTEGER columns; converted to {@link LocalDateTime} in the system zone so they
* compare cleanly against {@code wallet_ledger.occurred_at} (written by
* {@code @CreationTimestamp} with JVM-local semantics).
*
* @param priceId the Stripe Price the subscription's (sole) item bills against; null if the
* item row hasn't synced yet
* @param currency lower-case ISO 4217 of that Price; null when the price row is missing
* @param perDocMinor per-document rate in minor units (may be fractional via {@code
* unit_amount_decimal}); null when the price row is missing or carries no usable amount
* (e.g. a tiered price, which PAYG doesn't use)
*/
public record SubscriptionBilling(
LocalDateTime periodStart,
LocalDateTime periodEnd,
String priceId,
String status,
String currency,
BigDecimal perDocMinor) {}
/**
* The per-document rate of a Price looked up directly (not via a subscription) — used to price
* the cap estimate for un-subscribed teams, whose default policy points at Stripe Prices that
* carry the same {@code unit_amount} they'd be billed at on subscribing.
*
* @param priceId the resolved Stripe Price id
* @param currency lower-case ISO 4217 of that Price
* @param perDocMinor per-document rate in minor units (may be fractional); never null — a row
* with no usable amount is filtered out rather than returned with a null rate
*/
public record PriceRate(String priceId, String currency, BigDecimal perDocMinor) {}
private static final String QUERY =
"SELECT s.current_period_start, s.current_period_end, s.status::text AS status,"
+ " p.id AS price_id, p.currency AS currency,"
+ " p.unit_amount AS unit_amount, p.unit_amount_decimal AS unit_amount_decimal"
+ " FROM stripe.subscriptions s"
+ " LEFT JOIN LATERAL ("
+ " SELECT si.price FROM stripe.subscription_items si"
+ " WHERE si.subscription = s.id AND COALESCE(si.deleted, false) = false"
+ " ORDER BY si.created DESC NULLS LAST LIMIT 1"
+ " ) item ON true"
+ " LEFT JOIN stripe.prices p ON p.id = item.price"
+ " WHERE s.id = ?";
private final JdbcTemplate jdbcTemplate;
public StripeSubscriptionDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
}
/** Billing window + rate for {@code subscriptionId}; empty if unsynced or schema absent. */
public Optional<SubscriptionBilling> findBilling(String subscriptionId) {
if (subscriptionId == null || subscriptionId.isBlank()) {
return Optional.empty();
}
try {
List<SubscriptionBilling> rows =
jdbcTemplate.query(
QUERY,
(rs, i) -> {
long startEpoch = rs.getLong("current_period_start");
boolean startNull = rs.wasNull();
long endEpoch = rs.getLong("current_period_end");
boolean endNull = rs.wasNull();
if (startNull || endNull) {
return null;
}
return new SubscriptionBilling(
toLocal(startEpoch),
toLocal(endEpoch),
rs.getString("price_id"),
rs.getString("status"),
rs.getString("currency"),
extractRate(rs));
},
subscriptionId);
return rows.stream().filter(Objects::nonNull).findFirst();
} catch (DataAccessException e) {
// Missing stripe schema (sync engine not provisioned) or transient DB issue. The
// caller falls back to a calendar-month window; usage still accrues correctly.
log.warn(
"stripe.subscriptions lookup failed for {}: {}",
subscriptionId,
e.getMessage());
return Optional.empty();
}
}
/**
* Per-document rate of the active {@code stripe.prices} row with the given {@code lookupKey} in
* {@code currency} — the elegant mirror of {@link #findBilling}, reading the same synced table
* by Stripe Price {@code lookup_key} instead of via a subscription. The PAYG layer uses this to
* price the cap estimate for an un-subscribed team: there's no subscription to read a rate off,
* but the PAYG Price (lookup key {@code plan:processor}) carries the very rate they'd be billed
* at. We resolve by lookup_key rather than the default policy's price ids because those aren't
* seeded — the lookup key is the stable, env-agnostic handle (same one the price-lookup edge
* function uses).
*
* <p>Empty when the {@code stripe} schema is absent (H2 unit tests), no active matching row
* exists, or the row carries no usable per-unit amount (e.g. a tiered price, which PAYG doesn't
* use). Callers degrade to "no estimate" exactly as the subscribed path degrades to a
* calendar-month window.
*/
public Optional<PriceRate> findRateByLookupKey(String lookupKey, String currency) {
if (lookupKey == null || lookupKey.isBlank() || currency == null || currency.isBlank()) {
return Optional.empty();
}
String sql =
"SELECT p.id AS price_id, p.currency AS currency,"
+ " p.unit_amount AS unit_amount,"
+ " p.unit_amount_decimal AS unit_amount_decimal"
+ " FROM stripe.prices p"
+ " WHERE p.lookup_key = ? AND p.currency = ?"
+ " AND COALESCE(p.active, true) = true"
+ " ORDER BY p.created DESC NULLS LAST LIMIT 1";
try {
List<PriceRate> rows =
jdbcTemplate.query(
sql,
(rs, i) -> {
BigDecimal rate = extractRate(rs);
if (rate == null) {
return null;
}
return new PriceRate(
rs.getString("price_id"), rs.getString("currency"), rate);
},
lookupKey,
currency.toLowerCase());
return rows.stream().filter(Objects::nonNull).findFirst();
} catch (DataAccessException e) {
log.warn(
"stripe.prices rate lookup failed for lookup_key {} / currency {}: {}",
lookupKey,
currency,
e.getMessage());
return Optional.empty();
}
}
/**
* Reads the per-document rate off a {@code stripe.prices} row. Prefers {@code
* unit_amount_decimal} (sub-minor-unit precision, e.g. half-cent rates), falls back to the
* integer {@code unit_amount}, and returns null when neither is usable or the amount is ≤ 0
* (tiered/zero prices PAYG doesn't bill on). Both queries alias the columns identically so this
* is shared verbatim.
*/
private static BigDecimal extractRate(ResultSet rs) throws SQLException {
BigDecimal rate = null;
String decimal = rs.getString("unit_amount_decimal");
if (decimal != null && !decimal.isBlank()) {
try {
rate = new BigDecimal(decimal);
} catch (NumberFormatException ignore) {
rate = null;
}
}
if (rate == null) {
long unitAmount = rs.getLong("unit_amount");
if (!rs.wasNull()) {
rate = BigDecimal.valueOf(unitAmount);
}
}
if (rate != null && rate.signum() <= 0) {
rate = null;
}
return rate;
}
private static LocalDateTime toLocal(long epochSeconds) {
return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
}
}
@@ -22,6 +22,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.model.LedgerBucket;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.model.ReferenceType;
@@ -76,6 +77,15 @@ public class WalletLedgerEntry implements Serializable {
@Column(name = "stripe_event_id", length = 128)
private String stripeEventId;
/**
* PAYG analytics axis. {@code null} for system entries (grants, resets) and pre-V16 rows; set
* by the charge interceptor for billable debits. Stripe pricing never reads this — it's a
* single flat meter — so the column stays soft-typed (no NOT NULL, no FK).
*/
@Enumerated(EnumType.STRING)
@Column(name = "billing_category", length = 16)
private BillingCategory billingCategory;
@CreationTimestamp
@Column(name = "occurred_at", nullable = false, updatable = false)
private LocalDateTime occurredAt;
@@ -42,6 +42,24 @@ public interface TeamMembershipRepository extends JpaRepository<TeamMembership,
@Query("SELECT tm FROM TeamMembership tm JOIN FETCH tm.team WHERE tm.user.id = :userId")
List<TeamMembership> findByUserId(@Param("userId") Long userId);
/**
* Resolve the single membership a user belongs to. In the PAYG design every user is owned by
* exactly one team personal-team-for-new-signups, then optionally migrated when they accept
* an invite (the old personal team is deleted on accept). For diagnostic safety this picks the
* earliest-created row if multiple exist, but in steady state there is exactly one.
*
* <p>Returns both the team and its role so the PAYG wallet endpoint can answer "what does this
* user see?" in a single query rather than a list-then-filter dance.
*
* @param userId the user ID
* @return the user's primary membership, if any
*/
@Query(
"SELECT tm FROM TeamMembership tm JOIN FETCH tm.team"
+ " WHERE tm.user.id = :userId"
+ " ORDER BY tm.createdAt ASC")
List<TeamMembership> findPrimaryMembership(@Param("userId") Long userId);
/**
* Find all members with a specific role in a team
*
@@ -91,7 +91,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
+ " END, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
nativeQuery = true)
int consumeCreditBySupabaseId(
@@ -109,7 +109,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND cycle_credits_remaining >= :amount",
nativeQuery = true)
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
@@ -123,7 +123,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
+ " total_api_calls_made = total_api_calls_made + 1, "
+ " last_api_usage = now() "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
+ " AND bought_credits_remaining >= :amount",
nativeQuery = true)
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
@@ -133,7 +133,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
value =
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
@@ -142,7 +142,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
value =
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
+ "FROM user_credits uc "
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
nativeQuery = true)
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
}
@@ -23,3 +23,10 @@ spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consoli
logging.level.stirling.software.saas=DEBUG
logging.level.org.springframework.security.oauth2.jwt=WARN
logging.level.org.springframework.security.oauth2.server.resource=WARN
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
# shared secret the team-invitation flow uses — no service-role key in the Java env).
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
# The billing portal is NOT here — the FE calls create-customer-portal-session directly.
payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units
@@ -35,6 +35,31 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
# ---------- PAYG meter reporting ----------
# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService
# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set
# PAYG_METER_ENDPOINT in deployed envs to enable. Compose with the Supabase functions base via
# env (e.g. PAYG_METER_ENDPOINT=$SUPABASE_FUNCTIONS_URL/meter-payg-units) rather than templating
# here — concatenating an empty default would produce a half-valid URL.
# Auth rides the existing backend<->edge-fn shared secret (SUPABASE_EDGE_FUNCTION_SECRET, same
# one team-invitation-email uses) — the backend never holds the RLS-bypassing service-role key.
payg.meter.endpoint=${PAYG_METER_ENDPOINT:}
payg.meter.auth-token=${app.supabase.edge-function-secret:}
# Reconcile job: retries meter events logged to payg_meter_event_log but never confirmed posted
# (Stripe blip, edge-fn outage, pod crash mid-POST). Re-sends under the same idempotency key so
# Stripe dedups; only within Stripe's 24h window. Defaults are sensible; tune/disable via env.
payg.meter.reconcile.enabled=${PAYG_METER_RECONCILE_ENABLED:true}
payg.meter.reconcile.retry-delay=${PAYG_METER_RECONCILE_RETRY_DELAY:PT5M}
payg.meter.reconcile.batch-size=${PAYG_METER_RECONCILE_BATCH_SIZE:100}
payg.meter.reconcile-cron=${PAYG_METER_RECONCILE_CRON:0 */15 * * * *}
# ---------- PAYG customer portal ----------
# The Stripe billing-portal session is minted FE-direct: the Plan page calls the Supabase
# `create-customer-portal-session` edge function with the user's JWT (same pattern as checkout),
# and the function's SECURITY DEFINER RPC enforces team membership. No Java proxy, no backend
# config — return_url host allowlisting lives in the edge fn (PORTAL_ALLOWED_RETURN_HOSTS).
supabase.url=https://${app.supabase.project-ref}.supabase.co
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json
@@ -0,0 +1,58 @@
-- PAYG analytics axis: stamp every billable ledger entry / shadow row with the category that
-- produced it (API | AI | AUTOMATION | BYPASSED). PAYG stays on a single flat-priced Stripe meter
-- forever — this column is for in-app breakdowns and analytics, never for Stripe pricing.
--
-- All adds are nullable: pre-V16 rows have no category and stay NULL; the interceptor populates
-- it for new rows going forward.
-- ---------------------------------------------------------------------------------------------
-- 1. wallet_ledger.billing_category
-- ---------------------------------------------------------------------------------------------
ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL;
COMMENT ON COLUMN wallet_ledger.billing_category IS
'API | AI | AUTOMATION | BYPASSED. NULL = system entry or pre-V16 backfill.';
-- Partial index — only billable rows ever read this column, and NULLs would just bloat the tree.
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_category_period
ON wallet_ledger (team_id, billing_category, occurred_at)
WHERE billing_category IS NOT NULL;
-- ---------------------------------------------------------------------------------------------
-- 2. payg_shadow_charge.billing_category + job_source
-- ---------------------------------------------------------------------------------------------
ALTER TABLE payg_shadow_charge
ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL,
ADD COLUMN IF NOT EXISTS job_source VARCHAR(32) NULL;
-- Backfill job_source from processing_job (best-effort — rows whose job has already been pruned
-- stay NULL, which is fine: the shadow row is self-describing post-V16 and only legacy ones lack
-- the column.)
UPDATE payg_shadow_charge sc
SET job_source = pj.source
FROM processing_job pj
WHERE pj.job_id = sc.job_id
AND sc.job_source IS NULL;
-- ---------------------------------------------------------------------------------------------
-- 3. pricing_policy_stripe_price.stripe_product_id
-- Operator populates this manually per row when seeding new policies. Nullable for backward
-- compatibility with existing rows that don't carry a Product reference.
-- ---------------------------------------------------------------------------------------------
ALTER TABLE pricing_policy_stripe_price
ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(128) NULL;
-- ---------------------------------------------------------------------------------------------
-- 4. wallet_category_summary view — pre-grouped per-team, per-month, per-category aggregate that
-- the in-app breakdown widget reads. Recomputed live on every SELECT; cheap thanks to the
-- partial index above.
-- ---------------------------------------------------------------------------------------------
CREATE OR REPLACE VIEW wallet_category_summary AS
SELECT
team_id,
date_trunc('month', occurred_at) AS period_start,
billing_category,
SUM(CASE WHEN amount_units < 0 THEN -amount_units ELSE 0 END) AS units_debited,
COUNT(*) FILTER (WHERE entry_type = 'DEBIT') AS debit_count
FROM wallet_ledger
WHERE billing_category IS NOT NULL
GROUP BY team_id, date_trunc('month', occurred_at), billing_category;
@@ -0,0 +1,34 @@
-- Consolidate users.supabase_id into users.supabase_auth_id.
--
-- The `supabase_auth_id` column is the canonical link to Supabase Auth — it was
-- created by the initial Supabase schema migration (Sep 2025) and is referenced
-- by every RLS policy in the Supabase side of the world (V14's
-- payg_team_ext_select / payg_team_ext_leader_update, the public.payg_*
-- SECURITY DEFINER RPCs, etc.).
--
-- PR #6384 ("SaaS Consolidation") accidentally added a parallel `supabase_id`
-- column via Flyway V2 — same purpose, different name. Java's User entity then
-- mapped to this new column. The result was a split-brain:
-- * Pre-#6384 users had supabase_auth_id populated, supabase_id NULL.
-- * Post-#6384 users had supabase_id populated, supabase_auth_id NULL.
-- * RLS policies + RPCs always check supabase_auth_id, so post-#6384 users
-- failed every membership check.
--
-- This migration:
-- 1. Backfills supabase_auth_id from supabase_id where the former is NULL.
-- 2. Drops the supabase_id column and its unique index.
--
-- The Java User entity has been switched to @Column(name = "supabase_auth_id")
-- in the same change-set; this migration assumes the new code is already
-- deployed (or will be deployed together with this migration).
-- 1. Backfill the canonical column from the duplicate, where needed.
UPDATE users
SET supabase_auth_id = supabase_id
WHERE supabase_auth_id IS NULL
AND supabase_id IS NOT NULL;
-- 2. Drop the duplicate column. IF EXISTS guards against environments where
-- the column was already removed manually.
DROP INDEX IF EXISTS uk_users_supabase_id;
ALTER TABLE users DROP COLUMN IF EXISTS supabase_id;
@@ -0,0 +1,95 @@
-- PAYG free allowance: monthly per-cycle allowance → one-time LIFETIME grant.
--
-- Product decision (2026-06-11): every team gets a one-time free document grant. It does NOT
-- replenish monthly and is NOT lost when the team subscribes — they keep whatever is unused.
--
-- Mechanics: the grant is tracked as a running counter on the team sidecar
-- (payg_team_extensions.free_units_remaining), seeded once from the team's effective pricing
-- policy and maintained by the charge pipeline (deducted when a billable DEBIT is written,
-- restored on a first-step refund). Because the counter is authoritative, the wallet_ledger is
-- no longer the source of truth for the grant and its old rows can be pruned after a retention
-- window (separate future job).
--
-- See notes/payg-lifetime-free-grant-plan-2026-06-11.html.
-- ---------------------------------------------------------------------------------------------
-- 1. Rename the policy column — it is no longer "per cycle", it's the one-time grant size.
-- ---------------------------------------------------------------------------------------------
ALTER TABLE stirling_pdf.pricing_policy
RENAME COLUMN free_tier_units_per_cycle TO free_tier_units;
COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units IS
'One-time lifetime free document grant handed to a team on creation (copied into '
'payg_team_extensions.free_units_remaining). NOT per-cycle: it never replenishes and '
'survives subscribing. 0 = no free grant (block / meter from the first document).';
-- ---------------------------------------------------------------------------------------------
-- 2. The running counter on the team sidecar.
-- ---------------------------------------------------------------------------------------------
ALTER TABLE stirling_pdf.payg_team_extensions
ADD COLUMN IF NOT EXISTS free_units_remaining BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN stirling_pdf.payg_team_extensions.free_units_remaining IS
'Remaining one-time free documents for this team. Seeded from the effective pricing '
'policy''s free_tier_units at row creation; decremented by min(jobUnits, remaining) when a '
'billable charge is written; restored on a first-step refund. Lifetime — never resets. '
'Authoritative source for the free grant (independent of wallet_ledger retention).';
-- ---------------------------------------------------------------------------------------------
-- 3. Per-job free/paid split on the shadow row — makes metering + refunds exact and removes
-- any need to SUM the ledger over a team's lifetime.
-- ---------------------------------------------------------------------------------------------
ALTER TABLE stirling_pdf.payg_shadow_charge
ADD COLUMN IF NOT EXISTS free_units_consumed INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN stirling_pdf.payg_shadow_charge.free_units_consumed IS
'How many of this job''s payg_units came out of the team''s free grant at charge time. '
'Paid (metered) units = payg_units - free_units_consumed. A refund restores this many '
'units to payg_team_extensions.free_units_remaining.';
-- ---------------------------------------------------------------------------------------------
-- 4. Seed the counter at team creation. Replace the V14 trigger function so new teams get the
-- default policy's grant from minute one. (The trigger itself still points at this function.)
-- ---------------------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
INSERT INTO stirling_pdf.payg_team_extensions(team_id, free_units_remaining)
VALUES (
NEW.team_id,
COALESCE(
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
WHERE pp.is_default = TRUE LIMIT 1),
0)
)
ON CONFLICT (team_id) DO NOTHING;
RETURN NEW;
END $$;
-- ---------------------------------------------------------------------------------------------
-- 5. Backfill existing teams. remaining = max(0, grant - lifetime_consumed). lifetime_consumed
-- is -SUM(amount_units) over the team's DEBIT+REFUND ledger entries (debits negative, refunds
-- positive), so grant + SUM(amount_units) collapses to grant - consumed. One-time read of the
-- ledger; after this the counter stands alone. Grant = team override policy, else the default.
-- ---------------------------------------------------------------------------------------------
UPDATE stirling_pdf.payg_team_extensions ext
SET free_units_remaining = GREATEST(
0,
COALESCE(
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
WHERE pp.policy_id = ext.pricing_policy_id),
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
WHERE pp.is_default = TRUE LIMIT 1),
0)
+ COALESCE(
(SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl
WHERE wl.team_id = ext.team_id
AND wl.entry_type IN ('DEBIT', 'REFUND')),
0));
@@ -0,0 +1,42 @@
-- PAYG launch free grant: give the default pricing policy a real one-time grant.
--
-- V14 added pricing_policy.free_tier_units with DEFAULT 0, and the default policy seeded in V12
-- predates the column — so on a fresh deploy every team's free_units_remaining seeds to 0 and
-- V19's "every team gets a one-time free grant" intent ships dead (teams are gated / metered from
-- the very first billable document). This migration sets the launch grant on the default policy
-- and re-seeds existing teams that V19 left at 0 (V19 ran its backfill while the grant was still
-- 0, so every then-existing team computed to 0).
--
-- The launch value lives on the default policy row; tune it there (or via a future admin surface).
-- Both updates are guarded so a deliberately-tuned value — e.g. a smaller test grant — is never
-- clobbered.
-- ---------------------------------------------------------------------------------------------
-- 1. Launch grant on the default policy, only where it's still the accidental 0.
-- ---------------------------------------------------------------------------------------------
UPDATE stirling_pdf.pricing_policy
SET free_tier_units = 500
WHERE is_default = TRUE
AND free_tier_units = 0;
-- ---------------------------------------------------------------------------------------------
-- 2. Re-seed existing teams V19 left at 0. Same recompute as V19's backfill — remaining =
-- max(0, grant + net signed DEBIT/REFUND) — now that the grant is non-zero. Guarded to
-- free_units_remaining = 0: a team with a deliberately-set positive balance is left alone, and
-- a team that genuinely exhausted a real grant also recomputes to 0, so the guard is safe.
-- ---------------------------------------------------------------------------------------------
UPDATE stirling_pdf.payg_team_extensions ext
SET free_units_remaining = GREATEST(
0,
COALESCE(
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
WHERE pp.policy_id = ext.pricing_policy_id),
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
WHERE pp.is_default = TRUE LIMIT 1),
0)
+ COALESCE(
(SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl
WHERE wl.team_id = ext.team_id
AND wl.entry_type IN ('DEBIT', 'REFUND')),
0))
WHERE ext.free_units_remaining = 0;
@@ -0,0 +1,11 @@
-- Drop the unused wallet_category_summary view.
--
-- V16 created this view to back the wallet's per-category spend breakdown via
-- WalletCategorySummaryDao. That DAO was never wired up — the breakdown is built from the JPA
-- repository (WalletLedgerRepository.sumPeriodAmountByCategory) instead — so both the DAO and this
-- view have zero readers. The DAO is deleted in the same change; this drops the dead view.
--
-- Done as a new migration (not by editing V16) so Flyway's checksum validation doesn't fail on
-- databases that already applied V16. IF EXISTS keeps it safe on DBs where V16 hasn't run.
DROP VIEW IF EXISTS stirling_pdf.wallet_category_summary;