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
@@ -50,6 +50,16 @@ public class InternalApiClient {
"^/api/v1/(general|misc|security|convert|filter)(/[A-Za-z0-9_-]+)+$"
+ "|^/api/v1/ai/tools(/[A-Za-z0-9_-]+)+$");
/**
* Marker propagated on every internal sub-step dispatch so the saas PAYG interceptor classifies
* the call as {@code BillingCategory.AUTOMATION}. By construction every {@link
* InternalApiClient#post} caller is an automation surface (pipeline executor, AI workflow,
* policy runner) running a child tool inside a parent automation flow — see the saas {@code
* PaygChargeInterceptor.determineCategory} precedence chain, where this header dominates any
* per-tool {@code @RequiresFeature} annotation.
*/
public static final String AUTOMATION_HEADER = "X-Stirling-Automation";
private final ServletContext servletContext;
private final UserServiceInterface userService;
private final TempFileManager tempFileManager;
@@ -96,6 +106,11 @@ public class InternalApiClient {
if (apiKey != null && !apiKey.isEmpty()) {
headers.add("X-API-KEY", apiKey);
}
// Tag the sub-step as automation so PAYG bills it under AUTOMATION regardless of which
// tool-level @RequiresFeature annotation the dispatched controller carries (e.g. an AI-OCR
// step inside a policy run must bill as AUTOMATION, not AI). Set unconditionally because
// every caller of this dispatcher is an automation surface by design.
headers.add(AUTOMATION_HEADER, "true");
HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class);
@@ -59,6 +59,53 @@ class InternalApiClientTest {
servletContext, userService, tempFileManager, environment, applicationProperties);
}
@Test
void postTagsRequestAsAutomation() throws Exception {
// Every InternalApiClient.post() caller is a parent automation flow dispatching a child
// tool (pipeline executor, AI workflow, policy runner). Tagging the sub-step here means
// the saas PaygChargeInterceptor classifies it as BillingCategory.AUTOMATION regardless of
// the dispatched controller's @RequiresFeature — so an AI-OCR step inside a policy run
// bills as AUTOMATION, not AI. The header value is the literal string "true" because the
// interceptor compares case-insensitively-trimmed against that token.
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("fileInput", namedResource("input.pdf", "data"));
Path tempPath = Files.createTempFile("internal-api-automation-test", ".tmp");
TempFile tempFile = mock(TempFile.class);
when(tempFile.getPath()).thenReturn(tempPath);
when(tempFile.getFile()).thenReturn(tempPath.toFile());
when(tempFileManager.createManagedTempFile("internal-api")).thenReturn(tempFile);
HttpHeaders[] captured = {null};
try (var ignored =
mockConstruction(
RestTemplate.class,
(rt, ctx) -> {
when(rt.httpEntityCallback(any(), eq(Resource.class)))
.thenAnswer(
inv -> {
HttpEntity<?> entity = inv.getArgument(0);
captured[0] = entity.getHeaders();
return (RequestCallback) req -> {};
});
when(rt.execute(anyString(), eq(HttpMethod.POST), any(), any()))
.thenAnswer(inv -> fakeOkResponse(inv.getArgument(3)));
})) {
InternalApiClient mockedClient = newClient();
mockedClient.post("/api/v1/general/merge-pdfs", body);
assertNotNull(captured[0]);
assertEquals(
"true",
captured[0].getFirst(InternalApiClient.AUTOMATION_HEADER),
"Sub-step dispatch must carry the automation marker header");
} finally {
Files.deleteIfExists(tempPath);
}
}
@Test
void postDoesNotForceContentType() throws Exception {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
@@ -87,7 +87,11 @@ public class User implements UserDetails, Serializable {
private String email;
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
@Column(name = "supabase_id", unique = true)
// Column is `supabase_auth_id` (canonical name from the initial Supabase remote
// schema migration). An earlier Flyway V2 (PR #6384) accidentally introduced a
// parallel `supabase_id` column that was used by Java; V17 backfilled and dropped
// it. Field name is kept as `supabaseId` to avoid a wide refactor of callers.
@Column(name = "supabase_auth_id", unique = true)
private UUID supabaseId;
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
@@ -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;
@@ -0,0 +1,489 @@
package stirling.software.saas.payg.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import stirling.software.common.model.enumeration.TeamRole;
import stirling.software.proprietary.model.Team;
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.PaygWalletController.UpdateCapRequest;
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.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
import stirling.software.saas.payg.model.LedgerEntryType;
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.WalletPolicy;
import stirling.software.saas.repository.TeamMembershipRepository;
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
/**
* Pure-Mockito unit tests for {@link PaygWalletController}. Covers the documented role / state
* matrix free vs subscribed, leader vs member, anonymous plus the cap update endpoint's
* leader-only enforcement and cache invalidation.
*/
@ExtendWith(MockitoExtension.class)
class PaygWalletControllerTest {
@Mock private EntitlementService entitlementService;
@Mock private TeamBillingService billingService;
@Mock private TeamMembershipRepository memberRepo;
@Mock private PaygTeamExtensionsRepository extRepo;
@Mock private WalletPolicyRepository policyRepo;
@Mock private WalletLedgerRepository ledgerRepo;
@Mock private PaygShadowChargeRepository shadowRepo;
@Mock private UserRepository userRepository;
private PaygWalletController controller;
@BeforeEach
void setUp() {
controller =
new PaygWalletController(
entitlementService,
billingService,
memberRepo,
extRepo,
policyRepo,
ledgerRepo,
shadowRepo,
userRepository);
}
/**
* Free-team billing context: the one-time grant is fully unused (remaining == grant), no
* subscription facts, no monthly cap. The displayed limit comes from the snapshot, not here.
*/
private static TeamBillingContext freeBilling(long teamFreeGrant) {
LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay();
return new TeamBillingContext(
false,
null,
start,
start.plusMonths(1),
teamFreeGrant,
teamFreeGrant,
null,
null,
null,
null);
}
/**
* Subscribed billing context with a money cap (minor units) and a known per-doc rate. The grant
* is treated as exhausted (remaining 0) typical for a team that has subscribed; {@code
* monthlyCapDocUnits} is the paid-doc ceiling {@code floor(capMoney / rate)}.
*/
private static TeamBillingContext subscribedBilling(
String subscriptionId, Long capMoneyMinor, Long monthlyCapDocUnits) {
LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay();
return new TeamBillingContext(
true,
subscriptionId,
start,
start.plusMonths(1),
500L,
0L,
BigDecimal.valueOf(2),
"usd",
capMoneyMinor,
monthlyCapDocUnits);
}
private void stubEmptyLedgerReads(long teamId) {
when(ledgerRepo.sumPeriodAmountByCategory(
eq(teamId), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(List.of());
when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)).thenReturn(List.of());
}
// -----------------------------------------------------------------------------------------
// GET /wallet
// -----------------------------------------------------------------------------------------
@Test
void getWallet_freeTier_returnsFreeShape() {
User user = userWithId(7L, UUID.randomUUID());
Team team = teamWithId(42L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(7L))
.thenReturn(List.of(membership(team, user, TeamRole.MEMBER)));
when(billingService.forTeam(42L)).thenReturn(freeBilling(500L));
when(entitlementService.getSnapshot(42L)).thenReturn(snapshot(0L, 500L));
stubEmptyLedgerReads(42L);
ResponseEntity<WalletSnapshotResponse> resp =
controller.getWallet(jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
WalletSnapshotResponse body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.status()).isEqualTo("free");
assertThat(body.role()).isEqualTo("member");
assertThat(body.capUsd()).isNull();
assertThat(body.stripeSubscriptionId()).isNull();
assertThat(body.noCap()).isFalse();
assertThat(body.billableUsed()).isZero();
assertThat(body.billableLimit()).isEqualTo(500);
assertThat(body.freeAllowance()).isEqualTo(500);
assertThat(body.pricePerDocMinor()).isNull();
assertThat(body.currency()).isNull();
assertThat(body.estimatedBillMinor()).isNull();
assertThat(body.members()).isEmpty();
assertThat(body.recent()).isEmpty();
assertThat(body.categoryBreakdown().api()).isZero();
assertThat(body.categoryBreakdown().ai()).isZero();
assertThat(body.categoryBreakdown().automation()).isZero();
}
@Test
void getWallet_subscribedMember_returnsCapAndBreakdownFromLedger() {
User user = userWithId(8L, UUID.randomUUID());
Team team = teamWithId(99L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(8L))
.thenReturn(List.of(membership(team, user, TeamRole.MEMBER)));
// $25 cap (2500 minor) at $0.02/doc 1250 paid docs/month (the one-time grant is a
// separate pool, not added to the cap).
when(billingService.forTeam(99L))
.thenReturn(subscribedBilling("sub_test_99", 2500L, 1250L));
// 312 paid (metered) docs this period estimate 312 × $0.02 = $6.24 (624 minor).
when(shadowRepo.sumPaidUnits(eq(99L), any(), any())).thenReturn(312L);
when(billingService.estimateBillMinor(any(), eq(312L))).thenReturn(Optional.of(624L));
when(entitlementService.getSnapshot(99L)).thenReturn(snapshot(312L, 1250L));
when(ledgerRepo.sumPeriodAmountByCategory(eq(99L), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(
List.of(
new Object[] {BillingCategory.API, 110L},
new Object[] {BillingCategory.AI, 200L},
new Object[] {BillingCategory.AUTOMATION, 2L}));
when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(99L)).thenReturn(List.of());
ResponseEntity<WalletSnapshotResponse> resp =
controller.getWallet(jwtAuth(user.getSupabaseId()));
WalletSnapshotResponse body = resp.getBody();
assertThat(body.status()).isEqualTo("subscribed");
assertThat(body.role()).isEqualTo("member");
assertThat(body.capUsd()).isEqualTo(25);
assertThat(body.noCap()).isFalse();
assertThat(body.billableUsed()).isEqualTo(312);
assertThat(body.spendUnitsThisPeriod()).isEqualTo(312);
assertThat(body.billableLimit()).isEqualTo(1250);
assertThat(body.freeAllowance()).isEqualTo(500);
assertThat(body.pricePerDocMinor()).isEqualByComparingTo(BigDecimal.valueOf(2));
assertThat(body.currency()).isEqualTo("usd");
assertThat(body.estimatedBillMinor()).isEqualTo(624L);
assertThat(body.members()).isEmpty();
assertThat(body.categoryBreakdown().api()).isEqualTo(110);
assertThat(body.categoryBreakdown().ai()).isEqualTo(200);
assertThat(body.categoryBreakdown().automation()).isEqualTo(2);
assertThat(body.stripeSubscriptionId()).isEqualTo("sub_test_99");
// Member role ledger never queried per-user.
verify(ledgerRepo, never()).sumPeriodAmountForMember(any(), any(), any(), any(), any());
}
@Test
void getWallet_subscribedNoCap_returnsNoCapTrueAndNullLimit() {
User user = userWithId(9L, UUID.randomUUID());
Team team = teamWithId(11L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(9L))
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
when(billingService.forTeam(11L)).thenReturn(subscribedBilling("sub_nocap", null, null));
when(entitlementService.getSnapshot(11L)).thenReturn(snapshot(50L, null));
stubEmptyLedgerReads(11L);
when(memberRepo.findByTeamId(11L)).thenReturn(List.of());
ResponseEntity<WalletSnapshotResponse> resp =
controller.getWallet(jwtAuth(user.getSupabaseId()));
WalletSnapshotResponse body = resp.getBody();
assertThat(body.status()).isEqualTo("subscribed");
assertThat(body.role()).isEqualTo("leader");
assertThat(body.capUsd()).isNull();
assertThat(body.noCap()).isTrue();
// Uncapped no document ceiling to draw a bar against.
assertThat(body.billableLimit()).isNull();
}
@Test
void getWallet_leader_populatesMembers() {
User leader = userWithId(10L, UUID.randomUUID());
User member = userWithId(11L, UUID.randomUUID());
member.setUsername("alice");
member.setEmail("[email protected]");
Team team = teamWithId(77L);
TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER);
TeamMembership memberRow = membership(team, member, TeamRole.MEMBER);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(10L)).thenReturn(List.of(leaderRow));
when(billingService.forTeam(77L)).thenReturn(freeBilling(500L));
when(entitlementService.getSnapshot(77L)).thenReturn(snapshot(0L, 500L));
stubEmptyLedgerReads(77L);
when(memberRepo.findByTeamId(77L)).thenReturn(List.of(leaderRow, memberRow));
// Ledger returns signed (negative) debits.
when(ledgerRepo.sumPeriodAmountForMember(eq(77L), any(), any(), any(), any()))
.thenReturn(-42L);
ResponseEntity<WalletSnapshotResponse> resp =
controller.getWallet(jwtAuth(leader.getSupabaseId()));
WalletSnapshotResponse body = resp.getBody();
assertThat(body.role()).isEqualTo("leader");
assertThat(body.members()).hasSize(2);
MemberRow secondMember =
body.members().stream()
.filter(m -> "alice".equals(m.name()))
.findFirst()
.orElseThrow();
assertThat(secondMember.email()).isEqualTo("[email protected]");
assertThat(secondMember.spendUnits()).isEqualTo(42);
}
@Test
void getWallet_anonymousIsRejected() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<WalletSnapshotResponse> resp = controller.getWallet(anon);
// AuthenticationUtils.getCurrentUser throws SecurityException for "anonymousUser" since it
// has no Supabase id and is not a User principal the controller maps that to 401.
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(entitlementService, billingService, memberRepo, extRepo, policyRepo);
}
@Test
void getWallet_authenticatedNoTeam_returnsEmptyFreeShape() {
User user = userWithId(12L, UUID.randomUUID());
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(12L)).thenReturn(List.of());
ResponseEntity<WalletSnapshotResponse> resp =
controller.getWallet(jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody().status()).isEqualTo("free");
assertThat(resp.getBody().members()).isEmpty();
// Entitlement service must not be queried for a teamless user (avoids null-key NPE).
verifyNoInteractions(entitlementService);
}
// -----------------------------------------------------------------------------------------
// PATCH /cap
// -----------------------------------------------------------------------------------------
@Test
void updateCap_leaderUpdatesUnitsAndInvalidates() {
User leader = userWithId(20L, UUID.randomUUID());
Team team = teamWithId(33L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(20L))
.thenReturn(List.of(membership(team, leader, TeamRole.LEADER)));
when(policyRepo.findByTeamId(33L)).thenReturn(Optional.empty());
ResponseEntity<Void> resp =
controller.updateCap(
new UpdateCapRequest(40, false), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
ArgumentCaptor<WalletPolicy> saved = ArgumentCaptor.forClass(WalletPolicy.class);
verify(policyRepo).save(saved.capture());
// Rate unknown in this test (docCapForMoney empty) legacy conversion fallback so
// the cap stays enforced rather than silently lifting.
assertThat(saved.getValue().getCapUnits()).isEqualTo(4000L); // 40 USD * 100 units/USD
assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(4000L); // 40 USD == 4000 cents
verify(entitlementService, times(1)).invalidate(33L);
}
@Test
void updateCap_withKnownRate_storesDerivedDocCap() {
User leader = userWithId(24L, UUID.randomUUID());
Team team = teamWithId(36L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(24L))
.thenReturn(List.of(membership(team, leader, TeamRole.LEADER)));
when(policyRepo.findByTeamId(36L)).thenReturn(Optional.empty());
TeamBillingContext billing = subscribedBilling("sub_36", null, null);
when(billingService.forTeam(36L)).thenReturn(billing);
// $28 cap at $0.02/doc 1400 paid documents/month (the grant is a separate pool).
when(billingService.docCapForMoney(billing, 2800L)).thenReturn(Optional.of(1400L));
ResponseEntity<Void> resp =
controller.updateCap(
new UpdateCapRequest(28, false), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
ArgumentCaptor<WalletPolicy> saved = ArgumentCaptor.forClass(WalletPolicy.class);
verify(policyRepo).save(saved.capture());
assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(2800L);
assertThat(saved.getValue().getCapUnits()).isEqualTo(1400L);
verify(entitlementService).invalidate(36L);
}
@Test
void updateCap_noCapTrue_clearsCapUnits() {
User leader = userWithId(21L, UUID.randomUUID());
Team team = teamWithId(34L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(21L))
.thenReturn(List.of(membership(team, leader, TeamRole.LEADER)));
WalletPolicy existing = new WalletPolicy();
existing.setTeamId(34L);
existing.setCapUnits(1000L);
existing.setCapSourceMoney(1000L);
when(policyRepo.findByTeamId(34L)).thenReturn(Optional.of(existing));
ResponseEntity<Void> resp =
controller.updateCap(
new UpdateCapRequest(0, true), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
ArgumentCaptor<WalletPolicy> saved = ArgumentCaptor.forClass(WalletPolicy.class);
verify(policyRepo).save(saved.capture());
assertThat(saved.getValue().getCapUnits()).isNull();
assertThat(saved.getValue().getCapSourceMoney()).isNull();
verify(entitlementService).invalidate(34L);
}
@Test
void updateCap_memberIsForbidden() {
User member = userWithId(22L, UUID.randomUUID());
Team team = teamWithId(35L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(member));
when(memberRepo.findPrimaryMembership(22L))
.thenReturn(List.of(membership(team, member, TeamRole.MEMBER)));
ResponseEntity<Void> resp =
controller.updateCap(
new UpdateCapRequest(50, false), jwtAuth(member.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(policyRepo, never()).save(any());
verify(entitlementService, never()).invalidate(any());
}
@Test
void updateCap_noTeam_isForbidden() {
User user = userWithId(23L, UUID.randomUUID());
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(23L)).thenReturn(List.of());
ResponseEntity<Void> resp =
controller.updateCap(
new UpdateCapRequest(10, false), jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(policyRepo, never()).save(any());
}
@Test
void updateCap_anonymousIs401() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<Void> resp = controller.updateCap(new UpdateCapRequest(10, false), anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(policyRepo, entitlementService);
}
// -----------------------------------------------------------------------------------------
// Fixtures
// -----------------------------------------------------------------------------------------
private static User userWithId(Long id, UUID supabaseId) {
User u = new User();
u.setId(id);
u.setSupabaseId(supabaseId);
return u;
}
private static Team teamWithId(Long id) {
Team t = new Team();
t.setId(id);
t.setName("t-" + id);
return t;
}
private static TeamMembership membership(Team team, User user, TeamRole role) {
TeamMembership m = new TeamMembership();
m.setTeam(team);
m.setUser(user);
m.setRole(role);
return m;
}
private static EntitlementSnapshot snapshot(long spend, Long cap) {
LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay();
LocalDateTime end = start.plusMonths(1);
return new EntitlementSnapshot(
EntitlementState.FULL,
FeatureSet.FULL,
List.of(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE),
spend,
cap,
start,
end,
false);
}
private static Authentication jwtAuth(UUID supabaseId) {
Jwt jwt =
Jwt.withTokenValue("token")
.header("alg", "RS256")
.claim("sub", supabaseId.toString())
.claim("email", "[email protected]")
.build();
return new EnhancedJwtAuthenticationToken(
jwt, List.of(), "[email protected]", supabaseId.toString());
}
}
@@ -0,0 +1,150 @@
package stirling.software.saas.payg.cap;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import stirling.software.saas.payg.cap.CapEvaluator.Evaluation;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
class CapEvaluatorTest {
// ---------------------------------------------------------------------------------------
// evaluate(): single-axis cap evaluation
// ---------------------------------------------------------------------------------------
@Test
void nullCap_returnsFullStateAndFullGates() {
Evaluation e = CapEvaluator.evaluate(1_000_000L, null, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
assertThat(e.enabledGates())
.containsExactlyInAnyOrder(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE);
}
@Test
void zeroCap_treatedAsUnlimitedForSafety() {
// Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL).
Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
}
@Test
void wellBelowWarn_returnsFull() {
Evaluation e = CapEvaluator.evaluate(10L, 100L, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
}
@Test
void exactlyAtWarnThreshold_returnsWarned() {
// 80% of 100 = 80; pct = 80 / 100 * 100 = 80 warnAtPct=80 WARNED
Evaluation e = CapEvaluator.evaluate(80L, 100L, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.WARNED);
// Warn band keeps the FULL feature set only flags the FE to show a banner.
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
assertThat(e.enabledGates()).hasSize(4);
}
@Test
void betweenWarnAndDegrade_returnsWarned() {
Evaluation e = CapEvaluator.evaluate(95L, 100L, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.WARNED);
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
}
@Test
void exactlyAtDegradeThreshold_returnsDegradedWithConfiguredSet() {
Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
// MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; only
// AUTOMATION + AI_SUPPORT are blocked.
assertThat(e.enabledGates())
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
}
@Test
void overDegradeThreshold_returnsDegradedAndCannotEscalate() {
Evaluation e = CapEvaluator.evaluate(500L, 100L, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
}
@Test
void degradedFeatureSetClientOnly_dropsEverythingButClientSide() {
Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.CLIENT_ONLY);
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(e.featureSet()).isEqualTo(FeatureSet.CLIENT_ONLY);
assertThat(e.enabledGates()).containsExactly(FeatureGate.CLIENT_SIDE);
}
@Test
void nullDegradedFeatureSet_fallsBackToMinimal() {
Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, null);
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
}
@Test
void misconfiguredThresholds_treatedAsNoCap() {
// warn > degrade is malformed; the evaluator returns FULL rather than risk wrong
// degradation. The admin write path is supposed to validate this.
Evaluation e = CapEvaluator.evaluate(100L, 100L, 110, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
// negative warn bad config FULL
Evaluation neg = CapEvaluator.evaluate(100L, 100L, -10, 100, FeatureSet.MINIMAL);
assertThat(neg.state()).isEqualTo(EntitlementState.FULL);
// zero degrade bad config FULL (would otherwise degrade on any spend)
Evaluation zero = CapEvaluator.evaluate(0L, 100L, 80, 0, FeatureSet.MINIMAL);
assertThat(zero.state()).isEqualTo(EntitlementState.FULL);
}
@Test
void zeroSpend_returnsFullEvenIfCapTiny() {
Evaluation e = CapEvaluator.evaluate(0L, 1L, 80, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
}
@Test
void warnAtZeroPct_warnsImmediately() {
// Edge case: warn-at-0% means any spend WARNED. Allowed even if quirky.
Evaluation e = CapEvaluator.evaluate(1L, 100L, 0, 100, FeatureSet.MINIMAL);
assertThat(e.state()).isEqualTo(EntitlementState.WARNED);
}
// ---------------------------------------------------------------------------------------
// gatesFor(): mapping FeatureSet declared gates
// ---------------------------------------------------------------------------------------
@Test
void gatesFor_full_listsAllFour() {
assertThat(CapEvaluator.gatesFor(FeatureSet.FULL))
.containsExactlyInAnyOrder(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE);
}
@Test
void gatesFor_minimal_keepsOffsiteAndClientSide() {
// MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; AUTOMATION +
// AI_SUPPORT are the only gates dropped on degrade.
assertThat(CapEvaluator.gatesFor(FeatureSet.MINIMAL))
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
}
@Test
void gatesFor_null_returnsEmpty() {
assertThat(CapEvaluator.gatesFor(null)).isEmpty();
}
}
@@ -0,0 +1,52 @@
package stirling.software.saas.payg.cap;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AnnotationUtils;
import stirling.software.saas.ai.controller.AiCreateController;
import stirling.software.saas.ai.controller.AiCreateInternalController;
import stirling.software.saas.ai.controller.AiProxyController;
import stirling.software.saas.payg.model.FeatureGate;
/**
* Annotation-rollout guard. The saas {@code PaygChargeInterceptor} reads class-level
* {@code @RequiresFeature} via {@link AnnotationUtils#findAnnotation(Class, Class)} to decide
* whether a request bills as {@code AI}, {@code AUTOMATION}, or falls through to the auth-derived
* default. These tests pin the gate on each AI surface so the classification can't silently regress
* to {@code BYPASSED} if someone strips the annotation while refactoring.
*
* <p><b>Out of scope</b>: {@code PipelineController} (in core) and {@code PolicyController} (in
* proprietary) neither module can import {@code @RequiresFeature} from saas without a forbidden
* upward dependency. Their automation classification is enforced via the {@code
* X-Stirling-Automation} header set unconditionally by {@code InternalApiClient.post}; see the
* dedicated test in that module.
*/
class RequiresFeatureAnnotationRolloutTest {
@Test
void aiCreateController_isClassifiedAsAiSupport() {
RequiresFeature ann =
AnnotationUtils.findAnnotation(AiCreateController.class, RequiresFeature.class);
assertThat(ann).isNotNull();
assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT);
}
@Test
void aiCreateInternalController_isClassifiedAsAiSupport() {
RequiresFeature ann =
AnnotationUtils.findAnnotation(
AiCreateInternalController.class, RequiresFeature.class);
assertThat(ann).isNotNull();
assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT);
}
@Test
void aiProxyController_isClassifiedAsAiSupport() {
RequiresFeature ann =
AnnotationUtils.findAnnotation(AiProxyController.class, RequiresFeature.class);
assertThat(ann).isNotNull();
assertThat(ann.value()).containsExactly(FeatureGate.AI_SUPPORT);
}
}
@@ -17,14 +17,18 @@ import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.saas.payg.docs.DocumentClassifier;
@@ -33,15 +37,24 @@ 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.ProcessType;
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;
/**
* Exercises {@link JobChargeService} as an orchestrator: policy lookup, step-limit resolution,
@@ -54,6 +67,9 @@ class JobChargeServiceTest {
private DocumentClassifier classifier;
private PaygShadowChargeRepository shadowRepo;
private ProcessingJobRepository jobRepo;
private PaygTeamExtensionsRepository teamExtRepo;
private PaygMeterReportingService meterReporter;
private WalletLedgerRepository ledgerRepo;
private JobChargeService service;
@BeforeEach
@@ -63,7 +79,31 @@ class JobChargeServiceTest {
classifier = Mockito.mock(DocumentClassifier.class);
shadowRepo = Mockito.mock(PaygShadowChargeRepository.class);
jobRepo = Mockito.mock(ProcessingJobRepository.class);
service = new JobChargeService(jobService, policyService, classifier, shadowRepo, jobRepo);
teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
meterReporter = Mockito.mock(PaygMeterReportingService.class);
ledgerRepo = Mockito.mock(WalletLedgerRepository.class);
// findByIdForUpdate defaults to Optional.empty() (Mockito) no free grant consumed unless
// a test stubs the sidecar row. The free split is decided at openProcess time now, not at
// close, so the meter tests just set free_units_consumed on the shadow row directly.
service =
new JobChargeService(
jobService,
policyService,
classifier,
shadowRepo,
jobRepo,
teamExtRepo,
meterReporter,
ledgerRepo);
}
@AfterEach
void tearDown() {
// Defensive: a previous test could have left a fake synchronization registered. Clearing
// ensures isolation when tests run in any order.
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clear();
}
}
@Test
@@ -80,7 +120,12 @@ class JobChargeServiceTest {
ChargeOutcome out =
service.openProcess(
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
new ChargeContext(
42L,
100L,
JobSource.WEB,
ProcessType.SINGLE_TOOL,
BillingCategory.API),
List.of(in));
assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.JOINED);
@@ -89,6 +134,7 @@ class JobChargeServiceTest {
verify(classifier, never()).classify(any(MultipartFile.class), any());
verify(classifier, never()).classify(anyList(), any());
verify(shadowRepo, never()).save(any());
verify(ledgerRepo, never()).save(any());
}
@Test
@@ -106,7 +152,12 @@ class JobChargeServiceTest {
ChargeOutcome out =
service.openProcess(
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
new ChargeContext(
42L,
100L,
JobSource.WEB,
ProcessType.SINGLE_TOOL,
BillingCategory.API),
List.of(in));
assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.OPENED);
@@ -126,9 +177,82 @@ class JobChargeServiceTest {
// Legacy comparison not wired yet zeroed until CreditService is wired in the follow-up.
assertThat(row.getLegacyCreditsCharged()).isZero();
assertThat(row.getDiffPct()).isZero();
// PAYG analytics axis: billing_category + job_source are copied from the context so the
// row stays self-describing after processing_job rows are pruned.
assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.API);
assertThat(row.getJobSource()).isEqualTo(JobSource.WEB);
// Job entity carries the classified docUnits so close-time receipts can render correctly.
assertThat(newJob.getDocUnits()).isEqualTo(4);
// Live ledger DEBIT mirrors the shadow row: same units, stored NEGATIVE per the
// wallet_ledger sign convention, tied back to the job via reference.
ArgumentCaptor<WalletLedgerEntry> ledgerCaptor =
ArgumentCaptor.forClass(WalletLedgerEntry.class);
verify(ledgerRepo).save(ledgerCaptor.capture());
WalletLedgerEntry debit = ledgerCaptor.getValue();
assertThat(debit.getTeamId()).isEqualTo(100L);
assertThat(debit.getActorUserId()).isEqualTo(42L);
assertThat(debit.getEntryType()).isEqualTo(LedgerEntryType.DEBIT);
assertThat(debit.getBucket()).isEqualTo(LedgerBucket.CYCLE);
assertThat(debit.getAmountUnits()).isEqualTo(-4);
assertThat(debit.getReferenceType()).isEqualTo(ReferenceType.JOB);
assertThat(debit.getReferenceId()).isEqualTo(newJob.getId().toString());
assertThat(debit.getPolicyId()).isEqualTo(policy.getId());
assertThat(debit.getBillingCategory()).isEqualTo(BillingCategory.API);
}
@Test
void openProcess_bypassedCategory_writesShadowRowButNoLedgerDebit(@TempDir Path tmp)
throws IOException {
// Manual UI work is never billed: the shadow row still lands (comparison audit trail)
// but the live wallet_ledger must stay untouched.
PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
service.openProcess(
new ChargeContext(
42L,
100L,
JobSource.WEB,
ProcessType.SINGLE_TOOL,
BillingCategory.BYPASSED),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
verify(shadowRepo).save(any(PaygShadowCharge.class));
verify(ledgerRepo, never()).save(any());
}
@Test
void openProcess_openedAutomationContext_writesShadowRowWithAutomationCategory(
@TempDir Path tmp) throws IOException {
PricingPolicy policy =
stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10, JobSource.PIPELINE, 20));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
service.openProcess(
new ChargeContext(
42L,
100L,
JobSource.PIPELINE,
ProcessType.AUTOMATION,
BillingCategory.AUTOMATION),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION);
assertThat(captor.getValue().getJobSource()).isEqualTo(JobSource.PIPELINE);
}
@Test
@@ -146,7 +270,12 @@ class JobChargeServiceTest {
ChargeOutcome out =
service.openProcess(
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.AUTOMATION),
new ChargeContext(
42L,
100L,
JobSource.WEB,
ProcessType.AUTOMATION,
BillingCategory.AUTOMATION),
List.of(a, b));
assertThat(out.units()).isEqualTo(7);
@@ -167,12 +296,107 @@ class JobChargeServiceTest {
ChargeOutcome out =
service.openProcess(
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
new ChargeContext(
42L,
100L,
JobSource.WEB,
ProcessType.SINGLE_TOOL,
BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
assertThat(out.units()).isEqualTo(5);
}
@Test
void openProcess_drawsFreeGrant_storesSplitAndDecrementsCounter(@TempDir Path tmp)
throws IOException {
// Team has 10 free units left; a 4-unit job draws all 4 from the grant. The shadow row
// records free_units_consumed = 4 (so nothing meters) and the counter drops to 6.
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 4));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(10L);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getPaygUnits()).isEqualTo(4);
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(4);
// Counter decremented in-place and persisted.
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(6L);
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_grantStraddle_drawsRemainderFreeAndBillsTheRest(@TempDir Path tmp)
throws IOException {
// Only 3 free units left; a 10-unit job takes the 3 (counter 0) and the other 7 bill.
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 10));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(3L);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getPaygUnits()).isEqualTo(10);
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(3);
assertThat(ext.getFreeUnitsRemaining()).isZero();
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_exhaustedGrant_storesZeroFreeAndLeavesCounterUntouched(@TempDir Path tmp)
throws IOException {
// Grant already at 0 nothing free, full units bill, counter not re-saved.
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 5));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getFreeUnitsConsumed()).isZero();
verify(teamExtRepo, never()).save(any());
}
@Test
void openProcess_resolvesStepLimitFromPolicy_perJobSource(@TempDir Path tmp)
throws IOException {
@@ -187,7 +411,12 @@ class JobChargeServiceTest {
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
service.openProcess(
new ChargeContext(42L, 100L, JobSource.PIPELINE, ProcessType.AUTOMATION),
new ChargeContext(
42L,
100L,
JobSource.PIPELINE,
ProcessType.AUTOMATION,
BillingCategory.AUTOMATION),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<JobContext> ctxCaptor = ArgumentCaptor.forClass(JobContext.class);
@@ -211,7 +440,12 @@ class JobChargeServiceTest {
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
service.openProcess(
new ChargeContext(42L, 100L, JobSource.DESKTOP_APP, ProcessType.SINGLE_TOOL),
new ChargeContext(
42L,
100L,
JobSource.DESKTOP_APP,
ProcessType.SINGLE_TOOL,
BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<JobContext> ctxCaptor = ArgumentCaptor.forClass(JobContext.class);
@@ -225,7 +459,11 @@ class JobChargeServiceTest {
() ->
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
42L,
100L,
JobSource.WEB,
ProcessType.SINGLE_TOOL,
BillingCategory.API),
List.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("inputs must not be empty");
@@ -234,9 +472,8 @@ class JobChargeServiceTest {
@Test
void markFirstStepFailed_flipsShadowRowAndClosesProcess() {
UUID jobId = UUID.randomUUID();
PaygShadowCharge row = new PaygShadowCharge();
row.setJobId(jobId);
row.setStatus(ShadowChargeStatus.CHARGED);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
row.setPolicyId(7L);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(java.util.Optional.of(row));
ProcessingJob job = openJob(jobId);
when(jobRepo.findById(jobId)).thenReturn(java.util.Optional.of(job));
@@ -250,6 +487,37 @@ class JobChargeServiceTest {
assertThat(job.getClosedAt()).isNotNull();
verify(shadowRepo).save(row);
verify(jobRepo).save(job);
// Compensating REFUND entry: positive amount mirroring the openProcess debit, same JOB
// reference so the pair nets to zero for the period.
ArgumentCaptor<WalletLedgerEntry> ledgerCaptor =
ArgumentCaptor.forClass(WalletLedgerEntry.class);
verify(ledgerRepo).save(ledgerCaptor.capture());
WalletLedgerEntry refund = ledgerCaptor.getValue();
assertThat(refund.getTeamId()).isEqualTo(100L);
assertThat(refund.getEntryType()).isEqualTo(LedgerEntryType.REFUND);
assertThat(refund.getBucket()).isEqualTo(LedgerBucket.CYCLE);
assertThat(refund.getAmountUnits()).isEqualTo(4);
assertThat(refund.getReferenceType()).isEqualTo(ReferenceType.JOB);
assertThat(refund.getReferenceId()).isEqualTo(jobId.toString());
assertThat(refund.getPolicyId()).isEqualTo(7L);
assertThat(refund.getBillingCategory()).isEqualTo(BillingCategory.API);
// This row consumed no free units, so the grant counter is left alone.
verify(teamExtRepo, never()).restoreFreeUnits(eq(100L), Mockito.anyLong());
}
@Test
void markFirstStepFailed_withFreeConsumed_restoresGrantToCounter() {
// A first-step failure is pre-meter: nothing billed to Stripe, but the grant moved at
// charge time. The refund must hand exactly those free units back to the team's counter.
UUID jobId = UUID.randomUUID();
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId)));
service.markFirstStepFailed(jobId, "first-step-5xx:503");
verify(teamExtRepo).restoreFreeUnits(100L, 3L);
}
@Test
@@ -267,6 +535,8 @@ class JobChargeServiceTest {
verify(shadowRepo, never()).save(any());
verify(jobRepo, never()).save(any());
// No double-credit: the REFUND ledger entry only accompanies the CHARGEDREFUNDED flip.
verify(ledgerRepo, never()).save(any());
}
@Test
@@ -330,6 +600,262 @@ class JobChargeServiceTest {
verify(jobRepo, never()).save(any());
}
// --- close() meter reporting in afterCommit -----------------------------------------------
@Test
void close_subscribedTeam_postsMeterEventAfterCommit() {
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setStripeCustomerId("cus_subscribed");
ext.setPaygSubscriptionId("sub_test");
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
// Row consumed no free units (free_units_consumed = 0) all 4 are paid and meter.
withTransactionSynchronization(
() -> {
service.close(jobId);
Mockito.verifyNoInteractions(meterReporter);
});
// afterCommit ran on tearDown of withTransactionSynchronization meter posted now.
verify(meterReporter)
.recordUsage(
100L,
"cus_subscribed",
4,
BillingCategory.API,
"process:" + jobId + ":close",
jobId);
}
@Test
void close_fullyFreeJob_doesNotPostMeterEvent() {
// The free-vs-paid split is fixed at charge time. A job whose 4 units all came from the
// one-time grant (free_units_consumed = 4) has nothing left to meter; the ledger DEBIT
// alone records the usage.
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, 4, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setStripeCustomerId("cus_subscribed");
ext.setPaygSubscriptionId("sub_test");
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
withTransactionSynchronization(() -> service.close(jobId));
Mockito.verifyNoInteractions(meterReporter);
}
@Test
void close_partiallyFreeJob_metersOnlyThePaidPortion() {
// 20-unit job that drew 10 from the remaining grant at charge time (free_units_consumed =
// 10) 10 paid units meter to Stripe.
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 20, 10, BillingCategory.AUTOMATION);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setStripeCustomerId("cus_subscribed");
ext.setPaygSubscriptionId("sub_test");
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
withTransactionSynchronization(() -> service.close(jobId));
verify(meterReporter)
.recordUsage(
100L,
"cus_subscribed",
10,
BillingCategory.AUTOMATION,
"process:" + jobId + ":close",
jobId);
}
@Test
void close_freeTierTeam_doesNotPostMeterEvent() {
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
// No stripe_customer_id treated as free-tier on this branch (pre-#6532).
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setStripeCustomerId(null);
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
withTransactionSynchronization(() -> service.close(jobId));
Mockito.verifyNoInteractions(meterReporter);
}
@Test
void close_noTeamExtensionsRow_doesNotPostMeterEvent() {
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
when(teamExtRepo.findById(100L)).thenReturn(Optional.empty());
withTransactionSynchronization(() -> service.close(jobId));
Mockito.verifyNoInteractions(meterReporter);
}
@Test
void close_refundedShadowRow_doesNotPostMeterEvent() {
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
row.setStatus(ShadowChargeStatus.REFUNDED);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
withTransactionSynchronization(() -> service.close(jobId));
Mockito.verifyNoInteractions(meterReporter);
Mockito.verifyNoInteractions(teamExtRepo);
}
@Test
void close_noShadowRow_doesNotPostMeterEvent() {
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.empty());
withTransactionSynchronization(() -> service.close(jobId));
Mockito.verifyNoInteractions(meterReporter);
Mockito.verifyNoInteractions(teamExtRepo);
}
@Test
void close_bypassedCategoryOnShadowRow_doesNotPostMeterEvent() {
// Defensive: BYPASSED rows shouldn't normally exist (the interceptor short-circuits
// before openProcess), but if one slips through we must not meter it.
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.BYPASSED);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
withTransactionSynchronization(() -> service.close(jobId));
Mockito.verifyNoInteractions(meterReporter);
Mockito.verifyNoInteractions(teamExtRepo);
}
@Test
void close_meterReporterThrowsRuntimeException_doesNotPropagate() {
// PaygMeterReportingService is documented to swallow; defence-in-depth in
// JobChargeService catches a misbehaving impl so the afterCommit hook can't poison the
// close flow.
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.AUTOMATION);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setStripeCustomerId("cus_subscribed");
ext.setPaygSubscriptionId("sub_test");
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
Mockito.doThrow(new RuntimeException("simulated meter failure"))
.when(meterReporter)
.recordUsage(
Mockito.anyLong(),
Mockito.anyString(),
Mockito.anyInt(),
Mockito.any(BillingCategory.class),
Mockito.anyString(),
Mockito.any(UUID.class));
// Should not throw afterCommit's defence-in-depth wraps the call.
withTransactionSynchronization(() -> service.close(jobId));
verify(meterReporter)
.recordUsage(
100L,
"cus_subscribed",
4,
BillingCategory.AUTOMATION,
"process:" + jobId + ":close",
jobId);
}
@Test
void close_noActiveTransactionSync_skipsMeterPostButStillClosesJob() {
// Direct call without an outer @Transactional no sync to register against. close()
// must still close the job; the meter post is implicitly deferred to whatever async path
// eventually wraps the call (or is never made, which is fine for ledger-only flows).
UUID jobId = UUID.randomUUID();
ProcessingJob job = openJob(jobId);
when(jobService.close(jobId)).thenReturn(job);
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
service.close(jobId);
Mockito.verifyNoInteractions(meterReporter);
verify(jobService).close(jobId);
}
private static void withTransactionSynchronization(Runnable body) {
TransactionSynchronizationManager.initSynchronization();
try {
body.run();
// Drain registered synchronizations to simulate a successful commit.
for (TransactionSynchronization sync :
TransactionSynchronizationManager.getSynchronizations()) {
sync.afterCommit();
}
} finally {
TransactionSynchronizationManager.clear();
}
}
private static PaygShadowCharge chargedShadowRow(
UUID jobId, Long teamId, int units, BillingCategory category) {
return chargedShadowRow(jobId, teamId, units, 0, category);
}
private static PaygShadowCharge chargedShadowRow(
UUID jobId, Long teamId, int units, int freeUnitsConsumed, BillingCategory category) {
PaygShadowCharge row = new PaygShadowCharge();
row.setJobId(jobId);
row.setTeamId(teamId);
row.setPaygUnits(units);
row.setFreeUnitsConsumed(freeUnitsConsumed);
row.setStatus(ShadowChargeStatus.CHARGED);
row.setBillingCategory(category);
return row;
}
// --- helpers --------------------------------------------------------------------------------
private static PricingPolicy stubPolicy(int minCharge, Map<JobSource, Integer> stepLimits) {
@@ -0,0 +1,568 @@
package stirling.software.saas.payg.entitlement;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.lang.reflect.Method;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.method.HandlerMethod;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.proprietary.model.Team;
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.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
/**
* Pure-Mockito tests for {@link EntitlementGuard}. Covers the four decision-matrix cells: anonymous
* billable 401, anonymous manual pass, authenticated FULL pass, authenticated DEGRADED for a
* billable route 402.
*/
class EntitlementGuardTest {
private EntitlementService entitlementService;
private UserRepository userRepository;
private MeterRegistry meterRegistry;
private EntitlementGuard guard;
private final ObjectMapper json = new ObjectMapper();
@BeforeEach
void setUp() {
entitlementService = Mockito.mock(EntitlementService.class);
userRepository = Mockito.mock(UserRepository.class);
meterRegistry = new SimpleMeterRegistry();
guard = new EntitlementGuard(entitlementService, userRepository, meterRegistry);
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// ---------------------------------------------------------------------------------------
// Scope: non-AutoJobPostMapping routes skip the guard entirely
// ---------------------------------------------------------------------------------------
@Test
void nonHandlerMethod_passesThrough() throws Exception {
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, "someRawHandler");
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void routeWithNeitherAnnotation_isSkipped() throws Exception {
HandlerMethod hm = handlerFor("plainEndpoint");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
Mockito.verifyNoInteractions(entitlementService, userRepository);
}
@Test
void routeWithRequiresFeatureButNoAutoJobPostMapping_isInScope() throws Exception {
// Regression: @RequiresFeature alone (e.g. JSON-bodied AI controllers) must be in-scope.
// Previously the guard short-circuited unless @AutoJobPostMapping was present, which meant
// a team without AI entitlement could hit /api/v1/ai/* freely.
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
HandlerMethod hm = handlerFor("aiOnlyNoAutoJobPostMapping");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(402);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT");
verify(entitlementService).getSnapshot(42L);
}
@Test
void routeWithClassLevelRequiresFeatureOnly_isInScope() throws Exception {
// Mirrors AiCreateController shape: @RequiresFeature lives on the @RestController class,
// not the method. Must still be picked up by the guard.
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerForClassLevel("classLevelAiMethod");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED");
assertThat(body.get("category").asText()).isEqualTo("AI");
}
// ---------------------------------------------------------------------------------------
// Anonymous user
// ---------------------------------------------------------------------------------------
@Test
void anonymousUser_billableRoute_returns401SignupRequired() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerFor("automationOnly");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(401);
assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED");
assertThat(body.get("category").asText()).isEqualTo("AUTOMATION");
Mockito.verifyNoInteractions(entitlementService);
}
@Test
void anonymousUser_aiRoute_returns401WithAiCategory() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerFor("aiOnly");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("category").asText()).isEqualTo("AI");
}
@Test
void anonymousUser_manualTool_passesThroughUnbilled() throws Exception {
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
HandlerMethod hm = handlerFor("manualTool");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
Mockito.verifyNoInteractions(entitlementService);
}
// ---------------------------------------------------------------------------------------
// Authenticated user FULL vs DEGRADED
// ---------------------------------------------------------------------------------------
@Test
void authenticatedUser_fullState_passesThrough() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot());
HandlerMethod hm = handlerFor("automationOnly");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void authenticatedUser_degradedAndBillable_returns402() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
HandlerMethod hm = handlerFor("automationOnly");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(402);
assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
assertThat(body.get("state").asText()).isEqualTo("DEGRADED");
assertThat(body.get("capUnits").asLong()).isEqualTo(500L);
assertThat(body.get("spendUnits").asLong()).isEqualTo(500L);
assertThat(body.get("missingGates").isArray()).isTrue();
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION");
}
@Test
void authenticatedUser_degradedButManualTool_passesThrough() throws Exception {
// KEY assertion: DEGRADED+MINIMAL must still allow manual server tools (OFFSITE_PROCESSING)
// that's the whole point of moving OFFSITE into MINIMAL.
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
HandlerMethod hm = handlerFor("manualTool");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void authenticatedUser_aiRouteDegraded_returns402() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
HandlerMethod hm = handlerFor("aiOnly");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(402);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT");
}
// ---------------------------------------------------------------------------------------
// API-key calls: billable, hard-stop when degraded (allowance/cap reached)
// ---------------------------------------------------------------------------------------
@Test
void apiKeyCall_degradedFreeTeam_returns402AndAdvisesSubscribe() throws Exception {
// A plain server tool (OFFSITE_PROCESSING) reached via API key: the gate survives DEGRADED,
// so the gate loop would wave it through but API usage is billable and must hard-stop
// once the free allowance is spent.
SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false));
HandlerMethod hm = handlerFor("manualTool"); // OFFSITE default gate
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(402);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED");
assertThat(body.get("subscribed").asBoolean()).isFalse();
assertThat(body.get("message").asText()).contains("Subscribe");
}
@Test
void apiKeyCall_degradedSubscribedTeam_returns402AndAdvisesCap() throws Exception {
SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(true));
HandlerMethod hm = handlerFor("manualTool");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isFalse();
assertThat(res.getStatus()).isEqualTo(402);
JsonNode body = json.readTree(res.getContentAsByteArray());
assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED");
assertThat(body.get("subscribed").asBoolean()).isTrue();
assertThat(body.get("message").asText()).contains("cap");
}
@Test
void apiKeyCall_withinAllowance_passes() throws Exception {
// Not degraded API usage under the free allowance proceeds normally.
SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L));
when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot());
HandlerMethod hm = handlerFor("manualTool");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void jwtWebManualTool_degraded_stillPasses() throws Exception {
// The "allow JWT web tool usage" guarantee: a web (JWT) user running an everyday server
// tool (OFFSITE_PROCESSING) is NOT blocked when the team is over allowance only billable
// API/AI/automation calls hard-stop. (Web manual calls are BillingCategory.BYPASSED.)
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false));
HandlerMethod hm = handlerFor("manualTool"); // OFFSITE survives DEGRADED
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
// ---------------------------------------------------------------------------------------
// Fail-open
// ---------------------------------------------------------------------------------------
@Test
void snapshotLookupThrows_failsOpenAndPasses() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
when(userRepository.findBySupabaseId(supabaseId))
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
when(entitlementService.getSnapshot(42L))
.thenThrow(new RuntimeException("transient DB outage"));
HandlerMethod hm = handlerFor("automationOnly");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
assertThat(res.getStatus()).isEqualTo(200);
}
@Test
void noTeam_passesThrough() throws Exception {
UUID supabaseId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
User u = new User();
u.setId(7L);
u.setTeam(null);
when(userRepository.findBySupabaseId(supabaseId)).thenReturn(Optional.of(u));
HandlerMethod hm = handlerFor("automationOnly");
MockHttpServletRequest req = new MockHttpServletRequest();
MockHttpServletResponse res = new MockHttpServletResponse();
boolean proceed = guard.preHandle(req, res, hm);
assertThat(proceed).isTrue();
verify(entitlementService, never()).getSnapshot(any());
}
// ---------------------------------------------------------------------------------------
// resolveRequiredGates fallback
// ---------------------------------------------------------------------------------------
@Test
void resolveRequiredGates_noAnnotation_defaultsToOffsiteProcessing() throws Exception {
HandlerMethod hm = handlerFor("manualTool");
FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm);
assertThat(gates).containsExactly(FeatureGate.OFFSITE_PROCESSING);
}
@Test
void resolveRequiredGates_withAnnotation_usesAnnotationValue() throws Exception {
HandlerMethod hm = handlerFor("automationOnly");
FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm);
assertThat(gates).containsExactly(FeatureGate.AUTOMATION);
}
// ---------------------------------------------------------------------------------------
// Helpers / fixture controller
// ---------------------------------------------------------------------------------------
private static EntitlementSnapshot fullSnapshot() {
return new EntitlementSnapshot(
EntitlementState.FULL,
FeatureSet.FULL,
List.of(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE),
0L,
500L,
LocalDateTime.of(2026, 6, 1, 0, 0),
LocalDateTime.of(2026, 7, 1, 0, 0),
false);
}
private static EntitlementSnapshot degradedSnapshot() {
return degradedSnapshot(false);
}
private static EntitlementSnapshot degradedSnapshot(boolean subscribed) {
return new EntitlementSnapshot(
EntitlementState.DEGRADED,
FeatureSet.MINIMAL,
List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE),
500L,
500L,
LocalDateTime.of(2026, 6, 1, 0, 0),
LocalDateTime.of(2026, 7, 1, 0, 0),
subscribed);
}
private static ApiKeyAuthenticationToken apiKeyAuth(long teamId) {
User u = userWithTeam(99L, teamId);
return new ApiKeyAuthenticationToken(
u, "sk-test", List.of(new SimpleGrantedAuthority("ROLE_USER")));
}
private static User userWithTeam(long userId, long teamId) {
User u = new User();
u.setId(userId);
Team t = new Team();
t.setId(teamId);
u.setTeam(t);
return u;
}
private static EnhancedJwtAuthenticationToken jwtAuth(UUID supabaseId) {
Map<String, Object> headers = new HashMap<>();
headers.put("alg", "RS256");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", supabaseId.toString());
claims.put("email", "[email protected]");
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
return new EnhancedJwtAuthenticationToken(
jwt,
List.of(new SimpleGrantedAuthority("ROLE_USER")),
"[email protected]",
supabaseId.toString());
}
private static HandlerMethod handlerFor(String methodName) throws NoSuchMethodException {
Method m = TestController.class.getDeclaredMethod(methodName);
return new HandlerMethod(new TestController(), m);
}
private static HandlerMethod handlerForClassLevel(String methodName)
throws NoSuchMethodException {
Method m = ClassLevelAiController.class.getDeclaredMethod(methodName);
return new HandlerMethod(new ClassLevelAiController(), m);
}
/** Fixture mounting route shapes the guard's resolver needs to discriminate. */
static class TestController {
@AutoJobPostMapping("/manual")
public String manualTool() {
return "ok";
}
@AutoJobPostMapping("/automation")
@RequiresFeature(FeatureGate.AUTOMATION)
public String automationOnly() {
return "ok";
}
@AutoJobPostMapping("/ai")
@RequiresFeature(FeatureGate.AI_SUPPORT)
public String aiOnly() {
return "ok";
}
/** Endpoint without any annotation — guard must skip. */
public String plainEndpoint() {
return "ok";
}
/** AI-controller shape: @RequiresFeature with NO @AutoJobPostMapping (JSON body). */
@RequiresFeature(FeatureGate.AI_SUPPORT)
public String aiOnlyNoAutoJobPostMapping() {
return "ok";
}
}
/**
* Mirrors {@code AiCreateController} layout: @RequiresFeature on the class, plain methods. The
* guard must pick up the class-level annotation.
*/
@RequiresFeature(FeatureGate.AI_SUPPORT)
static class ClassLevelAiController {
public String classLevelAiMethod() {
return "ok";
}
}
}
@@ -0,0 +1,271 @@
package stirling.software.saas.payg.entitlement;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.model.EntitlementState;
import stirling.software.saas.payg.model.FeatureGate;
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;
/**
* Unit tests for {@link EntitlementService}. Two branches (design 2026-06-11 the free allowance
* is a one-time lifetime grant):
*
* <ul>
* <li><b>Unsubscribed</b> gated by the grant. Cap = grant size, spend = {@code grant
* remaining}, both read straight from the billing context (no ledger query). Exhausted grant
* (remaining 0) DEGRADED.
* <li><b>Subscribed</b> gated by the monthly money-derived doc cap. Spend = this period's net
* billable units ({@link WalletLedgerRepository#sumPeriodNetBillable} negated, refunds
* netted).
* </ul>
*
* Also covers cache hit/miss + the invalidate cascade.
*/
class EntitlementServiceTest {
private static final LocalDateTime PERIOD_START = LocalDateTime.of(2026, 6, 9, 0, 0);
private static final LocalDateTime PERIOD_END = LocalDateTime.of(2026, 7, 9, 0, 0);
private TeamBillingService billingService;
private WalletPolicyRepository walletPolicyRepo;
private WalletLedgerRepository ledgerRepo;
private EntitlementService service;
@BeforeEach
void setUp() {
billingService = Mockito.mock(TeamBillingService.class);
walletPolicyRepo = Mockito.mock(WalletPolicyRepository.class);
ledgerRepo = Mockito.mock(WalletLedgerRepository.class);
service = new EntitlementService(billingService, walletPolicyRepo, ledgerRepo);
}
@Test
void getSnapshot_nullTeamId_throws() {
assertThatThrownBy(() -> service.getSnapshot(null))
.isInstanceOf(NullPointerException.class);
}
@Test
void freeTeam_capIsTheGrantAndSpendIsUsedFromCounter() {
// Unsubscribed: cap = grant size, spend = grant remaining no ledger read.
stubBilling(42L, freeContext(500L, 400L));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodCapUnits()).isEqualTo(500L);
assertThat(snap.periodSpendUnits()).isEqualTo(100L);
// 100/500 = 20% well below warn FULL
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL);
// The grant gate doesn't touch the ledger at all.
Mockito.verifyNoInteractions(ledgerRepo);
}
@Test
void subscribedTeam_capIsTheMoneyDerivedDocCap() {
stubBilling(42L, subscribedContext(2000L));
when(walletPolicyRepo.findByTeamId(42L))
.thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL)));
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-500L);
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodCapUnits()).isEqualTo(2000L);
assertThat(snap.periodSpendUnits()).isEqualTo(500L);
// 500/2000 = 25% FULL
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
}
@Test
void subscribedTeam_refundsNetAgainstSpend() {
// Net billable = debits refunds. A 300 net (e.g. 500 debited, 200 refunded) 300 spend.
stubBilling(42L, subscribedContext(2000L));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-300L);
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodSpendUnits()).isEqualTo(300L);
}
@Test
void spendWindow_comesFromBillingContextNotCalendarMonth() {
stubBilling(42L, subscribedContext(2000L));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L);
EntitlementSnapshot snap = service.getSnapshot(42L);
// The subscription-anchored window flows through to both the snapshot and the SUM query.
assertThat(snap.periodStart()).isEqualTo(PERIOD_START);
assertThat(snap.periodEnd()).isEqualTo(PERIOD_END);
verify(ledgerRepo).sumPeriodNetBillable(eq(42L), eq(PERIOD_START), eq(PERIOD_END));
}
@Test
void exhaustedGrant_returnsDegradedWithMinimalGates() {
// Grant fully consumed (remaining 0) billable categories hard-stop for an unsubscribed
// team. The displayed cap stays the grant size; spend reads as the full grant.
stubBilling(42L, freeContext(100L, 0L));
when(walletPolicyRepo.findByTeamId(42L))
.thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL)));
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(snap.featureSet()).isEqualTo(FeatureSet.MINIMAL);
assertThat(snap.periodCapUnits()).isEqualTo(100L);
assertThat(snap.periodSpendUnits()).isEqualTo(100L);
// MINIMAL now keeps OFFSITE_PROCESSING + CLIENT_SIDE (manual tools); AUTOMATION + AI gone.
assertThat(snap.enabledGates())
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
assertThat(snap.enabledGates())
.doesNotContain(FeatureGate.AUTOMATION, FeatureGate.AI_SUPPORT);
}
@Test
void grantInWarnBand_returnsWarnedButFullFeatureSet() {
// grant 100, remaining 15 used 85 = 85% (between warn 80 and degrade 100).
stubBilling(42L, freeContext(100L, 15L));
when(walletPolicyRepo.findByTeamId(42L))
.thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL)));
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.state()).isEqualTo(EntitlementState.WARNED);
assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL);
assertThat(snap.enabledGates()).hasSize(4);
}
@Test
void uncappedSubscribedTeam_nullCapNeverDegrades() {
stubBilling(42L, subscribedContext(null));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-1_000_000L);
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodCapUnits()).isNull();
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
}
@Test
void positiveNetBillable_treatedAsZeroSpend() {
// Subscribed defensive: if refunds exceed debits (positive net), spend clamps to zero.
stubBilling(42L, subscribedContext(100L));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(50L);
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodSpendUnits()).isZero();
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
}
@Test
void cacheHit_secondCallSkipsLedgerLookup() {
stubBilling(42L, subscribedContext(500L));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L);
service.getSnapshot(42L);
service.getSnapshot(42L);
service.getSnapshot(42L);
// Only one underlying ledger SUM despite 3 calls second + third hit the cache.
verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any());
assertThat(service.cacheSize()).isEqualTo(1);
}
@Test
void invalidate_dropsCacheAndCascadesToBillingService() {
stubBilling(42L, subscribedContext(500L));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L);
service.getSnapshot(42L);
service.invalidate(42L);
service.getSnapshot(42L);
verify(ledgerRepo, times(2)).sumPeriodNetBillable(eq(42L), any(), any());
// Window/cap facts must recompute together with the spend.
verify(billingService).invalidate(42L);
}
@Test
void invalidate_otherTeamLeavesEntryAlone() {
when(billingService.forTeam(any())).thenReturn(subscribedContext(500L));
when(walletPolicyRepo.findByTeamId(any())).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodNetBillable(any(), any(), any())).thenReturn(0L);
service.getSnapshot(42L);
service.invalidate(99L);
service.getSnapshot(42L);
// Only one fetch for team 42 99 invalidate didn't touch its entry.
verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any());
}
@Test
void currentMonthWindow_isStartOfMonthInclusiveToStartOfNextMonthExclusive() {
LocalDateTime mid = LocalDateTime.of(2026, 6, 15, 14, 30);
LocalDateTime[] w = EntitlementService.currentMonthWindow(mid);
assertThat(w[0]).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0));
assertThat(w[1]).isEqualTo(LocalDateTime.of(2026, 7, 1, 0, 0));
}
private void stubBilling(Long teamId, TeamBillingContext ctx) {
when(billingService.forTeam(teamId)).thenReturn(ctx);
}
/** Unsubscribed team: gated by the one-time grant (size + remaining); no monthly cap. */
private static TeamBillingContext freeContext(long grant, long remaining) {
return new TeamBillingContext(
false, null, PERIOD_START, PERIOD_END, grant, remaining, null, null, null, null);
}
/**
* Subscribed team: monthly money-derived paid-doc cap (null = uncapped); grant treated as
* exhausted (remaining 0 doesn't gate a paying team).
*/
private static TeamBillingContext subscribedContext(Long monthlyCapDocUnits) {
return new TeamBillingContext(
true,
"sub_test",
PERIOD_START,
PERIOD_END,
500L,
0L,
java.math.BigDecimal.valueOf(2),
"usd",
monthlyCapDocUnits == null ? null : monthlyCapDocUnits * 2,
monthlyCapDocUnits);
}
private static WalletPolicy walletPolicyThresholds(FeatureSet degradedSet) {
WalletPolicy p = new WalletPolicy();
p.setDegradedFeatureSet(degradedSet);
p.setWarnAtPct(80);
p.setDegradeAtPct(100);
return p;
}
}
@@ -130,7 +130,8 @@ class PaygChargeInterceptorTest {
@Test
void preHandle_openedDisposition_stashesJobIdAndDisposition() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
// API-key auth BillingCategory.API billable path engaged.
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 4, ChargeOutcome.Disposition.OPENED));
@@ -153,7 +154,8 @@ class PaygChargeInterceptorTest {
@Test
void preHandle_chargeServiceThrows_failsOpenAndIncrementsErrorCounter() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
// API-key auth API category reaches openProcess so the throw can be observed.
authenticateWithApiKey(makeUser(7L, 42L));
when(chargeService.openProcess(any(), anyList())).thenThrow(new RuntimeException("boom"));
MockMultipartHttpServletRequest req = newMultipart();
@@ -170,7 +172,7 @@ class PaygChargeInterceptorTest {
@Test
void afterCompletion_2xx_appendsStepAndRecordsOutputs() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -203,11 +205,33 @@ class PaygChargeInterceptorTest {
verify(jobService).recordOutput(eq(jobId), any());
verify(chargeService, never()).markFirstStepFailed(any(), any());
verify(chargeService, never()).decrementStepCount(any());
// Success on an OPENED process is the primary meter trigger fires now, not at close.
verify(chargeService).meterJobUsage(jobId);
}
@Test
void afterCompletion_2xx_joined_doesNotMeter() throws Exception {
// A JOINED follow-up step (chained tool on the same document) added no units when it
// joined it must not re-meter; the OPENED step already did.
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED));
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
MockHttpServletResponse res = new MockHttpServletResponse();
res.setStatus(200);
interceptor.preHandle(req, res, handlerMethodForFakeController());
interceptor.afterCompletion(req, res, handlerMethodForFakeController(), null);
verify(chargeService, never()).meterJobUsage(any());
}
@Test
void afterCompletion_5xx_opened_callsMarkFirstStepFailed() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -226,11 +250,13 @@ class PaygChargeInterceptorTest {
verify(jobService)
.appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("503"));
assertThat(meterRegistry.counter("payg.filter.refunds").count()).isEqualTo(1.0);
// First-step failure refunds never meter it.
verify(chargeService, never()).meterJobUsage(any());
}
@Test
void afterCompletion_5xx_joined_callsDecrementStepCount() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED));
@@ -249,7 +275,7 @@ class PaygChargeInterceptorTest {
@Test
void afterCompletion_4xx_appendsFailedStepNoRefundNoOutputs() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -267,6 +293,8 @@ class PaygChargeInterceptorTest {
verify(jobService, never()).recordOutput(any(), any());
verify(jobService)
.appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("422"));
// 4xx is a full charge (customer paid for the attempt), so it still meters.
verify(chargeService).meterJobUsage(jobId);
}
@Test
@@ -293,7 +321,7 @@ class PaygChargeInterceptorTest {
@Test
void afterCompletion_maxBytesExceeded_skipsOutputRecording() throws Exception {
properties.getResponse().setMaxBytes(2L);
authenticateWithUser(makeUser(7L, 42L));
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -317,7 +345,10 @@ class PaygChargeInterceptorTest {
@Test
void preHandle_desktopClientHeader_setsJobSourceDesktopApp() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
// API-key (billable) so the call reaches openProcess; the desktop header still wins the
// source mapping (checked before the API-key branch in determineSource). A manual JWT call
// is BYPASSED and never opens a process, so source wouldn't be recorded.
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -338,7 +369,9 @@ class PaygChargeInterceptorTest {
@Test
void preHandle_toolId_prefersBestMatchingPattern() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
// API-key (billable) so doPreHandle runs and records tool_id; a manual JWT call is
// BYPASSED before that point, so no tool_id is stored.
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -359,7 +392,9 @@ class PaygChargeInterceptorTest {
@Test
void preHandle_toolId_truncatesAndCountsWhenLongerThan128() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
// API-key (billable) so doPreHandle runs and records tool_id (a manual JWT call is
// BYPASSED).
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -380,7 +415,7 @@ class PaygChargeInterceptorTest {
@Test
void preHandle_pipelineHeader_setsJobSourcePipeline() throws Exception {
authenticateWithUser(makeUser(7L, 42L));
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
@@ -399,6 +434,184 @@ class PaygChargeInterceptorTest {
.isEqualTo(stirling.software.saas.payg.model.JobSource.PIPELINE);
}
// --- BillingCategory categorisation + bypass fast-path -------------------------------------
@Test
void preHandle_manualToolJwt_isBypassedAndSkipsOpenProcess() throws Exception {
// JWT-authenticated, plain @AutoJobPostMapping endpoint, no automation header BYPASSED.
// The interceptor must skip openProcess entirely (no temp files, no DB writes) and bump
// the payg.filter.bypassed counter.
authenticateWithUser(makeUser(7L, 42L));
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
boolean cont =
interceptor.preHandle(
req, new MockHttpServletResponse(), handlerMethodForFakeController());
assertThat(cont).isTrue();
verify(chargeService, never()).openProcess(any(), anyList());
assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_JOB_ID)).isNull();
assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_INPUT_TEMP_FILES)).isNull();
assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(1.0);
}
@Test
void preHandle_apiKeyAuth_setsBillingCategoryApi() throws Exception {
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.saas.payg.charge.ChargeContext.class);
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController());
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
assertThat(ctxCaptor.getValue().billingCategory())
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.API);
}
@Test
void preHandle_requiresFeatureAutomation_setsBillingCategoryAutomation() throws Exception {
// JWT auth on a @RequiresFeature(AUTOMATION) endpoint AUTOMATION category.
authenticateWithUser(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.saas.payg.charge.ChargeContext.class);
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAutomation());
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
assertThat(ctxCaptor.getValue().billingCategory())
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION);
}
@Test
void preHandle_requiresFeatureAiSupport_setsBillingCategoryAi() throws Exception {
// JWT auth on a @RequiresFeature(AI_SUPPORT) endpoint AI category.
authenticateWithUser(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.saas.payg.charge.ChargeContext.class);
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi());
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
assertThat(ctxCaptor.getValue().billingCategory())
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI);
}
@Test
void preHandle_requiresFeatureWithoutAutoJobPostMapping_reachesCategoryGate() throws Exception {
// Regression: AI controllers carry @RequiresFeature but NO @AutoJobPostMapping. They must
// still flow past the short-circuit gate so determineCategory runs (and so future
// multipart-bearing @RequiresFeature routes bill correctly). API-key auth +
// @RequiresFeature
// even without multipart inputs should land in the BillingCategory.API branch via
// determineCategory's auth check, then short-circuit inside doPreHandle because there are
// no multipart parts.
authenticateWithApiKey(makeUser(7L, 42L));
MockMultipartHttpServletRequest req = newMultipart();
// No file parts emulates a JSON-bodied AI controller request that happens to be wrapped
// as multipart. doPreHandle short-circuits with no openProcess call.
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[0]));
boolean cont =
interceptor.preHandle(
req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob());
assertThat(cont).isTrue();
verify(chargeService, never()).openProcess(any(), anyList());
// Importantly: not counted as BYPASSED the AI category was determined correctly.
assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(0.0);
}
@Test
void preHandle_aiEndpointWithoutAutoJobPostMapping_categoryIsAi() throws Exception {
// With multipart parts present + @RequiresFeature(AI_SUPPORT) but no @AutoJobPostMapping:
// the interceptor must run determineCategory and tag the ChargeContext as AI.
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.saas.payg.charge.ChargeContext.class);
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob());
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
assertThat(ctxCaptor.getValue().billingCategory())
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI);
}
@Test
void preHandle_classLevelRequiresFeatureOnly_isInScope() throws Exception {
// Mirrors AiCreateController shape: @RequiresFeature on the @RestController class.
// The interceptor must resolve it via beanType lookup and not short-circuit as
// "no annotation".
authenticateWithApiKey(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.saas.payg.charge.ChargeContext.class);
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForClassLevelAi());
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
assertThat(ctxCaptor.getValue().billingCategory())
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI);
}
@Test
void preHandle_aiEndpointWithAutomationHeader_automationWinsByPrecedence() throws Exception {
// X-Stirling-Automation: true on an @RequiresFeature(AI_SUPPORT) endpoint AUTOMATION
// (header beats annotation by design pipeline-driven AI counts as automation usage).
authenticateWithUser(makeUser(7L, 42L));
UUID jobId = UUID.randomUUID();
when(chargeService.openProcess(any(), anyList()))
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
org.mockito.ArgumentCaptor.forClass(
stirling.software.saas.payg.charge.ChargeContext.class);
MockMultipartHttpServletRequest req = newMultipart();
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
req.addHeader("X-Stirling-Automation", "true");
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi());
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
assertThat(ctxCaptor.getValue().billingCategory())
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION);
}
// --- helpers --------------------------------------------------------------------------------
private MockMultipartHttpServletRequest newMultipart() {
@@ -459,11 +672,78 @@ class PaygChargeInterceptorTest {
}
}
private static HandlerMethod handlerMethodForAutomation() {
try {
Method m = FakeController.class.getDeclaredMethod("handleAutomation");
return new HandlerMethod(new FakeController(), m);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
private static HandlerMethod handlerMethodForAi() {
try {
Method m = FakeController.class.getDeclaredMethod("handleAi");
return new HandlerMethod(new FakeController(), m);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
private static HandlerMethod handlerMethodForAiNoAutoJob() {
try {
Method m = FakeController.class.getDeclaredMethod("handleAiNoAutoJob");
return new HandlerMethod(new FakeController(), m);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
private static HandlerMethod handlerMethodForClassLevelAi() {
try {
Method m = ClassLevelAiController.class.getDeclaredMethod("classLevelAi");
return new HandlerMethod(new ClassLevelAiController(), m);
} catch (NoSuchMethodException e) {
throw new RuntimeException(e);
}
}
private void authenticateWithApiKey(User user) {
stirling.software.proprietary.security.model.ApiKeyAuthenticationToken token =
new stirling.software.proprietary.security.model.ApiKeyAuthenticationToken(
user, "test-api-key", List.of(new SimpleGrantedAuthority("ROLE_API")));
SecurityContextHolder.getContext().setAuthentication(token);
}
static class FakeController {
@AutoJobPostMapping(value = "/x", resourceWeight = 1)
public void handleAuto() {}
public void handlePlain() {}
@AutoJobPostMapping(value = "/auto", resourceWeight = 1)
@stirling.software.saas.payg.cap.RequiresFeature(
stirling.software.saas.payg.model.FeatureGate.AUTOMATION)
public void handleAutomation() {}
@AutoJobPostMapping(value = "/ai", resourceWeight = 1)
@stirling.software.saas.payg.cap.RequiresFeature(
stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT)
public void handleAi() {}
/**
* AI-controller shape: @RequiresFeature without @AutoJobPostMapping (JSON body / proxy).
*/
@stirling.software.saas.payg.cap.RequiresFeature(
stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT)
public void handleAiNoAutoJob() {}
}
/** Mirrors AiCreateController layout: @RequiresFeature on the class, plain methods. */
@stirling.software.saas.payg.cap.RequiresFeature(
stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT)
static class ClassLevelAiController {
public void classLevelAi() {}
}
/** Placeholder so AutoCloseable resources flow in some helper methods. */
@@ -1,35 +1,72 @@
package stirling.software.saas.payg.job;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import stirling.software.saas.payg.charge.JobChargeService;
/**
* Smoke test for the scheduler wiring. The interesting close logic is exercised in {@link
* JobServiceTest#closeStale_closesAllStaleJobs}; here we just confirm the scheduler bean delegates
* to {@code JobService.closeStale} and tolerates an empty result without erroring.
* Smoke test for the scheduler wiring. Confirms the scheduler closes each stale job through {@link
* JobChargeService#close} (so the Stripe meter afterCommit hook fires per job), isolates per-job
* failures, and tolerates an empty stale set without erroring. The close/meter logic itself is
* exercised in {@code JobChargeServiceTest}.
*/
class StaleJobCloserTest {
@Test
void closeStale_invokesJobService() {
JobService jobService = Mockito.mock(JobService.class);
when(jobService.closeStale()).thenReturn(3);
new StaleJobCloser(jobService).closeStale();
verify(jobService).closeStale();
private static ProcessingJob job(UUID id) {
ProcessingJob j = new ProcessingJob();
j.setId(id);
return j;
}
@Test
void closeStale_zeroClosedDoesNotThrow() {
void closeStale_closesEachStaleJobThroughChargeService() {
JobService jobService = Mockito.mock(JobService.class);
when(jobService.closeStale()).thenReturn(0);
JobChargeService chargeService = Mockito.mock(JobChargeService.class);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
when(jobService.findStale()).thenReturn(List.of(job(a), job(b)));
new StaleJobCloser(jobService).closeStale();
new StaleJobCloser(jobService, chargeService).closeStale();
verify(jobService).closeStale();
// Routed through the charge service (meter hook), NOT the bulk closeStale flip.
verify(chargeService).close(a);
verify(chargeService).close(b);
verify(jobService, never()).closeStale();
}
@Test
void closeStale_oneJobFailing_doesNotStrandTheRest() {
JobService jobService = Mockito.mock(JobService.class);
JobChargeService chargeService = Mockito.mock(JobChargeService.class);
UUID bad = UUID.randomUUID();
UUID good = UUID.randomUUID();
when(jobService.findStale()).thenReturn(List.of(job(bad), job(good)));
when(chargeService.close(bad)).thenThrow(new RuntimeException("boom"));
// Must not propagate the sweep continues to the next job.
new StaleJobCloser(jobService, chargeService).closeStale();
verify(chargeService).close(bad);
verify(chargeService).close(good);
}
@Test
void closeStale_emptyStaleSet_doesNotTouchChargeService() {
JobService jobService = Mockito.mock(JobService.class);
JobChargeService chargeService = Mockito.mock(JobChargeService.class);
when(jobService.findStale()).thenReturn(List.of());
new StaleJobCloser(jobService, chargeService).closeStale();
verify(chargeService, never()).close(any());
}
}
@@ -0,0 +1,121 @@
package stirling.software.saas.payg.meter;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.data.domain.Pageable;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
/**
* Unit tests for {@link PaygMeterReconcileScheduler}: retries unposted events for still-subscribed
* teams under the same idempotency key, skips teams that have since unsubscribed, and no-ops when
* disabled.
*/
class PaygMeterReconcileSchedulerTest {
private PaygMeterEventLogRepository logRepo;
private PaygTeamExtensionsRepository teamExtRepo;
private PaygMeterReportingService meterReportingService;
private MeterRegistry meterRegistry;
@BeforeEach
void setUp() {
logRepo = Mockito.mock(PaygMeterEventLogRepository.class);
teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
meterReportingService = Mockito.mock(PaygMeterReportingService.class);
meterRegistry = new SimpleMeterRegistry();
when(logRepo.countStuck(any())).thenReturn(0L);
}
private PaygMeterReconcileScheduler scheduler(boolean enabled) {
return new PaygMeterReconcileScheduler(
logRepo,
teamExtRepo,
meterReportingService,
enabled,
Duration.ofMinutes(5),
100,
meterRegistry);
}
private static PaygMeterEventLog row(Long teamId, UUID jobId, String key, int units) {
PaygMeterEventLog e = new PaygMeterEventLog();
e.setTeamId(teamId);
e.setJobId(jobId);
e.setIdempotencyKey(key);
e.setUnits(units);
return e;
}
private static PaygTeamExtensions ext(Long teamId, String customerId, String subscriptionId) {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(teamId);
ext.setStripeCustomerId(customerId);
ext.setPaygSubscriptionId(subscriptionId);
return ext;
}
@Test
void reconcile_subscribedTeam_reMetersUnderSameKey() {
UUID jobId = UUID.randomUUID();
when(logRepo.findRetryable(any(), any(), any(Pageable.class)))
.thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4)));
when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", "sub_live")));
scheduler(true).reconcile();
verify(meterReportingService)
.recordUsage(100L, "cus_live", 4, null, "process:" + jobId + ":close", jobId);
}
@Test
void reconcile_teamUnsubscribedSince_isSkipped() {
UUID jobId = UUID.randomUUID();
when(logRepo.findRetryable(any(), any(), any(Pageable.class)))
.thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4)));
// Customer still present but no live subscription no longer billable.
when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", null)));
scheduler(true).reconcile();
verify(meterReportingService, never())
.recordUsage(any(), any(), anyInt(), any(), any(), any());
}
@Test
void reconcile_missingTeamExtensions_isSkipped() {
UUID jobId = UUID.randomUUID();
when(logRepo.findRetryable(any(), any(), any(Pageable.class)))
.thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4)));
when(teamExtRepo.findAllById(any())).thenReturn(List.of());
scheduler(true).reconcile();
verify(meterReportingService, never())
.recordUsage(any(), any(), anyInt(), any(), any(), any());
}
@Test
void reconcile_disabled_isNoOp() {
scheduler(false).reconcile();
verifyNoInteractions(logRepo, teamExtRepo, meterReportingService);
}
}
@@ -0,0 +1,245 @@
package stirling.software.saas.payg.meter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.ConnectException;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import stirling.software.saas.payg.model.BillingCategory;
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
/**
* Covers the contract documented on {@link PaygMeterReportingService#recordUsage}: never throws,
* skips when endpoint is blank, counts non-2xx and exceptions on {@code payg.meter.errors}, and
* wraps every POST in a durable {@code payg_meter_event_log} row (pending posted / failed).
*/
class PaygMeterReportingServiceTest {
private static final String ENDPOINT =
"https://example.supabase.co/functions/v1/meter-payg-units";
private static final String TOKEN = "test-service-role-token";
private static final UUID JOB = UUID.fromString("00000000-0000-0000-0000-0000000000aa");
private RestTemplate restTemplate;
private PaygMeterEventLogRepository eventLogRepository;
private MeterRegistry meterRegistry;
private Counter errorsCounter;
@BeforeEach
void setUp() {
restTemplate = Mockito.mock(RestTemplate.class);
eventLogRepository = Mockito.mock(PaygMeterEventLogRepository.class);
meterRegistry = new SimpleMeterRegistry();
errorsCounter = meterRegistry.counter("payg.meter.errors");
}
private PaygMeterReportingService newService(String endpoint, String token) {
return new PaygMeterReportingService(
endpoint, token, restTemplate, eventLogRepository, meterRegistry);
}
@Test
void recordUsage_happyPath_postsBodyLogsPendingThenPosted() {
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
.thenReturn(new ResponseEntity<>("{\"ok\":true}", HttpStatus.OK));
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job1:close", JOB);
@SuppressWarnings("unchecked")
ArgumentCaptor<HttpEntity<Map<String, Object>>> entityCaptor =
ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate)
.exchange(
eq(ENDPOINT),
eq(HttpMethod.POST),
entityCaptor.capture(),
eq(String.class));
HttpEntity<Map<String, Object>> sent = entityCaptor.getValue();
Map<String, Object> body = sent.getBody();
assertThat(body).isNotNull();
// JSON number the edge fn type-checks team_id and ignores strings.
assertThat(body.get("team_id")).isEqualTo(100L);
assertThat(body.get("stripe_customer_id")).isEqualTo("cus_abc");
assertThat(body.get("units")).isEqualTo(5);
assertThat(body.get("idempotency_key")).isEqualTo("process:job1:close");
assertThat(body.get("metadata")).isEqualTo(Map.of("category", "API"));
HttpHeaders headers = sent.getHeaders();
assertThat(headers.getFirst("Authorization")).isEqualTo("Bearer " + TOKEN);
assertThat(headers.getContentType()).isNotNull();
assertThat(headers.getContentType().toString()).startsWith("application/json");
// Durable audit: pending row written before the POST, stamped posted after success.
verify(eventLogRepository).insertPending(100L, JOB, "process:job1:close", 5);
verify(eventLogRepository).markPosted("process:job1:close");
verify(eventLogRepository, never()).markFailed(any(), any(), any());
assertThat(errorsCounter.count()).isZero();
}
@Test
void recordUsage_5xxResponse_marksFailedIncrementsErrorCounterAndDoesNotThrow() {
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
.thenReturn(new ResponseEntity<>("oops", HttpStatus.INTERNAL_SERVER_ERROR));
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
assertThatCode(
() ->
service.recordUsage(
100L,
"cus_abc",
3,
BillingCategory.AUTOMATION,
"process:job2:close",
JOB))
.doesNotThrowAnyException();
verify(eventLogRepository).insertPending(100L, JOB, "process:job2:close", 3);
verify(eventLogRepository).markFailed(eq("process:job2:close"), eq("500"), any());
verify(eventLogRepository, never()).markPosted(any());
assertThat(errorsCounter.count()).isEqualTo(1.0);
}
@Test
void recordUsage_connectionRefused_marksFailedIncrementsErrorCounterAndDoesNotThrow() {
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
.thenThrow(new ResourceAccessException("connect refused", new ConnectException()));
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
assertThatCode(
() ->
service.recordUsage(
100L,
"cus_abc",
7,
BillingCategory.AI,
"process:job3:close",
JOB))
.doesNotThrowAnyException();
verify(eventLogRepository).insertPending(100L, JOB, "process:job3:close", 7);
verify(eventLogRepository).markFailed(eq("process:job3:close"), eq("exception"), any());
assertThat(errorsCounter.count()).isEqualTo(1.0);
}
@Test
void recordUsage_runtimeException_swallowed() {
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
.thenThrow(new RuntimeException("boom"));
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
assertThatCode(
() ->
service.recordUsage(
100L,
"cus_abc",
7,
BillingCategory.AI,
"process:job4:close",
JOB))
.doesNotThrowAnyException();
verify(eventLogRepository).markFailed(eq("process:job4:close"), eq("exception"), any());
assertThat(errorsCounter.count()).isEqualTo(1.0);
}
@Test
void recordUsage_logPendingFailure_stillPostsAndDoesNotThrow() {
// A DB hiccup writing the audit row must not stop us metering the customer.
Mockito.doThrow(new RuntimeException("db down"))
.when(eventLogRepository)
.insertPending(any(), any(), any(), anyInt());
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
.thenReturn(new ResponseEntity<>("{}", HttpStatus.OK));
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
assertThatCode(
() ->
service.recordUsage(
100L,
"cus_abc",
2,
BillingCategory.API,
"process:job9:close",
JOB))
.doesNotThrowAnyException();
verify(restTemplate).exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class));
}
@Test
void recordUsage_blankEndpoint_noopsAndDoesNotCallRestTemplateOrLog() {
PaygMeterReportingService service = newService("", TOKEN);
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job5:close", JOB);
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt());
assertThat(errorsCounter.count()).isZero();
}
@Test
void recordUsage_nullEndpoint_noopsAndDoesNotCallRestTemplate() {
PaygMeterReportingService service = newService(null, TOKEN);
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job6:close", JOB);
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt());
assertThat(errorsCounter.count()).isZero();
}
@Test
void recordUsage_zeroUnits_noopsAndDoesNotCallRestTemplateOrLog() {
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
service.recordUsage(100L, "cus_abc", 0, BillingCategory.API, "process:job7:close", JOB);
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt());
assertThat(errorsCounter.count()).isZero();
}
@Test
void recordUsage_blankServiceRoleToken_postsWithoutAuthorizationHeader() {
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
.thenReturn(new ResponseEntity<>("{}", HttpStatus.OK));
PaygMeterReportingService service = newService(ENDPOINT, "");
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job8:close", JOB);
@SuppressWarnings("unchecked")
ArgumentCaptor<HttpEntity<Map<String, Object>>> entityCaptor =
ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate, times(1))
.exchange(
eq(ENDPOINT),
eq(HttpMethod.POST),
entityCaptor.capture(),
eq(String.class));
assertThat(entityCaptor.getValue().getHeaders().getFirst("Authorization")).isNull();
}
}
@@ -107,6 +107,16 @@ class PaygEntitiesSmokeTest {
assertThat(entry.getAmountUnits()).isEqualTo(-4);
}
@Test
void walletLedgerEntry_billingCategoryRoundTrips() {
WalletLedgerEntry entry = new WalletLedgerEntry();
// Default (unset) is null captured by both the legacy debit path and pre-V16 rows.
assertThat(entry.getBillingCategory()).isNull();
entry.setBillingCategory(BillingCategory.AUTOMATION);
assertThat(entry.getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION);
}
@Test
void walletPolicy_carriesSensibleDefaults() {
WalletPolicy policy = new WalletPolicy();
@@ -148,4 +158,29 @@ class PaygEntitiesSmokeTest {
assertThat(row.getDiffPct()).isNegative();
}
@Test
void paygShadowCharge_billingCategoryAndJobSourceRoundTrip() {
PaygShadowCharge row = new PaygShadowCharge();
assertThat(row.getBillingCategory()).isNull();
assertThat(row.getJobSource()).isNull();
row.setBillingCategory(BillingCategory.AI);
row.setJobSource(JobSource.API);
assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.AI);
assertThat(row.getJobSource()).isEqualTo(JobSource.API);
}
@Test
void billingCategory_listingOrderIsStable() {
// No downstream relies on ordinal() today, but the comment in the enum claims BYPASSED is
// declared first as the default sentinel guard against an accidental reorder.
assertThat(BillingCategory.values())
.containsExactly(
BillingCategory.BYPASSED,
BillingCategory.API,
BillingCategory.AI,
BillingCategory.AUTOMATION);
}
}
@@ -3090,6 +3090,18 @@ integration = "Integration Configuration"
security = "Security Configuration"
system = "System Configuration"
[config.payg]
label = "Billing & usage"
section = "Pay-as-you-go"
[connectionMode.status]
localOffline = "Offline mode running"
localOnline = "Offline mode running"
saas = "Connected to Stirling Cloud"
selfhostedChecking = "Connected to self-hosted server (checking...)"
selfhostedOffline = "Self-hosted server unreachable"
selfhostedOnline = "Connected to self-hosted server"
[convert]
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as"
autoRotate = "Auto Rotate"
@@ -5132,6 +5144,239 @@ title = "Page Ranges"
bullet1 = "<strong>all</strong> → selects all pages"
title = "Special Keywords"
[pageSelection.tooltip.syntax]
text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported."
title = "Syntax Basics"
[pageSelection.tooltip.syntax.bullets]
keywords = "Keywords: odd, even"
numbers = "Numbers/ranges: 5, 10-20"
progressions = "Progressions: 3n, 4n+1"
[pageSelection.tooltip.tips]
bullet1 = "Page numbers start from 1 (not 0)"
bullet2 = "Spaces are automatically removed"
bullet3 = "Invalid expressions are ignored"
text = "Keep these guidelines in mind:"
title = "Tips"
[payg]
subtitle = "Pay-as-you-go — you only pay for what you process. Billing period {{start}} {{end}}."
[payg.activity]
subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here."
title = "Recent billable activity"
units = "units"
viewAll = "View all"
[payg.cap]
amount = "Cap amount"
currency = "Currency"
custom = "Custom"
degradeAt = "Limit spend at"
degradeAtDesc = "Set below 100% to leave yourself headroom."
docsEstimate = "≈ {{docs}} processed PDFs / month"
docsRate = "at {{rate}} / PDF"
noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time."
noCapLabel = "No cap"
perMonth = "/ month"
preview = "{{money}} ≈ {{units}} PDFs per month"
previewNote = "At current pricing. Final translation happens server-side on save."
previewShort = "≈ {{units}} PDFs per month at current pricing."
save = "Update cap"
subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier."
title = "Monthly spending cap"
warnAt = "Warn me at"
warnAtDesc = "Notify when usage crosses this threshold."
[payg.checkout]
connecting = "Connecting to Stripe…"
errorTitle = "Stripe error"
[payg.checkout.error]
startFailed = "Couldn't start checkout session"
[payg.checkout.mock]
backend = "Backend is in mock mode — no real Stripe session was created."
continue = "Continue with mock subscription"
noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured."
title = "Stripe Embedded Checkout (mock mode)"
[payg.confirm]
body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs."
capValue = "{{symbol}}{{amount}} / month"
noCap = "No cap"
note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page."
summaryLabel = "Monthly ceiling"
title = "Welcome to the Processor plan"
[payg.error]
body = "We couldn't reach the billing service. Refresh the page to try again."
title = "Couldn't load your plan"
[payg.exhausted]
body = "You've used all 500 of your free PDFs. Upgrade to Processor to keep going."
cta = "Go to billing"
title = "You've used your free PDFs"
[payg.free.cta]
benefit1Body = "chain tools, schedule runs, batch process"
benefit1Title = "Automation pipelines"
benefit2Body = "summarise, classify, redact, AI-OCR"
benefit2Title = "AI tools"
benefit3Body = "call any Stirling endpoint programmatically"
benefit3Title = "API access"
button = "Turn on Processor →"
reassurance = "No minimum · Set a $0 cap to test · Cancel anytime"
subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control."
title = "Turn on the Processor plan"
[payg.free.editor]
eyebrow = "Editor plan · Always free"
[payg.free.explainer]
alwaysFreeBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from."
alwaysFreeForYouBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted."
alwaysFreeForYouLabel = "Always free for you"
alwaysFreeLabel = "Always free"
countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor."
countsLabel = "Counts toward your 500 free"
sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, ask your owner to enable Processor."
sharedLabel = "Shared with the team"
[payg.free.header]
subtitle = "Editor plan — manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}."
[payg.free.hero]
capSuffix = "/ {{limit}} free PDFs"
eyebrow = "Your free PDFs"
metaCategories = "Automation · AI · API requests"
neverResets = "One-time — never resets"
[payg.free.member]
body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like — automation, AI, and API access share the team's one-time allowance of 500 free PDFs."
ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like."
title = "Want to process more than your 500 free PDFs?"
[payg.free.proc]
eyebrow = "Processor plan · metered"
[payg.free.state]
approachingLimit = "Approaching limit"
limitReached = "Limit reached"
plentyLeft = "Plenty left"
[payg.gates]
ai = "AI tools (AI Create, suggestions, AI-OCR)"
automation = "Automations & pipelines"
client = "Browser-only tools (viewer, page editor, file management)"
offsite = "Server tools (compress, OCR, convert, watermark…)"
pauses = "pauses at cap"
subtitle = "Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised."
title = "What happens when the cap is reached"
[payg.member]
askLeader = "Only your team owner can change the cap."
[payg.members]
docs = "PDFs"
subtitle = "Billable PDFs each teammate has processed this period."
title = "Team member usage"
[payg.role]
leader = "Team owner"
member = "Member"
[payg.signupRequired]
body = "Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
cancel = "Not now"
cta = "Sign up free"
subtext = "Creating an account is free and takes a few seconds. No credit card required."
title = "Sign up to use {{category}}"
[payg.signupRequired.category]
ai = "AI features"
api = "this tool"
automation = "automations"
default = "this feature"
[payg.state]
degraded = "Cap reached"
full = "Healthy"
warned = "Approaching cap"
[payg.stripe]
open = "Open billing portal"
subtitle = "Receipts, invoices, payment method, billing currency."
title = "Manage billing in Stripe"
[payg.stripe.toast.unavailable]
body = "Billing portal isn't available right now. Try again in a moment."
title = "Billing portal unavailable"
[payg.upgrade]
closeAria = "Close"
[payg.upgrade.button]
back = "← Back"
cancel = "Cancel"
continue = "Continue →"
finish = "Finish"
[payg.upgrade.cap]
customPlaceholder = "Or enter your own amount ($0 keeps it free)"
help = "We'll never charge above this. Set $0 if you want to keep everything free while testing."
noCapHint = "You can still cancel anytime from the customer portal."
noCapLabel = "No cap — I'll manage spend manually"
perMonthSuffix = "/ month"
presetsAria = "Monthly cap preset"
title = "Set your monthly spend ceiling"
usdNote = "Estimated in USD. You can adjust your cap any time after subscribing — in your own currency."
[payg.upgrade.checkout]
capLabel = "Monthly ceiling:"
capValue = "{{symbol}}{{amount}} / month"
edit = "Edit"
help = "Stripe handles your card details. Stirling never sees them."
loading = "Loading checkout…"
noCap = "No cap"
title = "Add your payment method"
[payg.upgrade.footer]
capHint = "You can change your cap any time later."
checkoutHint = "Card details handled by Stripe — never touched by Stirling."
confirmHint = "Your wallet will refresh automatically in a moment."
[payg.upgrade.help]
aiBody = "summarise, classify, redact, AI-OCR"
aiTitle = "AI tools"
apiBody = "programmatic access to any Stirling endpoint"
apiTitle = "API calls"
automationBody = "chained tools or scheduled runs that don't need clicks"
automationTitle = "Automation pipelines"
footnote = "Manual tools — viewing, editing, merging, splitting, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click."
title = "What we count toward billing"
[payg.upgrade.promise]
body = "You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge."
highlight = "Manual tools stay free, always."
[payg.upgrade.steps]
cap = "Set monthly ceiling"
payment = "Add payment method"
[payg.upgrade.title]
confirm = "You're subscribed"
default = "Upgrade to Processor plan"
[payg.usage]
credit = "{{amount}} account credit"
docs = "documents"
resetsIn = "Resets in {{days}} days"
resetsTomorrow = "Resets tomorrow"
spent = "{{spend}} of {{cap}} used"
thisPeriod = "This billing period"
[payment]
autoClose = "This window will close automatically..."
canCloseWindow = "You can now close this window."
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
"adminStorageSharing",
"adminMcp",
"help",
"payg",
] as const;
// Derive the type from the array
@@ -20,6 +20,7 @@ export interface AppConfig {
enableDesktopInstallSlide?: boolean;
premiumEnabled?: boolean;
premiumKey?: string;
paygEnabled?: boolean;
termsAndConditions?: string;
privacyPolicy?: string;
cookiePolicy?: string;
+2
View File
@@ -13,6 +13,7 @@ import AuthCallback from "@app/routes/AuthCallback";
import ResetPassword from "@app/routes/ResetPassword";
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
// Import global styles
import "@app/styles/tailwind.css";
@@ -36,6 +37,7 @@ export default function App() {
<AppLayout>
<OnboardingBootstrap />
<TrialExpiredBootstrap />
<SignupRequiredBootstrap />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
+11 -68
View File
@@ -18,11 +18,8 @@ import {
CreditSummary,
SubscriptionInfo,
CreditCheckResult,
ApiCredits,
} from "@app/types/credits";
import apiClient, {
setGlobalCreditUpdateCallback,
} from "@app/services/apiClient";
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
import { synchronizeUserUpgrade } from "@app/services/userService";
import {
syncOAuthAvatar,
@@ -145,72 +142,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [profilePictureMetadata, setProfilePictureMetadata] =
useState<ProfilePictureMetadata | null>(null);
const fetchCredits = useCallback(
async (sessionToUse?: Session | null) => {
const currentSession = sessionToUse ?? session;
if (!currentSession?.user) {
console.debug("[Auth Debug] No user session, skipping credit fetch");
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
return;
}
try {
console.debug(
"[Auth Debug] Fetching credits for user:",
currentSession.user.id,
);
// Auto-fires on session init and TOKEN_REFRESHED; a backend 401 must
// stay local rather than trigger the global login redirect.
const response = await apiClient.get<ApiCredits>("/api/v1/credits", {
suppressErrorToast: true,
skipAuthRedirect: true,
});
const apiCredits = response.data;
// Map server payload to app CreditSummary
const credits: CreditSummary = {
currentCredits: apiCredits.totalAvailableCredits,
maxCredits:
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
creditsUsed:
apiCredits.weeklyCreditsAllocated -
apiCredits.weeklyCreditsRemaining +
(apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
creditsRemaining: apiCredits.totalAvailableCredits,
resetDate: apiCredits.weeklyResetDate,
weeklyAllowance: apiCredits.weeklyCreditsAllocated,
};
setCreditSummary(credits);
setCreditBalance(credits.creditsRemaining);
const subscriptionInfo: SubscriptionInfo = {
status: "active",
tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free",
creditsPerWeek: credits.weeklyAllowance,
maxCredits: credits.maxCredits,
};
setSubscription(subscriptionInfo);
console.debug("[Auth Debug] Credits fetched successfully:", credits);
} catch (error: unknown) {
console.debug("[Auth Debug] Failed to fetch credits:", error);
// Don't set error state for credit fetching failures to avoid disrupting auth flow
// Credits might not be available in all deployments
setCreditBalance(null);
setCreditSummary(null);
setSubscription(null);
}
},
[session],
);
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
// consumers of useAuth() that still destructure creditBalance / refreshCredits
// compile cleanly; values just stay null forever and refreshCredits is a noop.
// _ underscore on the param keeps the public signature stable for callers.
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
/* legacy credit fetch removed — see comment above */
}, []);
const refreshCredits = useCallback(async () => {
await fetchCredits();
}, [fetchCredits]);
/* legacy credit refresh removed — useWallet() replaces this */
}, []);
const fetchProStatus = useCallback(
async (sessionToUse?: Session | null) => {
@@ -0,0 +1,129 @@
import { useEffect, useState, useMemo } from "react";
import { Modal, Stack, Button, Text } from "@mantine/core";
import PersonAddIcon from "@mui/icons-material/PersonAdd";
import { useTranslation } from "react-i18next";
import { withBasePath } from "@app/constants/app";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import type { PaygSignupRequiredDetail } from "@app/services/paygErrorInterceptor";
/**
* Bootstrap that listens for {@code payg:signupRequired} (dispatched by
* the {@code apiClient} response interceptor when an anonymous user hits
* a billable endpoint and the server returns {@code 401 SIGNUP_REQUIRED})
* and opens a Mantine modal explaining the free 500-op/month allowance
* with a "Sign up free" CTA.
*
* <h2>Why an event bus instead of direct render</h2>
* The {@code apiClient} module is created at app boot, outside the React
* tree, and can't import JSX. We bridge with a {@code CustomEvent}: the
* interceptor dispatches, this bootstrap (mounted near the app root)
* listens and renders. Same pattern as {@code TrialExpiredBootstrap},
* just driven by a request-side trigger rather than auth state.
*
* <h2>De-duping</h2>
* If the user fires multiple billable requests in quick succession (e.g.
* clicking a tool button twice), only one modal opens the listener
* ignores the event when the modal is already visible. The modal closes
* on backdrop click or Escape; we don't gate it on a localStorage flag
* because this is a deterministic "you need an account" UI, not a one-
* time onboarding nudge.
*/
export default function SignupRequiredBootstrap() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [category, setCategory] = useState<string | null>(null);
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent<PaygSignupRequiredDetail>).detail;
// De-dupe: if the modal is already open, the existing copy wins.
// The user is already being prompted; piling another open call on
// top would just flicker the same content.
setOpened((wasOpen) => {
if (!wasOpen) {
setCategory(detail?.category ?? null);
}
return true;
});
};
window.addEventListener("payg:signupRequired", handler as EventListener);
return () =>
window.removeEventListener(
"payg:signupRequired",
handler as EventListener,
);
}, []);
// Map the server's gate categories to user-facing nouns. The server
// returns the FeatureGate name (AI, AUTOMATION, API); the user has
// no idea what those are in raw form, so we pretty-print here. The
// fallback "this feature" keeps the modal sensible if the BE adds
// a category we don't know about.
const categoryNoun = useMemo(() => {
switch ((category ?? "").toUpperCase()) {
case "AI":
return t("payg.signupRequired.category.ai", "AI features");
case "AUTOMATION":
return t("payg.signupRequired.category.automation", "automations");
case "API":
return t("payg.signupRequired.category.api", "this tool");
default:
return t("payg.signupRequired.category.default", "this feature");
}
}, [category, t]);
const handleSignUp = () => {
window.location.href = withBasePath("/signup");
};
return (
<Modal
opened={opened}
onClose={() => setOpened(false)}
withCloseButton
centered
size="md"
radius="lg"
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
title={
<Text fw={700} size="lg">
{t("payg.signupRequired.title", "Sign up to use {{category}}", {
category: categoryNoun,
})}
</Text>
}
>
<Stack gap="md">
<Text>
{t(
"payg.signupRequired.body",
"Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
)}
</Text>
<Text size="sm" c="dimmed">
{t(
"payg.signupRequired.subtext",
"Creating an account is free and takes a few seconds. No credit card required.",
)}
</Text>
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: "0.5rem",
}}
>
<Button variant="default" onClick={() => setOpened(false)}>
{t("payg.signupRequired.cancel", "Not now")}
</Button>
<Button
leftSection={<PersonAddIcon style={{ fontSize: 16 }} />}
onClick={handleSignUp}
>
{t("payg.signupRequired.cta", "Sign up free")}
</Button>
</div>
</Stack>
</Modal>
);
}
@@ -61,6 +61,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
window.removeEventListener("appConfig:notice", handler as EventListener);
}, []);
// Full-screen overlays that live inside our React tree (e.g. the PAYG
// UpgradeModal, portal'd to document.body) announce themselves here so we
// can hide — not unmount — while they're up. Unmounting would kill the
// overlay itself since it's our descendant; hiding keeps all section state
// (active tab, scroll, wallet data) intact for when the overlay closes.
const [overlayActive, setOverlayActive] = useState(false);
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as
| { open?: boolean }
| undefined;
setOverlayActive(Boolean(detail?.open));
};
window.addEventListener("appConfig:overlay", handler as EventListener);
return () =>
window.removeEventListener("appConfig:overlay", handler as EventListener);
}, []);
// When the modal opens to Plan, proactively refresh credits and log values
useEffect(() => {
if (!opened) return;
@@ -106,7 +124,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
// Left navigation structure and icons
// Left navigation structure and icons. The Plan tab now internally branches
// free vs subscribed × leader vs member via useWallet(), so the modal no
// longer plumbs paygEnabled / isLeader through to the nav builder.
const configNavSections = useMemo(
() =>
createSaasConfigNavSections(Overview, openLogoutConfirm, {
@@ -139,7 +159,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
opened={opened}
onClose={onClose}
title={null}
size={isMobile ? "100%" : 980}
size={isMobile ? "100%" : 1200}
centered
radius="lg"
withCloseButton={false}
@@ -147,6 +167,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
// Hidden (not closed) while a child overlay like the PAYG UpgradeModal
// is up — see the appConfig:overlay listener above. The focus trap and
// escape/outside-close must release too: the trap would steal focus
// from the Stripe card iframe, and Escape would close US underneath
// the overlay — unmounting the checkout mid-payment.
styles={{
root: { display: overlayActive ? "none" : undefined },
}}
trapFocus={!overlayActive}
closeOnEscape={!overlayActive}
closeOnClickOutside={!overlayActive}
>
<div className="modal-container">
{/* Left navigation */}
@@ -42,10 +42,7 @@ export function ManageBillingButton({
url: string;
error?: string;
}>("manage-billing", {
body: {
name: "Functions",
return_url: returnUrl,
},
body: { return_url: returnUrl },
});
if (error) throw error;
if (!data || "error" in data)
@@ -0,0 +1,624 @@
/* Pay-as-you-go settings screen visual polish.
Uses the app's semantic theme tokens (theme.css) so it tracks light/dark. */
.payg {
--payg-accent: #0a8bff;
--payg-accent-soft: rgba(10, 139, 255, 0.1);
--payg-radius: 14px;
--payg-gap: 20px;
/* Cards sit ON the modal content bg; in light that's white-on-white so we
lean on border + shadow. Tokens are overridden per scheme below. */
--payg-card-bg: var(--bg-surface);
--payg-card-border: var(--border-default);
--payg-inset-bg: var(--bg-raised);
--payg-divider: var(--border-subtle);
}
/* Dark mode: the modal content bg is #2a2f36 and so is --bg-surface, so plain
cards vanish. Lift cards a shade above the modal and strengthen borders. */
[data-mantine-color-scheme="dark"] .payg {
--payg-card-bg: #313842;
--payg-card-border: #3d444e;
--payg-inset-bg: #272c33;
--payg-accent-soft: rgba(10, 139, 255, 0.16);
--payg-divider: #3d444e;
}
/* ── Header ─────────────────────────────────────────────────────────── */
.payg-header__title {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.payg-header__subtitle {
margin-top: 4px;
font-size: 0.875rem;
color: var(--text-muted);
}
.payg-role-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.payg-role-pill[data-leader="true"] {
background: var(--payg-accent-soft);
color: var(--payg-accent);
border: 1px solid rgba(10, 139, 255, 0.25);
}
.payg-role-pill[data-leader="false"] {
background: var(--bg-muted);
color: var(--text-muted);
border: 1px solid var(--border-default);
}
/* ── Plan header: free-vs-metered split (shared by free + subscribed) ──── */
.payg-planhead {
padding: 14px 20px;
border-radius: var(--payg-radius);
background: var(--payg-card-bg);
border: 1px solid var(--payg-card-border);
}
.payg-planhead__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.payg-planhead__eyebrow {
font-size: 0.78rem;
color: var(--text-muted);
}
.payg-planhead__split {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
}
.payg-planhead__col {
padding-right: 22px;
}
.payg-planhead__col--meter {
padding-right: 0;
padding-left: 22px;
border-left: 1px solid var(--payg-divider);
}
.payg-planhead__lbl {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 8px;
}
.payg-planhead__lbl--free {
color: #10b981;
}
.payg-planhead__lbl--meter {
color: var(--payg-accent);
}
.payg-planhead__lbl-icon {
font-size: 1rem !important;
}
.payg-planhead__title {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
line-height: 1.25;
}
.payg-planhead__body {
margin: 5px 0 0;
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.5;
}
@media (max-width: 640px) {
.payg-planhead__split {
grid-template-columns: 1fr;
gap: 16px;
}
.payg-planhead__col {
padding-right: 0;
}
.payg-planhead__col--meter {
padding-left: 0;
padding-top: 16px;
border-left: none;
border-top: 1px solid var(--payg-divider);
}
}
/* ── Generic card ───────────────────────────────────────────────────── */
.payg-card {
background: var(--payg-card-bg);
border: 1px solid var(--payg-card-border);
border-radius: var(--payg-radius);
padding: 16px 20px;
box-shadow: var(--shadow-xs);
}
.payg-card__title {
font-size: 0.95rem;
font-weight: 650;
color: var(--text-primary);
}
.payg-card__subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Hero usage panel ───────────────────────────────────────────────── */
.payg-hero {
position: relative;
overflow: hidden;
border-radius: var(--payg-radius);
padding: 18px 22px;
border: 1px solid var(--payg-card-border);
background: var(--payg-card-bg);
box-shadow: var(--shadow-xs);
}
.payg-hero::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.9;
}
.payg-hero[data-state="FULL"]::before {
background: linear-gradient(
135deg,
rgba(10, 139, 255, 0.12) 0%,
rgba(10, 139, 255, 0) 55%
);
}
.payg-hero[data-state="WARNED"]::before {
background: linear-gradient(
135deg,
rgba(234, 179, 8, 0.16) 0%,
rgba(234, 179, 8, 0) 55%
);
}
.payg-hero[data-state="DEGRADED"]::before {
background: linear-gradient(
135deg,
rgba(239, 68, 68, 0.16) 0%,
rgba(239, 68, 68, 0) 55%
);
}
.payg-hero__inner {
position: relative;
z-index: 1;
}
.payg-hero__eyebrow {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.payg-hero__figure {
display: flex;
align-items: baseline;
gap: 10px;
margin-top: 6px;
}
.payg-hero__spend {
font-size: 2.35rem;
font-weight: 750;
line-height: 1;
letter-spacing: -0.03em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.payg-hero__cap {
font-size: 1rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.payg-hero__meta {
margin-top: 14px;
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.payg-hero__meta-dot {
color: var(--border-strong);
}
.payg-hero__credit {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-green-700);
background: rgba(34, 197, 94, 0.14);
}
[data-mantine-color-scheme="dark"] .payg-hero__credit {
color: #4ade80;
background: rgba(34, 197, 94, 0.18);
}
/* Make Mantine default-variant buttons read clearly on the lifted cards in
both themes (their stock border can wash out against --payg-card-bg). */
.payg .mantine-Button-root[data-variant="default"] {
border-color: var(--payg-card-border);
}
/* Dark mode: the stock default button is a flat slab that disappears into the
card. Lift it with a soft top-down gradient + brighter border, and warm the
hover so the buttons feel tactile against --payg-card-bg (#313842). Driven
through Mantine's --button-* vars. The same rule covers disabled buttons
(Cancel/Update cap before the form is dirty) so they read identically to the
always-enabled ones Mantine only fades them via opacity. */
[data-mantine-color-scheme="dark"]
.payg
.mantine-Button-root[data-variant="default"] {
--button-bg: linear-gradient(180deg, #3d4651 0%, #353d48 100%);
--button-hover: linear-gradient(180deg, #475160 0%, #3d4654 100%);
--button-bd: 1px solid #4b5563;
--button-color: var(--mantine-color-white);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 1px 2px rgba(0, 0, 0, 0.25);
}
/* Mantine recolours disabled buttons to a dim grey via a hard-coded color/bg
on the disabled selector, overriding --button-color. Re-assert white + the
gradient so disabled buttons match the rest. */
[data-mantine-color-scheme="dark"]
.payg
.mantine-Button-root[data-variant="default"]:disabled,
[data-mantine-color-scheme="dark"]
.payg
.mantine-Button-root[data-variant="default"][data-disabled] {
background: var(--button-bg);
color: var(--mantine-color-white);
}
/* Status chip */
.payg-status {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 13px;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.payg-status__dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.payg-status[data-state="FULL"] {
background: var(--color-green-100);
color: var(--color-green-700);
}
.payg-status[data-state="FULL"] .payg-status__dot {
background: var(--color-green-500);
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
}
.payg-status[data-state="WARNED"] {
background: var(--color-yellow-100);
color: var(--color-yellow-700);
}
.payg-status[data-state="WARNED"] .payg-status__dot {
background: var(--color-yellow-500);
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
}
.payg-status[data-state="DEGRADED"] {
background: var(--color-red-100);
color: var(--color-red-700);
}
.payg-status[data-state="DEGRADED"] .payg-status__dot {
background: var(--color-red-500);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
}
/* Segmented usage bar */
.payg-bar {
margin-top: 18px;
height: 10px;
border-radius: 999px;
background: var(--bg-muted);
overflow: hidden;
position: relative;
}
.payg-bar__fill {
height: 100%;
border-radius: 999px;
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
.payg-bar__fill[data-state="FULL"] {
background: linear-gradient(90deg, #0a8bff, #38bdf8);
}
.payg-bar__fill[data-state="WARNED"] {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.payg-bar__fill[data-state="DEGRADED"] {
background: linear-gradient(90deg, #dc2626, #f87171);
}
/* ── "What counts as a document?" expandable help ───────────────────── */
.payg-help {
margin-top: 12px;
}
.payg-help__toggle {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0;
border: none;
background: none;
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
.payg-help__toggle:hover {
color: var(--text-primary);
}
.payg-help__chevron {
transition: transform 0.15s ease;
}
.payg-help__toggle[aria-expanded="true"] .payg-help__chevron {
transform: rotate(180deg);
}
.payg-help__panel {
margin-top: 10px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--payg-card-border);
background: var(--bg-muted);
font-size: 0.8125rem;
line-height: 1.45;
color: var(--text-secondary);
}
.payg-help__panel ul {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
}
/* ── Cap preview strip ──────────────────────────────────────────────── */
.payg-preview {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 16px;
border-radius: 10px;
background: var(--payg-accent-soft);
border: 1px solid rgba(10, 139, 255, 0.2);
}
.payg-preview__icon {
color: var(--payg-accent);
display: flex;
}
.payg-preview__main {
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 550;
}
.payg-preview__note {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
/* ── Gates grid ─────────────────────────────────────────────────────── */
.payg-gates {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 4px;
}
@media (max-width: 720px) {
.payg-gates {
grid-template-columns: 1fr;
}
}
.payg-gate {
display: flex;
align-items: center;
gap: 12px;
padding: 13px 15px;
border-radius: 11px;
border: 1px solid var(--payg-card-border);
background: var(--payg-inset-bg);
}
.payg-gate[data-enabled="false"] {
border-style: dashed;
opacity: 0.85;
}
.payg-gate__chip {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 9px;
flex-shrink: 0;
}
.payg-gate[data-enabled="true"] .payg-gate__chip {
background: rgba(34, 197, 94, 0.16);
color: #22c55e;
}
.payg-gate[data-enabled="false"] .payg-gate__chip {
background: rgba(239, 68, 68, 0.16);
color: #f87171;
}
.payg-gate__label {
font-size: 0.8125rem;
line-height: 1.35;
color: var(--text-primary);
flex: 1;
}
.payg-gate__tag {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 999px;
flex-shrink: 0;
white-space: nowrap;
}
.payg-gate__tag[data-variant="on"] {
color: var(--text-muted);
background: var(--bg-muted);
}
.payg-gate__tag[data-variant="pause"] {
color: #dc2626;
background: rgba(239, 68, 68, 0.12);
}
[data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] {
color: #f87171;
background: rgba(239, 68, 68, 0.18);
}
/* ── Member rows ────────────────────────────────────────────────────── */
.payg-member {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 4px;
border-bottom: 1px solid var(--payg-divider);
}
.payg-member:last-child {
border-bottom: none;
}
.payg-member__avatar {
width: 34px;
height: 34px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 650;
color: #fff;
flex-shrink: 0;
}
.payg-member__name {
font-size: 0.875rem;
font-weight: 550;
color: var(--text-primary);
}
.payg-member__email {
font-size: 0.75rem;
color: var(--text-muted);
}
.payg-member__usage {
text-align: right;
min-width: 120px;
}
.payg-member__usage-num {
font-size: 0.8125rem;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.payg-member__minibar {
height: 5px;
width: 90px;
border-radius: 999px;
background: var(--payg-inset-bg);
overflow: hidden;
margin-top: 5px;
margin-left: auto;
}
.payg-member__minibar-fill {
height: 100%;
border-radius: 999px;
background: var(--payg-accent);
}
/* ── Activity feed ──────────────────────────────────────────────────── */
.payg-activity-row {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 4px;
border-bottom: 1px solid var(--payg-divider);
}
.payg-activity-row:last-child {
border-bottom: none;
}
.payg-activity__dot {
width: 9px;
height: 9px;
border-radius: 999px;
flex-shrink: 0;
}
.payg-activity__dot[data-kind="ai"] {
background: var(--category-color-formatting); /* purple */
}
.payg-activity__dot[data-kind="automation"] {
background: var(--category-color-automation); /* pink */
}
.payg-activity__label {
font-size: 0.8438rem;
color: var(--text-primary);
flex: 1;
}
.payg-activity__ts {
font-size: 0.75rem;
color: var(--text-muted);
}
.payg-activity__kind {
font-size: 0.625rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
background: var(--bg-muted);
padding: 2px 8px;
border-radius: 999px;
}
.payg-activity__units {
font-size: 0.8125rem;
font-weight: 650;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
min-width: 58px;
text-align: right;
}
/* ── Stripe CTA card ────────────────────────────────────────────────── */
.payg-stripe {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-radius: var(--payg-radius);
border: 1px solid rgba(99, 91, 255, 0.28);
background: linear-gradient(
135deg,
rgba(99, 91, 255, 0.1) 0%,
rgba(99, 91, 255, 0.02) 100%
);
}
.payg-stripe__title {
font-size: 0.9375rem;
font-weight: 650;
color: var(--text-primary);
}
.payg-stripe__subtitle {
font-size: 0.8125rem;
color: var(--text-muted);
margin-top: 2px;
}
@@ -0,0 +1,788 @@
/**
* Pay-as-you-go billing & usage section the SUBSCRIBED leader/member views.
*
* All data comes from the real {@code Wallet} snapshot ({@code GET
* /api/v1/payg/wallet} via {@link useWallet}); there is no mock fallback. What
* the wallet doesn't provide, the UI doesn't show:
*
* - spend + cap render in the units/USD the backend actually returns
* ({@code spendUnitsThisPeriod}, {@code capUsd}, {@code noCap})
* - the per-category breakdown comes from {@code categoryBreakdown}
* (wallet_category_summary view)
* - money-equivalent display and the unitsmoney cap preview need
* stripe.prices via Sync Engine (design §13 / PR-C2) and are deliberately
* absent until that ships
* - the activity feed renders {@code wallet.recent}, which is {@code []} in
* V1 so it shows a real empty state, not fabricated rows
*/
import React, { useState } from "react";
import { Button, Group, Stack, Text } from "@mantine/core";
import { useRenderCount } from "@app/hooks/useRenderCount";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import LockIcon from "@mui/icons-material/LockOutlined";
import CheckIcon from "@mui/icons-material/CheckRounded";
import HelpOutlineIcon from "@mui/icons-material/HelpOutlineRounded";
import ExpandMoreIcon from "@mui/icons-material/ExpandMoreRounded";
import BoltIcon from "@mui/icons-material/BoltRounded";
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
import { alert as showToast } from "@app/components/toast";
// Relative (not @app/*) so the co-located CSS + sibling component resolve directly.
// eslint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
import { useTranslation } from "react-i18next";
import type { Wallet } from "@app/hooks/useWallet";
// ─── Types ────────────────────────────────────────────────────────────────
type Gate = "OFFSITE_PROCESSING" | "AUTOMATION" | "AI_SUPPORT" | "CLIENT_SIDE";
interface PaygProps {
role: "LEADER" | "MEMBER";
/** Real wallet snapshot from {@code useWallet}. Single source of truth. */
wallet: Wallet;
/**
* Persist a cap change. Provided by {@code Plan} {@code useWallet} for the
* leader view; absent on the member view (read-only).
*/
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
/**
* Open the Stripe Customer Portal. When omitted the Stripe card is hidden.
* On error the implementation shows a friendly toast and resolves callers
* don't need to wrap in try/catch.
*/
onOpenPortal?: () => Promise<void>;
}
// ─── Helpers ──────────────────────────────────────────────────────────────
// Stable-ish avatar colour from a string.
const AVATAR_COLORS = [
"#0a8bff",
"#8b5cf6",
"#ec4899",
"#10b981",
"#f59e0b",
"#06b6d4",
];
function avatarColor(seed: string): string {
let h = 0;
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
}
function gateLabel(
g: Gate,
t: (k: string, fallback: string) => string,
): string {
switch (g) {
case "OFFSITE_PROCESSING":
return t(
"payg.gates.offsite",
"Server tools (compress, OCR, convert, watermark…)",
);
case "AUTOMATION":
return t("payg.gates.automation", "Automations & pipelines");
case "AI_SUPPORT":
return t("payg.gates.ai", "AI tools (AI Create, suggestions, AI-OCR)");
case "CLIENT_SIDE":
return t(
"payg.gates.client",
"Browser-only tools (viewer, page editor, file management)",
);
}
}
// ─── "What counts as a document?" help ──────────────────────────────────────
/**
* Expandable explainer for the billing unit. Shared by the subscribed hero
* here and the free-tier hero in {@code PaygFree.tsx}. The bullets state the
* real charge mechanics (DefaultDocumentClassifier + JobChargeService)
* without hardcoding the policy-tunable thresholds: each non-empty file is
* at least one document; page count / file size can make it more; chained
* steps on the same file join the open process instead of re-charging; a
* first-step failure writes a compensating refund.
*/
export function DocHelp() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="payg-help">
<button
type="button"
className="payg-help__toggle"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<HelpOutlineIcon sx={{ fontSize: 15 }} />
{t("payg.docHelp.toggle", "What counts as a PDF?")}
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
</button>
{open && (
<div className="payg-help__panel">
<ul>
<li>
{t(
"payg.docHelp.billable",
"Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free.",
)}
</li>
<li>
{t(
"payg.docHelp.perFile",
"Each file you process counts as one PDF. Very long or very large files can count as more than one.",
)}
</li>
<li>
{t(
"payg.docHelp.chains",
"Running the same file through several steps of one automation counts it once, not once per step.",
)}
</li>
<li>
{t(
"payg.docHelp.refunds",
"If a job fails on its first step, the PDF is credited back automatically.",
)}
</li>
</ul>
</div>
)}
</div>
);
}
// ─── Hero usage panel ───────────────────────────────────────────────────────
/**
* Format minor units of an ISO currency for display ("$2.24", "£0.40"). Only
* called when the backend resolved the rate currency is always present
* alongside a non-null money amount.
*/
function formatMinor(minor: number, currency: string | null): string {
const code = (currency ?? "usd").toUpperCase();
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
}).format(minor / 100);
} catch {
return `${(minor / 100).toFixed(2)} ${code}`;
}
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency ? currency.toUpperCase() + " " : "$";
}
}
/**
* Usage hero gradient panel with the allowance bar. Every number is real:
* {@code billableUsed} is the ledger's period sum over the team's actual
* billing window (the Stripe subscription period); {@code billableLimit} is
* the backend-derived document ceiling (free allowance + what the money cap
* buys at the subscription Price's per-document rate, null when uncapped);
* {@code estimatedBillMinor} is spend beyond the allowance at that rate.
* Fields the backend couldn't resolve are null and simply not rendered.
*/
function UsageHero({ wallet }: { wallet: Wallet }) {
const { t } = useTranslation();
const periodEnd = new Date(wallet.billingPeriodEnd);
const daysLeft = Math.max(
0,
Math.ceil((periodEnd.getTime() - Date.now()) / 86_400_000),
);
const hasCap = !wallet.noCap && wallet.capUsd != null;
const breakdown = wallet.categoryBreakdown;
const limit = wallet.billableLimit;
const hasLimit = limit != null && limit > 0;
const pct = hasLimit
? Math.min(100, (wallet.billableUsed / limit) * 100)
: 0;
const state = hasLimit
? pct >= 100
? "DEGRADED"
: pct >= 80
? "WARNED"
: "FULL"
: "FULL";
const stateLabel = {
FULL: t("payg.state.full", "Healthy"),
WARNED: t("payg.state.warned", "Approaching cap"),
DEGRADED: t("payg.state.degraded", "Cap reached"),
}[state];
return (
<div className="payg-hero" data-state={state}>
<div className="payg-hero__inner">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<div>
<div className="payg-hero__eyebrow">
{t("payg.usage.thisPeriod", "This billing period")}
</div>
<div className="payg-hero__figure">
<span className="payg-hero__spend">
{wallet.billableUsed.toLocaleString()}
</span>
<span className="payg-hero__cap">
{hasLimit
? t(
"payg.usage.ofLimitProcessed",
"/ {{limit}} PDFs processed",
{ limit: limit.toLocaleString() },
)
: t("payg.usage.processed", "PDFs processed")}
</span>
</div>
</div>
<div className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</div>
</Group>
{hasLimit && (
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
)}
<div className="payg-hero__meta">
<span>
{t("payg.usage.firstFree", "First {{free}} free", {
free: wallet.freeAllowance.toLocaleString(),
})}
</span>
{wallet.estimatedBillMinor != null && (
<>
<span className="payg-hero__meta-dot"></span>
<span>
{t("payg.usage.estBill", "≈ {{amount}} so far this period", {
amount: formatMinor(
wallet.estimatedBillMinor,
wallet.currency,
),
})}
</span>
</>
)}
<span className="payg-hero__meta-dot"></span>
<span>
{hasCap
? t("payg.usage.capLine", "{{cap}}/mo cap", {
cap: `${currencySymbol(wallet.currency)}${wallet.capUsd}`,
})
: t("payg.usage.noCap", "No monthly cap")}
</span>
<span className="payg-hero__meta-dot"></span>
<span>
{daysLeft === 1
? t("payg.usage.resetsTomorrow", "Resets tomorrow")
: t("payg.usage.resetsIn", "Resets in {{days}} days", {
days: daysLeft,
})}
</span>
</div>
<div className="payg-hero__meta" style={{ marginTop: 6 }}>
<span>
{t(
"payg.usage.breakdown",
"AI {{ai}} • Automation {{automation}} • API {{api}}",
{
ai: breakdown.ai.toLocaleString(),
automation: breakdown.automation.toLocaleString(),
api: breakdown.api.toLocaleString(),
},
)}
</span>
</div>
<DocHelp />
</div>
</div>
);
}
// ─── Cap editor ─────────────────────────────────────────────────────────────
interface CapEditorProps {
/** Current cap in major currency units; null = no cap set. */
capUsd: number | null;
/** True when the leader explicitly disabled the cap. */
noCap: boolean;
/** Per-document rate in minor units; null when unknown — preview hides. */
pricePerDocMinor: number | null;
/** Currency of the rate; pairs with {@link CapEditorProps#pricePerDocMinor}. */
currency: string | null;
/**
* Persist the cap change. Receives whole major units (matches the backend's
* {@code PATCH /api/v1/payg/cap} body) or null for no-cap.
*/
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
}
/**
* Single-row cap editor: the shared {@link SpendCapControl} (preset chips +
* inline custom-entry pill + no-cap + inline Save) over a live " N paid
* PDFs/month" estimate, wrapped in the plan-page card chrome + the cap-reached
* disclosure. Save-only the working value is local, so abandoning the card
* abandons the edit. The very same control drives the upgrade checkout flow.
*/
function CapEditor({
capUsd,
noCap,
pricePerDocMinor,
currency,
onSaveCap,
}: CapEditorProps) {
const { t } = useTranslation();
const savedCap = noCap || capUsd == null ? null : capUsd;
const [working, setWorking] = useState<number | null>(savedCap);
return (
<div className="payg-card">
<Stack gap="sm">
<div>
<div className="payg-card__title">
{t("payg.cap.title", "Monthly spending cap")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.cap.subtitle",
"The most your team can spend per month. Billable processing pauses at the cap and resumes next period.",
)}
</div>
</div>
<SpendCapControl
capUsd={working}
onChange={setWorking}
pricePerDocMinor={pricePerDocMinor}
currency={currency}
savedCapUsd={savedCap}
onSave={onSaveCap}
/>
<CapReachedHelp />
</Stack>
</div>
);
}
function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) {
const { t } = useTranslation();
const hasCap = !noCap && capUsd != null;
return (
<div className="payg-card">
<Stack gap="sm">
<div className="payg-card__title">
{t("payg.cap.title", "Monthly spending cap")}
</div>
<Group gap="xs" align="baseline">
<Text fz={28} fw={750} lh={1}>
{hasCap ? `$${capUsd}` : t("payg.cap.noneShort", "No cap")}
</Text>
{hasCap && (
<Text c="dimmed">{t("payg.cap.perMonth", "/ month")}</Text>
)}
</Group>
<div className="payg-preview" style={{ marginTop: 6 }}>
<div>
<div className="payg-preview__main">
{t(
"payg.member.askLeader",
"Only your team owner can change the cap.",
)}
</div>
</div>
</div>
<CapReachedHelp />
</Stack>
</div>
);
}
// ─── Gates ────────────────────────────────────────────────────────────────
// What still works once the cap is hit. Everyday tools keep running; only AI
// and automation/pipelines pause until the cap resets or is raised.
const GATE_CAP_BEHAVIOR: { gate: Gate; staysAtCap: boolean }[] = [
{ gate: "CLIENT_SIDE", staysAtCap: true },
{ gate: "OFFSITE_PROCESSING", staysAtCap: true },
{ gate: "AUTOMATION", staysAtCap: false },
{ gate: "AI_SUPPORT", staysAtCap: false },
];
/**
* Folded-in "what happens at the cap" previously its own GatesCard, now a
* collapsed disclosure rendered inside the cap card(s) to save vertical space.
* Mirrors the {@link DocHelp} toggle pattern; default-collapsed so it costs no
* height until the user opens it.
*/
function CapReachedHelp() {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<div className="payg-help">
<button
type="button"
className="payg-help__toggle"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<HelpOutlineIcon sx={{ fontSize: 15 }} />
{t("payg.gates.title", "What happens when the cap is reached")}
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
</button>
{open && (
<div className="payg-help__panel">
<div className="payg-gates">
{GATE_CAP_BEHAVIOR.map(({ gate, staysAtCap }) => (
<div className="payg-gate" data-enabled={staysAtCap} key={gate}>
<span className="payg-gate__chip">
{staysAtCap ? (
<CheckIcon sx={{ fontSize: 18 }} />
) : (
<LockIcon sx={{ fontSize: 16 }} />
)}
</span>
<span className="payg-gate__label">{gateLabel(gate, t)}</span>
{!staysAtCap && (
<span className="payg-gate__tag" data-variant="pause">
{t("payg.gates.pauses", "pauses at cap")}
</span>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
// ─── Per-member usage ────────────────────────────────────────────────────────
/**
* Leader-only roster of each teammate's billable usage this period. Display-only per-member
* sub-cap enforcement isn't shipped (see the follow-ups note), so there's no cap control here.
*/
function MemberUsage({ members }: { members: Wallet["members"] }) {
const { t } = useTranslation();
return (
<div className="payg-card">
<Stack gap="sm">
<div>
<div className="payg-card__title">
{t("payg.members.title", "Team member usage")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.members.subtitle",
"Billable PDFs each teammate has processed this period.",
)}
</div>
</div>
<div>
{members.map((m) => (
<div className="payg-member" key={m.userId}>
<span
className="payg-member__avatar"
style={{ background: avatarColor(m.userId) }}
>
{m.name.charAt(0).toUpperCase()}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="payg-member__name">{m.name}</div>
<div className="payg-member__email">{m.email}</div>
</div>
<div className="payg-member__usage">
<div className="payg-member__usage-num">
{m.spendUnits.toLocaleString()}{" "}
<Text span size="xs" c="dimmed">
{t("payg.members.docs", "PDFs")}
</Text>
</div>
</div>
</div>
))}
</div>
</Stack>
</div>
);
}
// ─── Activity feed ──────────────────────────────────────────────────────────
/**
* Feature flag the activity feed is hidden until the meter-event surface is
* built and polished (Wave 2). The backend returns {@code []} today, so an
* unpolished "No billable activity yet" card adds nothing. Flip to {@code true}
* once {@code wallet.recent} carries real rows. Kept as a flag (not deleted) so
* the renderer below stays wired and ready.
*/
const SHOW_ACTIVITY_FEED = false;
/**
* Renders {@code wallet.recent} the backend returns {@code []} in V1 (the
* meter-event surface ships in Wave 2), so today this shows a real empty
* state. The row renderer is ready for when the rows arrive; fields are read
* defensively because the activity-row shape isn't finalised yet (the Wallet
* type carries {@code Record<string, unknown>} for the same reason).
*/
function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
const { t } = useTranslation();
return (
<div className="payg-card">
<Stack gap="sm">
<div>
<div className="payg-card__title">
{t("payg.activity.title", "Recent billable activity")}
</div>
<div className="payg-card__subtitle">
{t(
"payg.activity.subtitle",
"Only AI and automation draw from your budget. Everyday tools are free and aren't listed here.",
)}
</div>
</div>
{recent.length === 0 ? (
<Text size="sm" c="dimmed">
{t(
"payg.activity.empty",
"No billable activity yet this period.",
)}
</Text>
) : (
<div>
{recent.map((r, i) => (
<div className="payg-activity-row" key={String(r.id ?? i)}>
<span
className="payg-activity__dot"
data-kind={String(r.kind ?? "")}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="payg-activity__label">
{String(r.label ?? "")}
</div>
<div className="payg-activity__ts">{String(r.ts ?? "")}</div>
</div>
<span className="payg-activity__kind">
{String(r.kind ?? "")}
</span>
<span className="payg-activity__units">
{String(r.docUnits ?? 0)} {t("payg.activity.docs", "docs")}
</span>
</div>
))}
</div>
)}
</Stack>
</div>
);
}
// ─── Stripe CTA ──────────────────────────────────────────────────────────────
function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void> }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
try {
await onOpenPortal();
} catch (e: unknown) {
// 503 = Supabase edge fn isn't configured (local dev without
// PORTAL_NOT_CONFIGURED env). 404 = no Stripe customer yet (e.g. the
// team was force-subscribed via dev hooks). Both are user-actionable
// in roughly the same way ("try again later or contact support") so
// we don't bother branching the copy.
console.warn("[Payg] portal session failed", e);
showToast({
alertType: "warning",
title: t(
"payg.stripe.toast.unavailable.title",
"Billing portal unavailable",
),
body: t(
"payg.stripe.toast.unavailable.body",
"Billing portal isn't available right now. Try again in a moment.",
),
location: "bottom-right",
});
} finally {
setLoading(false);
}
};
return (
<div className="payg-stripe">
<div>
<div className="payg-stripe__title">
{t("payg.stripe.title", "Manage billing in Stripe")}
</div>
<div className="payg-stripe__subtitle">
{t(
"payg.stripe.subtitle",
"Receipts, invoices, payment method, billing currency.",
)}
</div>
</div>
<Button
onClick={handleClick}
loading={loading}
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
variant="light"
>
{t("payg.stripe.open", "Open billing portal")}
</Button>
</div>
);
}
// ─── Main component ───────────────────────────────────────────────────────
const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) => {
useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember");
const { t } = useTranslation();
const isLeader = role === "LEADER";
const fmt = (iso: string) =>
new Date(iso).toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
return (
<div className="payg">
<Stack gap="md">
{/* The modal chrome already renders the section title ("Billing &
usage"), so we lead with the descriptive subtitle + role pill. */}
<div className="payg-planhead">
<div className="payg-planhead__top">
<span className="payg-planhead__eyebrow">
{t("payg.header.eyebrow", "Processor plan · {{start}} {{end}}", {
start: fmt(wallet.billingPeriodStart),
end: fmt(wallet.billingPeriodEnd),
})}
</span>
<span className="payg-role-pill" data-leader={isLeader}>
{isLeader
? t("payg.role.leader", "Team owner")
: t("payg.role.member", "Member")}
</span>
</div>
<div className="payg-planhead__split">
<div className="payg-planhead__col">
<div className="payg-planhead__lbl payg-planhead__lbl--free">
<AllInclusiveIcon
className="payg-planhead__lbl-icon"
fontSize="small"
/>
{t("payg.header.freeLabel", "Always free")}
</div>
<p className="payg-planhead__title">
{t("payg.header.freeTitle", "Unlimited PDF editing")}
</p>
<p className="payg-planhead__body">
{t(
"payg.header.freeBody",
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
)}
</p>
</div>
<div className="payg-planhead__col payg-planhead__col--meter">
<div className="payg-planhead__lbl payg-planhead__lbl--meter">
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
{t("payg.header.meterLabel", "Metered")}
</div>
<p className="payg-planhead__title">
{t("payg.header.meterTitle", "Automation · AI · API")}
</p>
<p className="payg-planhead__body">
{t(
"payg.header.meterBody",
"{{limit}} free PDFs to start, then billed per PDF up to your cap.",
{ limit: wallet.freeAllowance.toLocaleString() },
)}
</p>
</div>
</div>
</div>
<UsageHero wallet={wallet} />
{isLeader ? (
<CapEditor
capUsd={wallet.capUsd}
noCap={wallet.noCap}
pricePerDocMinor={wallet.pricePerDocMinor}
currency={wallet.currency}
onSaveCap={onSaveCap}
/>
) : (
<CapReadOnly capUsd={wallet.capUsd} noCap={wallet.noCap} />
)}
{isLeader && wallet.members.length > 0 && (
<MemberUsage members={wallet.members} />
)}
{SHOW_ACTIVITY_FEED && <ActivityFeed recent={wallet.recent} />}
{isLeader && onOpenPortal && (
<StripePortalLink onOpenPortal={onOpenPortal} />
)}
</Stack>
</div>
);
};
export default Payg;
// Convenience exports for the config nav to render either variant directly.
export interface PaygLeaderProps {
/** See {@link PaygProps#wallet}. */
wallet: Wallet;
/** See {@link PaygProps#onSaveCap}. */
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
/** See {@link PaygProps#onOpenPortal}. */
onOpenPortal?: () => Promise<void>;
}
export const PaygLeader: React.FC<PaygLeaderProps> = ({
wallet,
onSaveCap,
onOpenPortal,
}) => (
<Payg
role="LEADER"
wallet={wallet}
onSaveCap={onSaveCap}
onOpenPortal={onOpenPortal}
/>
);
export const PaygMember: React.FC<{ wallet: Wallet }> = ({ wallet }) => (
<Payg role="MEMBER" wallet={wallet} />
);
@@ -0,0 +1,322 @@
/* Styles for the free-tier Plan views (PaygFreeLeader + PaygFreeMember).
Extends Payg.css the hero + bar + status pill reuse those classes so the
visual reads as a continuation of the Processor dashboard. New classes here
are prefixed `paygf-`. */
.payg-hero__head-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: nowrap;
}
/* ── Editor plan card (always-free tools only, no billing window) ─────── */
/* Reuses .payg-planhead chrome; the eyebrow sits in the flex top row so its
default 8px bottom margin would misalign it against the role pill. */
.paygf-editorcard__eyebrow {
margin-bottom: 0;
}
/* ── Processor plan card: two-column (pitch + benefits | meter + CTA) ──── */
.paygf-proc {
gap: 14px;
}
.paygf-proc__eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--payg-accent);
}
.paygf-proc__split {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 20px;
}
.paygf-proc__pitch {
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.paygf-proc__pitch .paygf-cta__subtitle {
margin-top: 0;
}
/* Force the benefits into a single column inside the narrower left column. */
.paygf-proc__benefits {
grid-template-columns: 1fr;
}
.paygf-proc__aside {
display: flex;
flex-direction: column;
gap: 12px;
padding-left: 20px;
border-left: 1px solid var(--payg-divider);
}
.paygf-proc__cta {
width: 100%;
text-align: center;
}
.paygf-proc__reassure {
text-align: center;
}
.paygf-proc__membernote {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.8rem;
line-height: 1.45;
color: var(--text-muted);
}
.paygf-proc__membernote-icon {
flex-shrink: 0;
color: var(--text-muted);
font-size: 1.1rem !important;
margin-top: 1px;
}
@media (max-width: 640px) {
.paygf-proc__split {
grid-template-columns: 1fr;
}
.paygf-proc__aside {
padding-left: 0;
padding-top: 16px;
border-left: none;
border-top: 1px solid var(--payg-divider);
}
}
/* ── Compact one-time free meter (inside the Processor aside) ─────────── */
.paygf-meter {
padding: 14px 16px;
border-radius: 11px;
background: var(--payg-inset-bg);
border: 1px solid var(--payg-card-border);
}
.paygf-meter__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.paygf-meter__figure {
display: flex;
align-items: baseline;
gap: 7px;
}
.paygf-meter__num {
font-size: 1.7rem;
font-weight: 750;
line-height: 1;
letter-spacing: -0.02em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.paygf-meter__cap {
font-size: 0.85rem;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.paygf-meter .payg-bar {
margin-top: 11px;
}
.paygf-meter__meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
font-size: 0.78rem;
color: var(--text-secondary);
}
/* ── Leader CTA card ──────────────────────────────────────────────────── */
.paygf-cta {
padding: 18px 22px;
border-radius: var(--payg-radius);
background: var(--payg-card-bg);
/* Gradient border using padding-box / border-box trick so the inner bg
stays solid + the outline gets the brand gradient. */
border: 1.5px solid transparent;
background:
linear-gradient(var(--payg-card-bg), var(--payg-card-bg)) padding-box,
linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%) border-box;
box-shadow: 0 10px 28px -18px rgba(10, 139, 255, 0.4);
display: flex;
flex-direction: column;
gap: 16px;
}
.paygf-cta__heading-row {
display: flex;
gap: 14px;
align-items: center;
}
.paygf-cta__icon {
flex-shrink: 0;
padding: 10px;
border-radius: 12px;
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
color: white !important;
font-size: 1.7rem !important;
}
.paygf-cta__heading-text {
flex: 1 1 auto;
}
.paygf-cta__title {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.01em;
line-height: 1.25;
}
.paygf-cta__subtitle {
margin: 4px 0 0;
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.5;
}
.paygf-cta__benefits {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 16px;
}
.paygf-cta__benefits li {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.85rem;
color: var(--text-primary);
line-height: 1.45;
}
.paygf-cta__check {
flex-shrink: 0;
color: var(--payg-accent);
margin-top: 2px;
}
/* Footer row: button on the left, reassurance text on the right.
Right-side text wraps to second line below button on narrow viewports. */
.paygf-cta__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding-top: 4px;
border-top: 1px solid var(--payg-divider);
padding-top: 16px;
}
.paygf-cta__button {
padding: 13px 22px;
border: none;
border-radius: 11px;
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
color: white;
font-weight: 600;
font-size: 0.95rem;
font-family: inherit;
cursor: pointer;
transition: transform 120ms ease, box-shadow 120ms ease;
white-space: nowrap;
}
.paygf-cta__button:hover {
transform: translateY(-1px);
box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
}
.paygf-cta__reassurance {
margin: 0;
font-size: 0.78rem;
color: var(--text-muted);
text-align: right;
}
/* ── Member ask-the-owner note ───────────────────────────────────────── */
.paygf-member-note {
padding: 18px 22px;
border-radius: var(--payg-radius);
background: var(--payg-inset-bg);
border: 1px solid var(--payg-card-border);
display: flex;
gap: 14px;
align-items: flex-start;
}
.paygf-member-note__icon {
flex-shrink: 0;
color: var(--text-muted);
padding: 8px;
background: var(--payg-card-bg);
border-radius: 10px;
font-size: 1.6rem !important;
}
.paygf-member-note__title {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.005em;
line-height: 1.3;
}
.paygf-member-note__body {
margin: 6px 0 0;
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
}
/* ── Explainer (free vs metered) — shared by leader + member ──────────── */
.paygf-explainer {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-top: var(--payg-gap);
}
@media (max-width: 640px) {
.paygf-explainer {
grid-template-columns: 1fr;
}
}
.paygf-explainer__col {
padding: 18px 20px;
border-radius: 12px;
background: var(--payg-card-bg);
border: 1px solid var(--payg-card-border);
}
.paygf-explainer__label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 10px;
}
.paygf-explainer__icon {
font-size: 1rem !important;
}
.paygf-explainer__icon--free {
color: #10b981;
}
.paygf-explainer__icon--paid {
color: var(--payg-accent);
}
.paygf-explainer__text {
margin: 0;
font-size: 0.85rem;
color: var(--text-primary);
line-height: 1.55;
}
@@ -0,0 +1,374 @@
/**
* Free-tier Plan views (leader + member) shown before the team subscribes to
* Processor. Mirrors the visual language of {@code Payg.tsx} so the upgrade
* feels like enabling a switch, not visiting a new product.
*
* <p><b>Free tier model (set 2026-06):</b> users only pay for
* <em>automation</em>, <em>AI</em>, and <em>API</em> operations. Manual tools
* viewing, editing, signing, merging, splitting, conversion, manual OCR,
* watermarks, compression are unmetered, no matter where they're triggered
* from. The distinction is the <em>type of work</em> (manual tool vs
* automation / AI / API), not where the click happens, because automation and
* AI also have UI surfaces. The one-time free grant (default 500) applies
* <em>only</em> to the three billable categories it is a lifetime allowance,
* not a monthly one, and a team keeps any unused portion after subscribing.
*
* <p>Layout: a slim <b>Editor plan</b> card (always-free tools only no dates,
* no metered split) on top, then a single <b>Processor plan</b> card that
* two-columns the upgrade pitch + benefits (left) against the one-time free
* meter stacked over the call-to-action (right).
*
* <p>Two variants:
* - {@link PaygFreeLeader} the right column's CTA opens the upgrade modal.
* - {@link PaygFreeMember} read-only; the CTA is replaced with an
* ask-the-owner note.
*/
import React, { useMemo, useState } from "react";
import { Stack } from "@mantine/core";
import BoltIcon from "@mui/icons-material/BoltRounded";
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
import CheckIcon from "@mui/icons-material/CheckRounded";
import LockIcon from "@mui/icons-material/LockOutlined";
import { useTranslation } from "react-i18next";
import { useRenderCount } from "@app/hooks/useRenderCount";
import { useWallet } from "@app/hooks/useWallet";
// eslint-disable-next-line no-restricted-imports
import "./Payg.css";
// eslint-disable-next-line no-restricted-imports
import "./PaygFree.css";
// eslint-disable-next-line no-restricted-imports
import UpgradeModal from "./UpgradeModal";
// eslint-disable-next-line no-restricted-imports
import { DocHelp } from "./Payg";
// ─── Shared free-tier snapshot ────────────────────────────
interface FreeSnapshot {
/** One-time free documents used so far (grant remaining). */
billableUsed: number;
/**
* The team's one-time free grant size in documents. Real value from the
* wallet endpoint (pricing_policy.free_tier_units); 500 below is only the
* pre-load placeholder for the first paint.
*/
billableLimit: number;
}
/**
* Read free-tier snapshot from the real {@link useWallet} hook. Falls back to
* a zeroed view if the wallet hasn't loaded yet this only happens briefly on
* first paint; once the snapshot arrives the component re-renders with real
* numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked
* into the rendered UI and made the page look like nothing was wired up.
*/
function useFreeSnapshot(): FreeSnapshot {
const { wallet } = useWallet();
return useMemo(() => {
if (wallet) {
return {
// Used = grant remaining, derived straight from the one-time grant so
// the free view never depends on the per-state meaning of billableUsed.
billableUsed: Math.max(0, wallet.freeAllowance - wallet.freeRemaining),
// The free view's ceiling IS the one-time grant size.
billableLimit: wallet.freeAllowance,
};
}
return { billableUsed: 0, billableLimit: 500 };
}, [wallet]);
}
type MeterState = "FULL" | "WARNED" | "DEGRADED";
/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */
function meterState(used: number, limit: number): { state: MeterState; pct: number } {
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
const state: MeterState =
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
return { state, pct };
}
// ─── Editor plan card (always-free tools only) ────────────────────────────
interface EditorPlanCardProps {
/** Role pill text on the right. */
pill: string;
/** LEADER pill colour treatment. */
leader?: boolean;
}
/**
* The top card: the free Editor plan. Manual tools only, no billing window
* the one-time grant lives in the Processor card below, so there's no period
* to show here.
*/
function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
const { t } = useTranslation();
return (
<div className="payg-planhead paygf-editorcard">
<div className="payg-planhead__top">
<span className="payg-planhead__lbl payg-planhead__lbl--free paygf-editorcard__eyebrow">
<AllInclusiveIcon
className="payg-planhead__lbl-icon"
fontSize="small"
/>
{t("payg.free.editor.eyebrow", "Editor plan · Always free")}
</span>
<span className="payg-role-pill" data-leader={leader ? "true" : "false"}>
{pill}
</span>
</div>
<p className="payg-planhead__title">
{t("payg.free.header.freeTitle", "Unlimited PDF editing")}
</p>
<p className="payg-planhead__body">
{t(
"payg.free.header.freeBody",
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
)}
</p>
</div>
);
}
// ─── Compact one-time free meter (right column of the Processor card) ──────
function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
const { t } = useTranslation();
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
const stateLabel =
state === "DEGRADED"
? t("payg.free.state.limitReached", "Limit reached")
: state === "WARNED"
? t("payg.free.state.approachingLimit", "Approaching limit")
: t("payg.free.state.plentyLeft", "Plenty left");
return (
<div className="paygf-meter" data-state={state}>
<div className="paygf-meter__top">
<div className="paygf-meter__figure">
<span className="paygf-meter__num">
{snap.billableUsed.toLocaleString()}
</span>
<span className="paygf-meter__cap">
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
limit: snap.billableLimit.toLocaleString(),
})}
</span>
</div>
<span className="payg-status" data-state={state}>
<span className="payg-status__dot" />
{stateLabel}
</span>
</div>
<div className="payg-bar">
<div
className="payg-bar__fill"
data-state={state}
style={{ width: `${pct}%` }}
/>
</div>
<div className="paygf-meter__meta">
<span>
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
</span>
<span className="payg-hero__meta-dot"></span>
<span>{t("payg.free.hero.neverResets", "One-time — never resets")}</span>
</div>
</div>
);
}
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
interface ProcessorCardProps {
snap: FreeSnapshot;
/** Leaders get the live CTA; members get the ask-owner note. */
isLeader: boolean;
/** Opens the upgrade modal — leader only. */
onTurnOn?: () => void;
}
function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
const { t } = useTranslation();
return (
<div className="paygf-cta paygf-proc">
<span className="paygf-proc__eyebrow">
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
{t("payg.free.proc.eyebrow", "Processor plan · metered")}
</span>
<div className="paygf-proc__split">
<div className="paygf-proc__pitch">
<h3 className="paygf-cta__title">
{t("payg.free.cta.title", "Turn on the Processor plan")}
</h3>
<p className="paygf-cta__subtitle">
{t(
"payg.free.cta.subtitle",
"Keep going past your {{limit}} free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control.",
{ limit: snap.billableLimit.toLocaleString() },
)}
</p>
<ul className="paygf-cta__benefits paygf-proc__benefits">
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>
{t("payg.free.cta.benefit1Title", "Automation pipelines")}
</strong>
{" — "}
{t(
"payg.free.cta.benefit1Body",
"chain tools, schedule runs, batch process",
)}
</span>
</li>
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>{t("payg.free.cta.benefit2Title", "AI tools")}</strong>
{" — "}
{t(
"payg.free.cta.benefit2Body",
"summarise, classify, redact, AI-OCR",
)}
</span>
</li>
<li>
<CheckIcon className="paygf-cta__check" fontSize="small" />
<span>
<strong>{t("payg.free.cta.benefit3Title", "API access")}</strong>
{" — "}
{t(
"payg.free.cta.benefit3Body",
"call any Stirling endpoint programmatically",
)}
</span>
</li>
</ul>
<DocHelp />
</div>
<div className="paygf-proc__aside">
<FreeMeterPanel snap={snap} />
{isLeader ? (
<>
<button
type="button"
className="paygf-cta__button paygf-proc__cta"
onClick={onTurnOn}
data-testid="turn-on-processor"
>
{t("payg.free.cta.button", "Turn on Processor →")}
</button>
<span className="paygf-cta__reassurance paygf-proc__reassure">
{t(
"payg.free.cta.reassurance",
"No minimum · Set a $0 cap to test · Cancel anytime",
)}
</span>
</>
) : (
<div className="paygf-proc__membernote">
<LockIcon
className="paygf-proc__membernote-icon"
fontSize="small"
/>
<span>
{t(
"payg.free.member.ownerOnly",
"Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like.",
)}
</span>
</div>
)}
</div>
</div>
</div>
);
}
// ─── Free LEADER ──────────────────────────────────────────────────────────
export interface PaygFreeLeaderProps {
/**
* Called when the user finishes the {@link UpgradeModal} checkout flow.
* Plumbed up to {@code PlanSection} so the page can flip to the subscribed
* view immediately. When undefined we fall back to a demo {@code alert} so
* the dev preview route still works in isolation.
*/
onUpgraded?: (result: { capUsd: number | null }) => void;
}
function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) {
useRenderCount("PaygFreeLeader");
const { t } = useTranslation();
const snap = useFreeSnapshot();
const { wallet } = useWallet();
const [upgradeOpen, setUpgradeOpen] = useState(false);
return (
<div className="payg">
<Stack gap="md">
<EditorPlanCard pill={t("payg.role.leader", "Team owner")} leader />
<ProcessorCard
snap={snap}
isLeader
onTurnOn={() => setUpgradeOpen(true)}
/>
</Stack>
{wallet?.teamId != null && (
<UpgradeModal
open={upgradeOpen}
teamId={wallet.teamId}
freeLimit={snap.billableLimit}
pricePerDocMinor={wallet.pricePerDocMinor}
rateCurrency={wallet.currency}
onClose={() => setUpgradeOpen(false)}
onComplete={({ capUsd }) => {
setUpgradeOpen(false);
if (onUpgraded) {
onUpgraded({ capUsd });
} else {
// Standalone fallback (dev preview route renders without a parent
// handler). Real flow always passes onUpgraded via PlanSection.
alert(
`Demo: subscription complete. Cap = ${
capUsd === null ? "no cap" : `$${capUsd}/mo`
}.`,
);
}
}}
/>
)}
</div>
);
}
// ─── Free MEMBER ──────────────────────────────────────────────────────────
function PaygFreeMemberInner() {
useRenderCount("PaygFreeMember");
const { t } = useTranslation();
const snap = useFreeSnapshot();
return (
<div className="payg">
<Stack gap="md">
<EditorPlanCard pill={t("payg.role.member", "Member")} />
<ProcessorCard snap={snap} isLeader={false} />
</Stack>
</div>
);
}
// React.memo so Plan re-rendering on loading/error toggles doesn't cascade
// down to these leaves. Plan passes a stable onUpgraded callback (hoisted in
// Plan.tsx) so the prop identity stays stable across wallet refetches.
export const PaygFreeLeader = React.memo(PaygFreeLeaderInner);
export const PaygFreeMember = React.memo(PaygFreeMemberInner);
@@ -1,245 +1,91 @@
import React, { lazy, Suspense, useState, useCallback, useEffect } from "react";
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
import { usePlans, PlanTier } from "@app/hooks/usePlans";
import type {
PurchaseType,
CreditsPack,
PlanID,
} from "@app/components/shared/StripeCheckoutSaas";
const StripeCheckout = lazy(
() => import("@app/components/shared/StripeCheckoutSaas"),
);
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
import { useAuth } from "@app/auth/UseSession";
/**
* SaaS "Plan" page the single entry point for billing, plan state, and
* usage. Branches on the team's wallet state and the viewer's role:
*
* - free + leader {@link PaygFreeLeader} (upgrade CTA + manual-tools
* framing)
* - free + member {@link PaygFreeMember} (ask-the-owner note)
* - subscribed + leader {@link PaygLeader} (full dashboard, editable cap)
* - subscribed + member {@link PaygMember} (member dashboard)
*
* <p>The hook handles loading + error states locally so the four view
* components stay focused on rendering the data they own. {@code Plan} is
* intentionally tiny (under 60 lines) so future "Plan-level" affordances
* a top-level error toast, a subscription confirmation card, etc have
* obvious places to land.
*/
import React, { useCallback } from "react";
import { Alert, Center, Loader } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useWallet } from "@app/hooks/useWallet";
import { useRenderCount } from "@app/hooks/useRenderCount";
import {
PaygLeader,
PaygMember,
} from "@app/components/shared/config/configSections/Payg";
import {
PaygFreeLeader,
PaygFreeMember,
} from "@app/components/shared/config/configSections/PaygFree";
const Plan: React.FC = () => {
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
const [purchaseType, setPurchaseType] =
useState<PurchaseType>("subscription");
const [selectedCreditsPack, setSelectedCreditsPack] =
useState<CreditsPack>(null);
const [currency, setCurrency] = useState<string>("gbp");
const { trialStatus } = useAuth();
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
useRenderCount("Plan");
const { t } = useTranslation();
const { wallet, loading, error, markSubscribed, updateCap, openPortal } =
useWallet();
const currencyOptions = [
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
{ value: "usd", label: "US dollar (USD, $)" },
{ value: "inr", label: "Indian rupee (INR, ₹)" },
{ value: "brl", label: "Brazilian real (BRL, R$)" },
{ value: "eur", label: "Euro (EUR, €)" },
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
{ value: "gbp", label: "British pound (GBP, £)" },
];
const handleUpgradeClick = useCallback(
(plan: PlanTier) => {
if (!data) return;
if (plan.isContactOnly) {
// Open contact form or redirect to contact page
window.open(
"mailto:[email protected]?subject=Enterprise Plan Inquiry",
"_blank",
);
return;
}
if (plan.id !== data.currentPlan.id) {
setSelectedPlan(plan);
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
// Stable callback so PaygFreeLeader's React.memo doesn't see a new prop
// identity on every Plan render (e.g. loading flips false→true→false on
// a refetch). Closing over the stable markSubscribed from useWallet
// means we don't need to add wallet state to deps.
const onUpgraded = useCallback(
({ capUsd }: { capUsd: number | null }) => {
// Bridges the modal's local success → backend mock → refetch loop.
// Real Stripe flow: the customer.subscription.created webhook is
// what flips status; we still call markSubscribed locally so the
// optimistic refetch hits immediately.
void markSubscribed(capUsd);
},
[data],
[markSubscribed],
);
const handleCreditPurchaseClick = useCallback(
(creditsPack: CreditsPack) => {
if (!data) return;
setSelectedCreditsPack(creditsPack);
setPurchaseType("credits");
setSelectedPlan(null);
setCheckoutOpen(true);
},
[data],
);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log("Payment successful, session:", sessionId);
// Update local state immediately - no page reload needed
if (selectedPlan && purchaseType === "subscription") {
updateCurrentPlan(selectedPlan.id);
}
// Close modal after brief delay to show success message
setTimeout(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, 2000);
},
[selectedPlan, purchaseType, updateCurrentPlan],
);
const handlePaymentError = useCallback((error: string) => {
console.error("Payment error:", error);
// Error is already displayed in the StripeCheckout component
}, []);
const handleCheckoutClose = useCallback(() => {
setCheckoutOpen(false);
setSelectedPlan(null);
setSelectedCreditsPack(null);
}, []);
const handleAddPaymentClick = useCallback(() => {
if (!data) return;
// Find Pro plan from available plans
const proPlan = Array.from(data.plans.values()).find(
(plan) => plan.id === "pro",
);
if (proPlan) {
setSelectedPlan(proPlan);
setPurchaseType("subscription");
setSelectedCreditsPack(null);
setCheckoutOpen(true);
}
}, [data]);
// Check URL parameters for action=add-payment
useEffect(() => {
if (!data) return;
const params = new URLSearchParams(window.location.search);
if (params.get("action") === "add-payment") {
handleAddPaymentClick();
// Clean up URL
params.delete("action");
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, "", newUrl);
}
}, [data, handleAddPaymentClick]);
// Early returns after all hooks are called
if (loading) {
if (loading && !wallet) {
return (
<div className="flex justify-center items-center py-8">
<Loader size="lg" />
</div>
<Center mih={200}>
<Loader />
</Center>
);
}
if (error) {
if (error || !wallet) {
return (
<Alert color="red" title="Error loading plans">
{error}
<Alert color="red" title={t("payg.error.title", "Couldn't load your plan")}>
{error ??
t(
"payg.error.body",
"We couldn't reach the billing service. Refresh the page to try again.",
)}
</Alert>
);
}
if (!data) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
if (wallet.status === "subscribed") {
return wallet.role === "leader" ? (
<PaygLeader
wallet={wallet}
onSaveCap={updateCap}
onOpenPortal={openPortal}
/>
) : (
<PaygMember wallet={wallet} />
);
}
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } =
data;
const plansArray = Array.from(plans.values());
return (
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{/* Currency Selector */}
<div>
<Group justify="space-between" align="center" mb="md">
<Text size="lg" fw={600}>
Currency
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || "gbp")}
data={currencyOptions}
searchable
clearable={true}
w={300}
/>
</Group>
</div>
<ActivePlanSection
currentPlan={currentPlan}
_activeSince={activeSince}
_nextBillingDate={nextBillingDate}
trialStatus={trialStatus ?? undefined}
onAddPaymentClick={handleAddPaymentClick}
/>
<Divider />
<AvailablePlansSection
plans={plansArray}
currentPlan={currentPlan}
onUpgradeClick={handleUpgradeClick}
/>
<Divider />
<ApiPackagesSection
apiPackages={apiPackages}
selectedCredits={selectedCredits}
onSelectedCreditsChange={setSelectedCredits}
onCreditPurchaseClick={handleCreditPurchaseClick}
/>
{/* Stripe Checkout Modal */}
{checkoutOpen &&
(selectedPlan !== null || selectedCreditsPack !== null) && (
<Suspense fallback={null}>
<StripeCheckout
opened={true}
onClose={handleCheckoutClose}
purchaseType={purchaseType}
planId={
purchaseType === "subscription"
? (selectedPlan?.id as PlanID)
: null
}
creditsPack={
purchaseType === "credits" ? selectedCreditsPack : null
}
planName={
purchaseType === "subscription"
? selectedPlan?.name || ""
: data?.apiPackages.find(
(pkg) => pkg.id === selectedCreditsPack,
)?.name || ""
}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
isTrialConversion={
trialStatus?.isTrialing && purchaseType === "subscription"
}
/>
</Suspense>
)}
</div>
);
// Free tier — only the leader sees the upgrade CTA.
if (wallet.role === "leader") {
return <PaygFreeLeader onUpgraded={onUpgraded} />;
}
return <PaygFreeMember />;
};
export default Plan;
@@ -0,0 +1,159 @@
/* Reusable monthly spend-cap control preset chips + an inline custom-entry
pill + a no-cap chip, with a live "≈ N PDFs/month" estimate beneath. Shared
by the subscribed plan-page cap editor (Payg.tsx) and the upgrade checkout
flow (UpgradeModal.tsx).
Self-contained tokens so the control looks right whether it's dropped into
the Plan card (light/dark modal content) or the dark upgrade modal. We lean
on the app's semantic theme tokens and override per scheme, mirroring the
Payg.css / UpgradeModal.css treatments the control sits beside. */
.scc {
--scc-accent: #0a8bff;
--scc-accent-text: #0a8bff;
--scc-accent-soft: rgba(10, 139, 255, 0.12);
--scc-accent-border: rgba(10, 139, 255, 0.25);
--scc-chip-bg: var(--bg-muted);
--scc-chip-border: var(--border-default);
/* Consistent vertical rhythm between the control row, the estimate, and any
note without it the blocks sit flush. */
display: flex;
flex-direction: column;
gap: 14px;
}
[data-mantine-color-scheme="dark"] .scc {
--scc-accent-text: #66b8ff;
--scc-accent-soft: rgba(10, 139, 255, 0.16);
--scc-chip-bg: #272d35;
--scc-chip-border: #3d444e;
}
/* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */
.scc-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
/* Shared pill shape for presets, the custom-entry pill, and no-cap. Keeping a
single height/radius/border is what makes the custom field read as "one more
button" rather than a form input bolted on. */
.scc-chip {
display: inline-flex;
align-items: center;
height: 34px;
padding: 0 16px;
border-radius: 999px;
border: 1px solid var(--scc-chip-border);
background: var(--scc-chip-bg);
color: var(--text-secondary);
font: inherit;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
font-variant-numeric: tabular-nums;
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.scc-chip:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--border-strong);
}
.scc-chip[data-selected="true"] {
background: var(--scc-accent-soft);
border-color: var(--scc-accent);
color: var(--scc-accent-text);
}
.scc-chip:disabled {
opacity: 0.45;
cursor: default;
}
/* Custom-entry pill Option A: a dashed pill that matches the presets and
reads as "or type your own", filling solid like a selected chip once it
carries a value. */
.scc-custom {
display: inline-flex;
align-items: center;
gap: 1px;
height: 34px;
padding: 0 14px;
border-radius: 999px;
border: 1px dashed var(--scc-chip-border);
background: transparent;
cursor: text;
transition: border-color 0.12s ease, background 0.12s ease;
}
.scc-custom:hover {
border-color: var(--border-strong);
}
.scc-custom[data-active="true"] {
border-style: solid;
border-color: var(--scc-accent);
background: var(--scc-accent-soft);
}
.scc-custom__symbol {
color: var(--text-muted);
font-size: 0.85rem;
font-weight: 600;
}
.scc-custom[data-active="true"] .scc-custom__symbol,
.scc-custom[data-active="true"] .scc-custom__input {
color: var(--scc-accent-text);
}
.scc-custom__input {
width: 70px;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font: inherit;
font-size: 0.85rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.scc-custom__input::placeholder {
color: var(--text-muted);
font-weight: 600;
}
.scc-custom:disabled,
.scc-custom[data-disabled="true"] {
opacity: 0.45;
cursor: default;
}
.scc-row__spacer {
margin-left: auto;
}
/* ── Live PDF estimate ───────────────────────────────────────────────── */
.scc-estimate {
display: flex;
align-items: center;
gap: 11px;
padding: 12px 14px;
border-radius: 10px;
background: var(--scc-accent-soft);
border: 1px solid var(--scc-accent-border);
}
.scc-estimate__icon {
color: var(--scc-accent-text);
display: flex;
}
.scc-estimate__main {
font-size: 0.875rem;
font-weight: 550;
color: var(--text-primary);
}
.scc-estimate__sub {
font-size: 0.75rem;
color: var(--text-muted);
margin-top: 1px;
}
/* Quiet helper line (e.g. "Shown in USD — change later in your currency"). */
.scc-note {
font-size: 0.8125rem;
color: var(--text-muted);
line-height: 1.45;
}
@@ -0,0 +1,245 @@
/**
* Reusable monthly spend-cap control.
*
* One inline row preset chips, a custom-entry pill that matches the presets,
* a "No cap" chip, and (optionally) a Save button over a live " N PDFs /
* month" estimate. Extracted from the subscribed plan-page cap editor so the
* exact same control drives the upgrade checkout flow.
*
* <h2>Currency-agnostic by design</h2>
*
* The control never decides a currency. It takes {@code pricePerDocMinor} +
* {@code currency} and renders whatever it's handed: the subscribed plan page
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
* yet) plus a {@code note} explaining the cap is editable later. When no rate
* is supplied the estimate simply hides.
*
* <h2>Controlled</h2>
*
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
* the working value. When {@code onSave} is provided the control renders the
* inline Save button and computes "dirty" against {@code savedCapUsd}.
*/
import React, { useState } from "react";
import { Button } from "@mantine/core";
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
// Quick amounts offered everywhere — recognition over recall.
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
export interface SpendCapControlProps {
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
capUsd: number | null;
/** Working-value setter. {@code null} signals no-cap. */
onChange: (capUsd: number | null) => void;
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
pricePerDocMinor?: number | null;
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
currency?: string | null;
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
presets?: readonly number[];
/**
* When provided, the control renders an inline Save button. Receives whole
* major units, or {@code null} for no-cap.
*/
onSave?: (capUsd: number | null) => Promise<void> | void;
/** Label for the Save button. */
saveLabel?: string;
/**
* The persisted value to diff against for the dirty check. Same encoding as
* {@link #capUsd} ({@code null} = persisted no-cap). Only used with
* {@link #onSave}.
*/
savedCapUsd?: number | null;
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
note?: React.ReactNode;
}
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
function formatMinor(minor: number, currency: string | null | undefined): string {
const code = (currency ?? "usd").toUpperCase();
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: code,
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
maximumFractionDigits: 3,
}).format(minor / 100);
} catch {
return `${(minor / 100).toFixed(2)} ${code}`;
}
}
/** Currency symbol for compact inline use; falls back to the ISO code. */
function currencySymbol(currency: string | null | undefined): string {
switch ((currency ?? "").toLowerCase()) {
case "usd":
case "":
return "$";
case "eur":
return "€";
case "gbp":
return "£";
default:
return currency!.toUpperCase() + " ";
}
}
const SpendCapControl: React.FC<SpendCapControlProps> = ({
capUsd,
onChange,
pricePerDocMinor,
currency,
presets = DEFAULT_CAP_PRESETS,
onSave,
saveLabel,
savedCapUsd,
note,
}) => {
const { t } = useTranslation();
const [saving, setSaving] = useState(false);
const sym = currencySymbol(currency);
const isNoCap = capUsd === null;
const presetSelected = capUsd != null && presets.includes(capUsd);
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
// the value came from the custom pill.
const customActive = capUsd != null && !presets.includes(capUsd);
// Local mirror of the custom field's text so partial/empty entry doesn't get
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
const [customText, setCustomText] = useState<string>(
customActive ? String(capUsd) : "",
);
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
// one-time free grant is a separate lifetime pool and is NOT added here —
// this is the paid PDFs the monthly cap buys.
const rate =
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
const previewDocs =
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
const selectPreset = (preset: number) => {
setCustomText("");
onChange(preset);
};
const selectNoCap = () => {
setCustomText("");
onChange(null);
};
const onCustomInput = (raw: string) => {
// Digits only; an empty field reads as "no custom value yet" → 0 so the
// estimate still renders sensibly without flipping to no-cap.
const cleaned = raw.replace(/[^0-9]/g, "");
setCustomText(cleaned);
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
onChange(Number.isNaN(v) ? 0 : v);
};
const handleSave = async () => {
if (!onSave) return;
setSaving(true);
try {
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
} finally {
setSaving(false);
}
};
return (
<div className="scc">
<div className="scc-row">
{presets.map((preset) => (
<button
key={preset}
type="button"
className="scc-chip"
data-selected={presetSelected && capUsd === preset}
onClick={() => selectPreset(preset)}
>
{sym}
{preset.toLocaleString()}
</button>
))}
{/* Custom-entry pill dashed until it carries a value, then it fills
like a selected chip. */}
<label className="scc-custom" data-active={customActive}>
<span className="scc-custom__symbol">{sym}</span>
<input
className="scc-custom__input"
inputMode="numeric"
value={customActive ? customText : ""}
placeholder={t("payg.cap.custom", "Custom")}
aria-label={t("payg.cap.amount", "Cap amount")}
onChange={(e) => onCustomInput(e.target.value)}
/>
</label>
<button
type="button"
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
data-selected={isNoCap}
onClick={selectNoCap}
>
{t("payg.cap.noCapLabel", "No cap")}
</button>
{onSave && (
<Button
variant="default"
size="xs"
className="scc-row__spacer"
disabled={!dirty || saving}
loading={saving}
leftSection={<LocalIcon icon="check-rounded" />}
onClick={handleSave}
>
{saveLabel ?? t("payg.cap.save", "Update cap")}
</Button>
)}
</div>
{previewDocs != null && (
<div className="scc-estimate">
<DescriptionIcon className="scc-estimate__icon" sx={{ fontSize: 22 }} />
<div>
<div className="scc-estimate__main">
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
docs: previewDocs.toLocaleString(),
})}
</div>
<div className="scc-estimate__sub">
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
rate: formatMinor(pricePerDocMinor ?? 0, currency),
})}
</div>
</div>
</div>
)}
{isNoCap && (
<div className="scc-note">
{t(
"payg.cap.noCapDesc",
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
)}
</div>
)}
{note && <div className="scc-note">{note}</div>}
</div>
);
};
export default SpendCapControl;
@@ -0,0 +1,314 @@
/**
* Stripe Embedded Checkout panel lives in its own module so it can be
* imported lazily. {@code @stripe/stripe-js} pulls a fairly chunky third-party
* SDK; we don't want it in the main bundle for users who never open the
* UpgradeModal, let alone reach step 2.
*
* <h2>Lazy-load pattern</h2>
*
* <pre>
* // In UpgradeModal.tsx — only when the user advances to step 2:
* const StripeCheckoutPanel = React.lazy(
* () => import("./StripeCheckoutPanel"),
* );
* </pre>
*
* The {@code loadStripe()} call inside this module deferred-imports the SDK
* itself, so the chunk graph is:
*
* <pre>
* StripeCheckoutPanel.chunk.js
* @stripe/react-stripe-js (pulled in by ESM static import here)
* @stripe/stripe-js (pulled in by loadStripe inside fetchStripe)
* </pre>
*
* Both chunks are eligible for Vite tree-shaking + lazy load; nothing in the
* main bundle references either package.
*
* <h2>Architecture</h2>
*
* Stripe-touching code lives in Supabase edge functions, not the Java
* backend. This panel invokes {@code create-checkout-session}
* directly via {@code supabase.functions.invoke()} same pattern {@link
* usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is
* attached automatically by the Supabase client.
*
* <p>The edge function (SaaS PR #300) is the canonical place Stripe Checkout
* Sessions get created it uses the Stripe Sync Engine tables, has dedicated
* unit tests, and shares Stripe SDK / secret-key plumbing with the metering +
* webhook edge functions. Routing through Java would have meant a useless
* proxy hop + a second Stripe SDK to maintain.
*
* <h2>Behaviour</h2>
*
* <ol>
* <li>On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id,
* currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is
* NOT set here it's an application-layer setting applied via {@code PATCH /payg/cap} after
* the subscription lands.
* <li>If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge
* function isn't deployed yet (errors out / returns a {@code cs_mock_}
* sentinel), render a clearly-labelled placeholder + "Continue with
* mock" button so the post-completion path stays testable.
* <li>Otherwise render the real {@code <EmbeddedCheckoutProvider>} +
* {@code <EmbeddedCheckout>} iframe.
* </ol>
*
* The parent {@link UpgradeModal} passes {@code onComplete} which fires when
* either the real Stripe checkout emits its complete event OR the user
* presses "Continue with mock" in unconfigured environments.
*/
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { supabase } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
// Eager static imports here are OK because this whole module is itself lazy-
// imported by the modal. They land in the same lazy chunk.
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from "@stripe/react-stripe-js";
import type { Stripe } from "@stripe/stripe-js";
export interface StripeCheckoutPanelProps {
/**
* The caller's team_id — required by the Supabase edge function, which can't
* derive it from the JWT alone (the function runs outside our app's Spring
* Security context and has no access to the {@code team_memberships} table
* other than via this hint).
*/
teamId: number;
/** Currency lower-case 3-letter ISO (e.g. {@code "gbp"}). Selects the Stripe Price. */
currency?: string;
/** Cap in USD; null means no cap. Tracked locally; set on the wallet via PATCH after subscription. */
capUsd: number | null;
/** Called when Stripe (or the mock continue button) signals completion. */
onComplete: () => void;
/** Called when the call to /api/v1/payg/checkout fails. */
onError?: (message: string) => void;
}
/**
* Response shape from the {@code create-checkout-session} Supabase edge
* function. Mirrors what the function returns.
*/
interface CheckoutResponse {
/** Stripe Checkout Session client_secret. */
client_secret: string;
/**
* Optional sentinel: edge functions in non-prod environments may return a
* stubbed secret prefixed {@code cs_mock_} so the FE knows to render the
* placeholder rather than try to mount a real iframe with a bad secret.
*/
mock?: boolean;
}
// Singleton Stripe promise — created on first use and reused for the lifetime
// of the tab. {@code loadStripe} is dynamically imported so the actual SDK
// chunk is only pulled when this code path runs.
let stripePromise: Promise<Stripe | null> | null = null;
function getStripe(publishableKey: string): Promise<Stripe | null> {
if (stripePromise === null) {
stripePromise = import("@stripe/stripe-js").then((mod) =>
mod.loadStripe(publishableKey),
);
}
return stripePromise;
}
const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
teamId,
currency = "gbp",
// capUsd is part of the props contract but intentionally unused here — the cap is set
// application-side via PATCH /payg/cap after the subscription lands, not during checkout.
onComplete,
onError,
}) => {
const { t } = useTranslation();
const { user } = useAuth();
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [isMock, setIsMock] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Billing email for the Stripe Checkout Session. Passing customer_email to
// Stripe prefills the email field AND locks it (Stripe's own behaviour for
// sessions created with customer_email or an attached customer) — the user
// can't bill a different address than the account they're signed in with.
const billingEmail = user?.email ?? null;
// Stable ref for the error callback so we don't have to include it in the
// effect deps.
const onErrorRef = useRef<typeof onError>(onError);
onErrorRef.current = onError;
// Stash t() in a ref so the effect can read the current translator without
// forcing t into its deps.
const tRef = useRef(t);
tRef.current = t;
const publishableKey =
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
// Dev preview route has no backend — skip the API call and go straight
// to the mock placeholder so the design + completion path stay testable.
// Both checks required so a production tenant with a /dev/ URL prefix
// can't accidentally trigger the placeholder.
const devPreview =
import.meta.env.DEV &&
typeof window !== "undefined" &&
window.location.pathname.startsWith("/dev/");
useEffect(() => {
if (devPreview) {
setClientSecret("cs_mock_devpreview");
setIsMock(true);
setLoading(false);
return;
}
// React 18 strict-mode dev mounts effects twice. We use `cancelled` to
// discard the first mount's response (its setState calls become no-ops),
// and the second mount's response wins. We deliberately do NOT short-
// circuit the second mount with a ref — earlier versions did that and
// ran into the trap where mount 1 was cancelled but the live mount
// never re-fetched, leaving `loading` stuck at true. Two network calls
// in dev is an acceptable cost; prod has no strict mode → single fetch.
let cancelled = false;
async function createSession() {
try {
// Direct Supabase edge function invocation. We call
// {@code create-checkout-session} (not {@code create-payg-team-subscription} —
// that one creates a subscription directly without going through the
// hosted Stripe Embedded Checkout iframe and doesn't return a
// client_secret). team_id is required because the edge fn runs
// outside our Spring Security context and can't resolve it from the
// JWT alone. The cap is *not* set during checkout — it's an
// application-layer setting, applied via PATCH /payg/cap after the
// subscription lands. window.location.href is the success_url so the
// user comes back to the Plan tab after Stripe finishes the redirect.
const returnUrl = window.location.href;
const { data, error: invokeError } =
await supabase.functions.invoke<CheckoutResponse>(
"create-checkout-session",
{
body: {
team_id: teamId,
currency,
success_url: returnUrl,
cancel_url: returnUrl,
// Maps to Stripe's customer_email when the team has no Stripe
// customer yet — prefills + locks the email field in Checkout.
// Teams with an existing customer get the email locked from the
// customer record instead; this field is ignored for them.
...(billingEmail ? { billing_owner_email: billingEmail } : {}),
},
},
);
if (invokeError) {
throw invokeError;
}
if (!data?.client_secret) {
throw new Error("Edge function returned no client_secret");
}
if (cancelled) return;
setClientSecret(data.client_secret);
setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"));
} catch (e: unknown) {
if (cancelled) return;
const msg =
e instanceof Error
? e.message
: tRef.current(
"payg.checkout.error.startFailed",
"Couldn't start checkout session",
);
setError(msg);
onErrorRef.current?.(msg);
} finally {
if (!cancelled) setLoading(false);
}
}
void createSession();
return () => {
cancelled = true;
};
}, [teamId, currency, devPreview, billingEmail]);
if (loading) {
return (
<div className="upm-stripe-mount" data-state="loading">
<div className="upm-stripe-mount__title">
{t("payg.checkout.connecting", "Connecting to Stripe…")}
</div>
</div>
);
}
if (error) {
return (
<div className="upm-stripe-mount" data-state="error">
<div className="upm-stripe-mount__title">
{t("payg.checkout.errorTitle", "Stripe error")}
</div>
<div>{error}</div>
</div>
);
}
// Mock mode OR no publishable key configured → friendly placeholder.
const showMockPlaceholder = isMock || publishableKey.length === 0;
if (showMockPlaceholder) {
return (
<div className="upm-stripe-mount" data-state="mock">
<div className="upm-stripe-mount__title">
{t(
"payg.checkout.mock.title",
"Stripe Embedded Checkout (mock mode)",
)}
</div>
<div>
{publishableKey.length === 0
? t(
"payg.checkout.mock.noKey",
"VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured.",
)
: t(
"payg.checkout.mock.backend",
"Backend is in mock mode — no real Stripe session was created.",
)}
</div>
<div style={{ marginTop: 12 }}>
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={onComplete}
>
{t(
"payg.checkout.mock.continue",
"Continue with mock subscription",
)}
</button>
</div>
</div>
);
}
if (!clientSecret) return null;
return (
<div className="upm-stripe-mount" data-state="live">
<EmbeddedCheckoutProvider
stripe={getStripe(publishableKey)}
options={{ clientSecret, onComplete }}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
};
export default StripeCheckoutPanel;
@@ -0,0 +1,440 @@
/* Upgrade-to-Processor modal: two steps inside one frame.
Step 1: pick a monthly cap.
Step 2: Stripe Embedded Checkout (or placeholder when no test key set).
The frame doesn't change between steps only the panel slides so the
user feels like they're filling out one form, not navigating pages. */
.upm {
--upm-accent: #0a8bff;
--upm-accent-2: #6c5ce7;
--upm-accent-soft: rgba(10, 139, 255, 0.08);
--upm-success: #10b981;
--upm-radius: 18px;
--upm-card-bg: var(--bg-surface);
--upm-border: var(--border-default);
--upm-divider: var(--border-subtle);
--upm-text: var(--text-primary);
--upm-muted: var(--text-muted);
}
[data-mantine-color-scheme="dark"] .upm {
--upm-card-bg: #313842;
--upm-border: #3d444e;
--upm-divider: #3d444e;
--upm-accent-soft: rgba(10, 139, 255, 0.14);
}
/* Backdrop locks the page and centres the modal. z-index matches
Z_INDEX_OVER_SETTINGS_MODAL (styles/zIndex.ts) must sit above the
AppConfigModal's 1300 (Z_INDEX_OVER_FULLSCREEN_SURFACE). The config modal
also hides itself while we're open (appConfig:overlay event), so this is
belt-and-braces for the brief overlap during open/close transitions. */
.upm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
z-index: 1400;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
animation: upm-fade-in 180ms ease;
}
@keyframes upm-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.upm-frame {
background: var(--upm-card-bg);
border: 1px solid var(--upm-border);
border-radius: var(--upm-radius);
width: 100%;
/* Stripe Embedded Checkout flips from its single-column layout to the two-column
"horizontal" one (order summary beside the payment form) once the iframe is
~1000px wide measured empirically against a live test session; Stripe doesn't
document the breakpoint. The live iframe gets frame_width 44px (the 22px body
padding each side; the live mount's own padding is removed see
.upm-stripe-mount[data-state="live"]). So 1100px ~1056px iframe, comfortably
past the ~1000px threshold. Because the frame stays width:100% under this cap,
narrow windows shrink it and Stripe falls back to single column horizontal
when it fits, vertical when it doesn't. Cap + confirm steps read fine at this
width (their internal text is already capped see upm-confirm__body,
upm-section-help). */
max-width: 1100px;
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.4);
animation: upm-pop-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes upm-pop-in {
from {
opacity: 0;
transform: translateY(12px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ── Header: title + close + step dots ────────────────────────────────── */
.upm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px 14px;
border-bottom: 1px solid var(--upm-divider);
}
.upm-header__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--upm-text);
margin: 0;
}
.upm-header__close {
background: transparent;
border: none;
color: var(--upm-muted);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 120ms ease, color 120ms ease;
}
.upm-header__close:hover {
background: var(--upm-divider);
color: var(--upm-text);
}
/* Left cluster: optional back arrow + title. The back arrow only renders on the
checkout step (cap/confirm have no parent step to return to) it replaces the
old footer "← Back" button. */
.upm-header__left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.upm-header__back {
background: transparent;
border: none;
color: var(--upm-muted);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: -6px;
transition: background 120ms ease, color 120ms ease;
}
.upm-header__back:hover {
background: var(--upm-divider);
color: var(--upm-text);
}
.upm-steps {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 22px 14px;
background: var(--upm-accent-soft);
border-bottom: 1px solid var(--upm-divider);
}
.upm-step {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
font-size: 0.78rem;
font-weight: 600;
color: var(--upm-muted);
transition: color 200ms ease;
}
.upm-step__dot {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--upm-card-bg);
border: 1.5px solid var(--upm-divider);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
}
.upm-step[data-state="active"] {
color: var(--upm-text);
}
.upm-step[data-state="active"] .upm-step__dot {
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
border-color: transparent;
color: white;
}
.upm-step[data-state="done"] {
color: var(--upm-success);
}
.upm-step[data-state="done"] .upm-step__dot {
background: var(--upm-success);
border-color: transparent;
color: white;
}
.upm-step__connector {
flex: 1 1 auto;
height: 1.5px;
background: var(--upm-divider);
margin: 0 4px;
}
/* ── Body: slides between steps without remounting ───────────────────── */
.upm-body {
padding: 22px;
overflow-y: auto;
flex: 1 1 auto;
}
/* Hero promise reinforcement — only shown on step 1 */
.upm-promise {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
background: var(--upm-accent-soft);
border-radius: 12px;
margin-bottom: 18px;
font-size: 0.85rem;
line-height: 1.45;
color: var(--upm-text);
}
.upm-promise__icon {
flex-shrink: 0;
color: var(--upm-accent);
margin-top: 1px;
}
.upm-promise__highlight {
font-weight: 700;
}
.upm-section-title {
font-size: 0.95rem;
font-weight: 700;
color: var(--upm-text);
margin: 0 0 4px;
letter-spacing: -0.005em;
}
.upm-section-help {
font-size: 0.825rem;
color: var(--upm-muted);
margin: 0 0 14px;
line-height: 1.45;
}
/* Cap selection (presets + custom + no-cap + estimate) now lives in the shared
SpendCapControl (SpendCapControl.css). The old upm-cap-* / upm-no-cap-toggle
rules were removed with it. */
/* What counts as "processed" — quiet helper text */
.upm-help-card {
background: var(--upm-divider);
border-radius: 10px;
padding: 12px 14px;
margin-top: 14px;
font-size: 0.78rem;
line-height: 1.5;
color: var(--upm-muted);
}
.upm-help-card__title {
display: block;
font-weight: 700;
color: var(--upm-text);
margin-bottom: 4px;
font-size: 0.8rem;
}
/* On the checkout step, step 1's "Set monthly ceiling" label is replaced (inside
the step bar) by the chosen ceiling + an inline Edit affordance see
UpgradeModal.tsx. Kept here next to the step-bar markup it styles. */
.upm-step__chosen {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--upm-text);
font-weight: 700;
}
.upm-step__edit {
background: transparent;
border: none;
color: var(--upm-accent);
font-weight: 600;
cursor: pointer;
font-family: inherit;
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 6px;
}
.upm-step__edit:hover {
background: rgba(10, 139, 255, 0.12);
}
/* Placeholder card for the loading / error / mock states a dashed, centered
box with helper text. The live Stripe iframe is NOT meant to wear this chrome;
it gets a full-width reset below (data-state="live"). */
.upm-stripe-mount {
min-height: 220px;
border: 1.5px dashed var(--upm-divider);
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 8px;
color: var(--upm-muted);
font-size: 0.875rem;
}
/* Live Stripe Embedded Checkout strip the placeholder card chrome so the
iframe spans the full body width and Stripe renders its two-column
"horizontal" layout. Without this the dashed border + 24px padding + centering
above box the iframe ~48px narrower, dropping it back to single column. */
.upm-stripe-mount[data-state="live"] {
display: block;
border: none;
padding: 0;
min-height: 0;
text-align: left;
}
.upm-stripe-mount__title {
font-weight: 700;
color: var(--upm-text);
font-size: 0.95rem;
}
.upm-stripe-mount__code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.75rem;
padding: 2px 6px;
background: var(--upm-card-bg);
border-radius: 4px;
border: 1px solid var(--upm-divider);
color: var(--upm-text);
}
/* ── Footer / nav actions ─────────────────────────────────────────────── */
.upm-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 22px;
border-top: 1px solid var(--upm-divider);
background: var(--upm-card-bg);
}
.upm-footer__actions {
display: flex;
gap: 8px;
}
.upm-btn {
padding: 10px 18px;
border-radius: 10px;
border: 1.5px solid transparent;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 120ms ease;
font-family: inherit;
white-space: nowrap;
}
.upm-btn[data-variant="ghost"] {
background: transparent;
border-color: var(--upm-divider);
color: var(--upm-text);
}
.upm-btn[data-variant="ghost"]:hover {
border-color: var(--upm-accent);
color: var(--upm-accent);
}
.upm-btn[data-variant="primary"] {
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
color: white;
}
.upm-btn[data-variant="primary"]:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5);
}
.upm-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
/* ── Step 3: confirmation ─────────────────────────────────────────────── */
.upm-confirm {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 12px 8px 4px;
gap: 12px;
}
.upm-confirm__icon {
font-size: 64px !important;
color: #10b981;
filter: drop-shadow(0 6px 18px rgba(16, 185, 129, 0.35));
}
.upm-confirm__title {
margin: 6px 0 0;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.015em;
color: var(--upm-text);
}
.upm-confirm__body {
margin: 0;
font-size: 0.95rem;
color: var(--upm-text-muted);
max-width: 380px;
line-height: 1.55;
}
.upm-confirm__summary {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
max-width: 360px;
padding: 14px 18px;
background: var(--upm-inset-bg, rgba(0, 0, 0, 0.03));
border: 1px solid var(--upm-divider);
border-radius: 12px;
margin-top: 6px;
font-size: 0.92rem;
}
.upm-confirm__summary > strong {
color: var(--upm-text);
font-size: 1.05rem;
}
.upm-confirm__note {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--upm-text-muted);
max-width: 380px;
line-height: 1.5;
}
@@ -0,0 +1,532 @@
/**
* Upgrade-to-Processor modal. Three sequential panels inside one frame:
*
* Step 1: Cap selection local state only, no side effects
* Step 2: Stripe Checkout POSTs to /api/v1/payg/checkout, mounts the
* Stripe Embedded Checkout iframe (lazy-loaded)
* Step 3: Confirmation brief "Welcome to Processor" beat before
* the modal closes and the parent's
* {@code onComplete} triggers a wallet refetch
*
* <p>The Stripe SDK ({@code @stripe/stripe-js} + {@code @stripe/react-stripe-js})
* is loaded via {@code React.lazy} on a dedicated module so the chunk only
* downloads when the user actually advances to step 2. The main bundle pays
* nothing for users who never open the modal.
*
* <p>Cap state is held locally nothing reaches the backend until the user
* commits in step 2. A user who cancels mid-modal leaves no side effects.
*/
import React, { Suspense, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import CloseIcon from "@mui/icons-material/CloseRounded";
import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
import CheckCircleIcon from "@mui/icons-material/CheckCircleRounded";
import { useTranslation } from "react-i18next";
// eslint-disable-next-line no-restricted-imports
import "./UpgradeModal.css";
// eslint-disable-next-line no-restricted-imports
import SpendCapControl from "./SpendCapControl";
/**
* Tell the AppConfigModal (or any other full-screen surface listening) that an
* upgrade overlay is opening/closing so it can hide itself rather than stack
* under us. Same window-event pattern the config modal already uses for
* appConfig:navigate / appConfig:notice.
*/
function dispatchOverlay(open: boolean) {
window.dispatchEvent(
new CustomEvent("appConfig:overlay", { detail: { open } }),
);
}
// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user
// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the
// chunk-graph reasoning.
const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel"));
interface UpgradeModalProps {
open: boolean;
/**
* The caller's team_id. Threaded through to {@link StripeCheckoutPanel} so the
* {@code create-checkout-session} edge function can scope the subscription to
* the right team.
*/
teamId: number;
/** Called when the user closes the modal without completing checkout. */
onClose: () => void;
/**
* Called after the user confirms cap + completes Stripe checkout. The parent
* is expected to refresh the wallet snapshot which will then show the
* subscribed state.
*/
onComplete: (result: { capUsd: number | null }) => void;
/** ISO 4217 currency code for the cap input. Default USD. */
currency?: "USD" | "EUR" | "GBP";
/**
* The team's one-time free grant in documents the real {@code
* wallet.freeAllowance}, threaded from the free-leader view so the step copy
* quotes the backend's number instead of a hardcoded one. A lifetime grant,
* not a monthly one.
*/
freeLimit: number;
/**
* Per-document rate in minor units for the live "≈ N paid PDFs/month"
* estimate, threaded from {@code wallet.pricePerDocMinor}. For unsubscribed
* teams the backend resolves this from the default pricing policy's USD
* Price (Stripe hasn't assigned the team a currency yet). Null hides the
* estimate.
*/
pricePerDocMinor?: number | null;
/** Lower-case ISO currency of {@link #pricePerDocMinor} (e.g. {@code "usd"}). */
rateCurrency?: string | null;
}
type Step = "cap" | "checkout" | "confirm";
function currencySymbol(c: UpgradeModalProps["currency"]): string {
switch (c) {
case "EUR":
return "€";
case "GBP":
return "£";
default:
return "$";
}
}
export default function UpgradeModal({
open,
teamId,
onClose,
onComplete,
currency = "USD",
freeLimit,
pricePerDocMinor,
rateCurrency,
}: UpgradeModalProps) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("cap");
const [capUsd, setCapUsd] = useState<number>(500);
const [noCap, setNoCap] = useState<boolean>(false);
// The config modal hides itself while we're open (it listens for this event)
// so the upgrade flow visually REPLACES it instead of stacking inside it.
// Cleanup fires open=false on unmount too, so the config modal can't get
// stuck hidden if we unmount without a clean close.
useEffect(() => {
dispatchOverlay(open);
return () => dispatchOverlay(false);
}, [open]);
if (!open) {
return null;
}
const effectiveCap = noCap ? null : capUsd;
const sym = currencySymbol(currency);
const goToCheckout = () => setStep("checkout");
const goBackToCap = () => setStep("cap");
const goToConfirm = () => setStep("confirm");
// Modal close → reset internal step so reopening starts at step 1.
const closeAndReset = () => {
setStep("cap");
onClose();
};
// Portal to document.body so the overlay escapes the config modal's portal /
// stacking context. Without this the fixed-position backdrop layers inside
// the Mantine modal (z-index 1300) instead of over the whole page, producing
// the modal-in-modal look.
return createPortal(
<div className="upm" role="dialog" aria-modal="true">
<div className="upm-backdrop" onClick={closeAndReset}>
<div className="upm-frame" onClick={(e) => e.stopPropagation()}>
{/* Header title + close. Title stays constant; the step indicator
below tells the user where they are. */}
<header className="upm-header">
<div className="upm-header__left">
{step === "checkout" && (
<button
type="button"
className="upm-header__back"
aria-label={t("payg.upgrade.backAria", "Back")}
onClick={goBackToCap}
>
<ArrowBackIcon fontSize="small" />
</button>
)}
<h2 className="upm-header__title">
{step === "confirm"
? t("payg.upgrade.title.confirm", "You're subscribed")
: t("payg.upgrade.title.default", "Upgrade to Processor plan")}
</h2>
</div>
<button
type="button"
className="upm-header__close"
aria-label={t("payg.upgrade.closeAria", "Close")}
onClick={closeAndReset}
>
<CloseIcon fontSize="small" />
</button>
</header>
{/* Step indicator. Hidden on the confirmation panel since the
modal is winding down at that point. */}
{step !== "confirm" && (
<div className="upm-steps">
<div
className="upm-step"
data-state={step === "cap" ? "active" : "done"}
>
<span className="upm-step__dot">1</span>
{step === "checkout" ? (
<span className="upm-step__chosen">
{effectiveCap === null
? t("payg.upgrade.checkout.noCap", "No cap")
: t(
"payg.upgrade.checkout.capValue",
"{{symbol}}{{amount}} / month",
{ symbol: sym, amount: effectiveCap },
)}
<button
type="button"
className="upm-step__edit"
onClick={goBackToCap}
>
{t("payg.upgrade.checkout.edit", "Edit")}
</button>
</span>
) : (
<span>
{t("payg.upgrade.steps.cap", "Set monthly ceiling")}
</span>
)}
</div>
<div className="upm-step__connector" />
<div
className="upm-step"
data-state={step === "checkout" ? "active" : "idle"}
>
<span className="upm-step__dot">2</span>
<span>
{t(
"payg.upgrade.steps.payment",
"Add payment method",
)}
</span>
</div>
</div>
)}
<div className="upm-body">
{step === "cap" && (
<CapStep
capUsd={capUsd}
setCapUsd={setCapUsd}
noCap={noCap}
setNoCap={setNoCap}
pricePerDocMinor={pricePerDocMinor}
rateCurrency={rateCurrency}
/>
)}
{step === "checkout" && (
// Keyed on the cap value so editing the cap → returning to
// step 2 unmounts + remounts the panel, triggering a fresh
// POST /checkout for the new cap. Without the key, the
// StripeCheckoutPanel's fetchedRef short-circuits and the
// session keeps the stale cap.
<CheckoutStep
key={`co:${effectiveCap ?? "nocap"}`}
teamId={teamId}
effectiveCap={effectiveCap}
currency={currency}
onComplete={goToConfirm}
/>
)}
{step === "confirm" && (
<ConfirmationStep
effectiveCap={effectiveCap}
currency={currency}
freeLimit={freeLimit}
/>
)}
</div>
{step !== "checkout" && (
<footer className="upm-footer">
<div className="upm-footer__actions">
{step === "cap" && (
<>
<button
type="button"
className="upm-btn"
data-variant="ghost"
onClick={closeAndReset}
>
{t("payg.upgrade.button.cancel", "Cancel")}
</button>
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={goToCheckout}
>
{t("payg.upgrade.button.continue", "Continue →")}
</button>
</>
)}
{step === "confirm" && (
<button
type="button"
className="upm-btn"
data-variant="primary"
onClick={() => {
setStep("cap");
onComplete({ capUsd: effectiveCap });
}}
>
{t("payg.upgrade.button.finish", "Finish")}
</button>
)}
</div>
</footer>
)}
</div>
</div>
</div>,
document.body,
);
}
// ─── Step 1: cap selection ──────────────────────────────────────────────
interface CapStepProps {
capUsd: number;
setCapUsd: (v: number) => void;
noCap: boolean;
setNoCap: (v: boolean) => void;
pricePerDocMinor?: number | null;
rateCurrency?: string | null;
}
function CapStep({
capUsd,
setCapUsd,
noCap,
setNoCap,
pricePerDocMinor,
rateCurrency,
}: CapStepProps) {
const { t } = useTranslation();
return (
<>
<div className="upm-promise">
<ShieldIcon className="upm-promise__icon" fontSize="small" />
<div>
<span className="upm-promise__highlight">
{t(
"payg.upgrade.promise.highlight",
"Manual tools stay free, always.",
)}
</span>{" "}
{t(
"payg.upgrade.promise.body",
"You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge.",
)}
</div>
</div>
<h3 className="upm-section-title">
{t(
"payg.upgrade.cap.title",
"Set your monthly spend ceiling",
)}
</h3>
<p className="upm-section-help">
{t(
"payg.upgrade.cap.help",
"We'll never charge above this. Set $0 if you want to keep everything free while testing.",
)}
</p>
{/* Same control the subscribed plan page renders. null = no cap; the
shared control owns the presets, the inline custom-entry pill, the
no-cap chip, and the live processed-PDF estimate. */}
<SpendCapControl
capUsd={noCap ? null : capUsd}
onChange={(v) => {
if (v === null) {
setNoCap(true);
} else {
setNoCap(false);
setCapUsd(v);
}
}}
pricePerDocMinor={pricePerDocMinor}
currency={rateCurrency}
note={t(
"payg.upgrade.cap.usdNote",
"Estimated in USD. You can adjust your cap any time after subscribing — in your own currency.",
)}
/>
<div className="upm-help-card" style={{ marginTop: 16 }}>
<span className="upm-help-card__title">
{t(
"payg.upgrade.help.title",
"What we count toward billing",
)}
</span>
<ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}>
<li>
<strong>
{t(
"payg.upgrade.help.automationTitle",
"Automation pipelines",
)}
</strong>
{" — "}
{t(
"payg.upgrade.help.automationBody",
"chained tools or scheduled runs that don't need clicks",
)}
</li>
<li>
<strong>
{t("payg.upgrade.help.aiTitle", "AI tools")}
</strong>
{" — "}
{t(
"payg.upgrade.help.aiBody",
"summarise, classify, redact, AI-OCR",
)}
</li>
<li>
<strong>
{t("payg.upgrade.help.apiTitle", "API calls")}
</strong>
{" — "}
{t(
"payg.upgrade.help.apiBody",
"programmatic access to any Stirling endpoint",
)}
</li>
</ul>
<div style={{ marginTop: 8, fontStyle: "italic" }}>
{t(
"payg.upgrade.help.footnote",
"Manual tools — viewing, editing, merging, splitting, signing, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click.",
)}
</div>
</div>
</>
);
}
// ─── Step 2: Stripe Embedded Checkout (lazy-loaded) ────────────────────
interface CheckoutStepProps {
teamId: number;
effectiveCap: number | null;
currency: UpgradeModalProps["currency"];
onComplete: () => void;
}
function CheckoutStep({
teamId,
effectiveCap,
currency,
onComplete,
}: CheckoutStepProps) {
const { t } = useTranslation();
return (
<>
<h3 className="upm-section-title">
{t(
"payg.upgrade.checkout.title",
"Add your payment method",
)}
</h3>
<p className="upm-section-help">
{t(
"payg.upgrade.checkout.help",
"Stripe handles your card details. Stirling never sees them.",
)}
</p>
<Suspense
fallback={
<div className="upm-stripe-mount" data-state="loading">
<div className="upm-stripe-mount__title">
{t("payg.upgrade.checkout.loading", "Loading checkout…")}
</div>
</div>
}
>
<StripeCheckoutPanel
teamId={teamId}
currency={currency?.toLowerCase() ?? "gbp"}
capUsd={effectiveCap}
onComplete={onComplete}
/>
</Suspense>
</>
);
}
// ─── Step 3: confirmation ──────────────────────────────────────────────
interface ConfirmationStepProps {
effectiveCap: number | null;
currency: UpgradeModalProps["currency"];
freeLimit: number;
}
function ConfirmationStep({
effectiveCap,
currency,
freeLimit,
}: ConfirmationStepProps) {
const { t } = useTranslation();
const sym = currencySymbol(currency);
return (
<div className="upm-confirm">
<CheckCircleIcon className="upm-confirm__icon" />
<h3 className="upm-confirm__title">
{t(
"payg.confirm.title",
"Welcome to the Processor plan",
)}
</h3>
<p className="upm-confirm__body">
{t(
"payg.confirm.body",
"Your team can now process documents with automation, AI, and the API beyond your {{limit}} free PDFs.",
{ limit: freeLimit.toLocaleString() },
)}
</p>
<div className="upm-confirm__summary">
<span>{t("payg.confirm.summaryLabel", "Monthly ceiling")}</span>
<strong>
{effectiveCap === null
? t("payg.confirm.noCap", "No cap")
: t("payg.confirm.capValue", "{{symbol}}{{amount}} / month", {
symbol: sym,
amount: effectiveCap,
})}
</strong>
</div>
<p className="upm-confirm__note">
{t(
"payg.confirm.note",
"You can change your cap, cancel, or open the Stripe customer portal any time from this page.",
)}
</p>
</div>
);
}
@@ -1,79 +1,32 @@
import { useCallback, useEffect, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { ApiCredits } from "@app/types/credits";
import { isUserAnonymous } from "@app/auth/supabase";
function coerceNumber(value: unknown, fallback = 0): number {
const n = typeof value === "string" ? Number(value) : (value as number);
return Number.isFinite(n) ? n : fallback;
}
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
// Accept a variety of possible backend keys to be resilient
return {
weeklyCreditsRemaining: coerceNumber(
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left,
),
weeklyCreditsAllocated: coerceNumber(
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total,
),
boughtCreditsRemaining: coerceNumber(
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left,
),
totalBoughtCredits: coerceNumber(
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total,
),
totalAvailableCredits: coerceNumber(
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total,
),
weeklyResetDate: String(
raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? "",
),
lastApiUsage: String(
raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? "",
),
};
}
export function useCredits() {
const { session, loading, user } = useAuth();
const isAnonymous = Boolean(user && isUserAnonymous(user));
const [data, setData] = useState<ApiCredits | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
// Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only
// hasAttempted toggles, so the rest have no setters.
const [data] = useState<ApiCredits | null>(null);
const [isLoading] = useState<boolean>(false);
const [error] = useState<Error | null>(null);
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
// Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the
// wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The
// hook surface is preserved for the ApiKeys page consumer (it destructures `data`,
// `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null —
// the consumer's usage widget will render its "no data" state.
const fetchCredits = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const res =
await apiClient.get<Record<string, unknown>>("/api/v1/credits");
const normalized = normalizeCredits(res.data);
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
const isEmpty =
!normalized.weeklyCreditsAllocated &&
!normalized.weeklyCreditsRemaining &&
!normalized.totalBoughtCredits &&
!normalized.boughtCreditsRemaining &&
!normalized.totalAvailableCredits &&
!normalized.weeklyResetDate &&
!normalized.lastApiUsage;
setData(isEmpty ? null : normalized);
} catch (e: unknown) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setIsLoading(false);
setHasAttempted(true);
}
setHasAttempted(true);
}, []);
useEffect(() => {
if (!loading && session && !hasAttempted && !isAnonymous) {
fetchCredits();
setHasAttempted(true);
}
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
}, [loading, session, hasAttempted, isAnonymous]);
return {
data,
@@ -1,143 +0,0 @@
import React from "react";
import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import CreditCardIcon from "@mui/icons-material/CreditCard";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import { ManageBillingButton } from "@app/components/shared/ManageBillingButton";
interface TrialStatus {
isTrialing: boolean;
trialEnd: string;
daysRemaining: number;
hasPaymentMethod: boolean;
hasScheduledSub: boolean;
}
interface ActivePlanSectionProps {
currentPlan: PlanTier;
_activeSince?: string;
_nextBillingDate?: string;
trialStatus?: TrialStatus;
onAddPaymentClick?: () => void;
}
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
currentPlan,
_activeSince,
_nextBillingDate,
trialStatus,
onAddPaymentClick,
}) => {
const { t } = useTranslation();
return (
<div>
<Flex justify="space-between" align="center">
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.activePlan.title", "Active Plan")}
</h3>
<ManageBillingButton
returnUrl={`${window.location.origin}/account`}
trialStatus={trialStatus}
/>
</Flex>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t("plan.activePlan.subtitle", "Your current subscription details")}
</p>
{/* Trial Status Alert */}
{trialStatus?.isTrialing && (
<Alert
color="blue"
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
mt="md"
mb="md"
title={t("plan.trial.title", "Free Trial Active")}
>
<Text size="sm">
{t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", {
days: trialStatus.daysRemaining,
})}
</Text>
<Text size="xs" c="dimmed">
{t("plan.trial.endDate", "Expires: {{date}}", {
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
})}
</Text>
{trialStatus.hasScheduledSub ? (
<Text size="xs" c="green" fw={500} mt="sm">
{" "}
{t(
"plan.trial.subscriptionScheduled",
"Subscription scheduled - starts {{date}}",
{
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
},
)}
</Text>
) : (
onAddPaymentClick && (
<Button
size="xs"
variant="light"
mt="sm"
onClick={onAddPaymentClick}
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
>
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
</Button>
)
)}
</Alert>
)}
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<div>
<Group gap="xs">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
{trialStatus?.isTrialing && (
<Badge color="blue" variant="light">
{t("plan.trial.badge", "Trial")}
</Badge>
)}
</Group>
{/* {activeSince && (
<Text size="sm" c="dimmed">
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
</Text>
)} */}
</div>
<div className="text-right">
<Text size="xl" fw={700}>
{currentPlan.currency}
{currentPlan.price}/month
</Text>
{/* {nextBillingDate && (
<Text size="sm" c="dimmed">
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
</Text>
)} */}
</div>
</Group>
</Card>
</div>
);
};
export default ActivePlanSection;
@@ -1,126 +0,0 @@
import React from "react";
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import type { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
interface ApiPackage {
id: string;
name: string;
credits: number;
price: number;
currency: string;
description: string;
}
interface ApiPackagesSectionProps {
apiPackages: ApiPackage[];
selectedCredits: number;
onSelectedCreditsChange: (value: number) => void;
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
}
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
apiPackages,
selectedCredits,
onSelectedCreditsChange,
onCreditPurchaseClick,
}) => {
const { t } = useTranslation();
return (
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.apiPackages.title", "API Credit Packages")}
</h3>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.apiPackages.subtitle",
"Purchase API credits for your applications",
)}
</p>
<Card padding="xl" radius="md" className="mb-4">
<Stack gap="xl">
{/* Credits Selection */}
<div>
<Text size="lg" fw={600} mb="md">
{t("plan.selectCredits", "Select Credit Amount")}
</Text>
<div className="px-4">
<Slider
value={selectedCredits}
onChange={onSelectedCreditsChange}
onChangeEnd={(value) =>
onSelectedCreditsChange(Math.round(value))
}
min={0}
max={3}
step={0.01}
marks={[
{ value: 0, label: "100" },
{ value: 1, label: "500" },
{ value: 2, label: "1K" },
{ value: 3, label: "5K" },
]}
size="lg"
className="mb-6"
label={null}
/>
</div>
</div>
{/* Selected Package Display */}
<Flex gap={"xl"} justify="space-between" align="center">
<div>
<Text size="xl" fw={700}>
{apiPackages[
Math.round(selectedCredits)
].credits.toLocaleString()}{" "}
Credits
</Text>
<Text size="sm" c="dimmed">
{apiPackages[Math.round(selectedCredits)].description}
</Text>
</div>
<div className="">
<Text size="xl" fw={700}>
{apiPackages[Math.round(selectedCredits)].currency}
{apiPackages[Math.round(selectedCredits)].price}
</Text>
<Text size="sm" c="dimmed">
{t("plan.totalCost", "Total Cost")}
</Text>
</div>
<Button
size="lg"
onClick={() =>
onCreditPurchaseClick(
apiPackages[Math.round(selectedCredits)].id as CreditsPack,
)
}
>
{t("plan.purchase", "Purchase")}
</Button>
</Flex>
</Stack>
</Card>
</div>
);
};
export default ApiPackagesSection;
@@ -1,148 +0,0 @@
import React, { useState } from "react";
import { Button, Card, Badge, Text, Collapse } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlan?: PlanTier;
currentLicenseInfo?: unknown;
onUpgradeClick: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: Array<{ value: string; label: string }>;
loginEnabled?: boolean;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlan,
onUpgradeClick,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
const isUserProOrAbove =
currentPlan?.id === "pro" || currentPlan?.id === "enterprise";
return (
<div>
<h3
style={{
margin: 0,
color: "var(--mantine-color-text)",
fontSize: "1rem",
}}
>
{t("plan.availablePlans.title", "Available Plans")}
</h3>
<p
style={{
margin: "0.25rem 0 1rem 0",
color: "var(--mantine-color-dimmed)",
fontSize: "0.875rem",
}}
>
{t(
"plan.availablePlans.subtitle",
"Choose the plan that fits your needs",
)}
</p>
<div
className="flex h-[20rem] mb-4 "
style={{ gap: "1rem", overflowX: "auto" }}
>
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
isCurrentPlan={plan.id === currentPlan?.id}
isUserProOrAbove={isUserProOrAbove}
onUpgradeClick={onUpgradeClick}
/>
))}
</div>
<div className="text-center">
<Button
variant="subtle"
onClick={() => setShowComparison(!showComparison)}
>
{showComparison
? t("plan.hideComparison", "Hide Feature Comparison")
: t("plan.showComparison", "Compare All Features")}
</Button>
</div>
<Collapse in={showComparison}>
<Card padding="lg" radius="md" withBorder className="mt-4">
<Text size="lg" fw={600} mb="md">
{t("plan.featureComparison", "Feature Comparison")}
</Text>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b">
<th className="text-left p-2">
{t("plan.feature.title", "Feature")}
</th>
{plans.map((plan) => (
<th
key={plan.id}
className="text-center p-2 min-w-24 relative"
>
{plan.name}
{plan.popular && (
<Badge
color="blue"
variant="filled"
style={{
position: "absolute",
top: "0rem",
right: "-2rem",
fontSize: "0.5rem",
fontWeight: "500",
height: "1rem",
padding: "0 0.1rem",
}}
>
{t("plan.popular", "Popular")}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0].features.map((_, featureIndex) => (
<tr key={featureIndex} className="border-b">
<td className="p-2">
{plans[0].features[featureIndex].name}
</td>
{plans.map((plan) => (
<td key={plan.id} className="text-center p-2">
{plan.features[featureIndex].included ? (
<Text c="green" fw={600}>
</Text>
) : (
<Text c="gray">-</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -1,113 +0,0 @@
import React from "react";
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PlanTier } from "@app/hooks/usePlans";
interface PlanCardProps {
plan?: PlanTier;
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
isCurrentPlan?: boolean;
isCurrentTier?: boolean;
isDowngrade?: boolean;
isUserProOrAbove?: boolean;
currentLicenseInfo?: unknown;
currentTier?: string | null; // Accept null for proprietary compatibility
onUpgradeClick?: (plan: PlanTier) => void;
onManageClick?: (plan: PlanTier) => void;
loginEnabled?: boolean;
}
const PlanCard: React.FC<PlanCardProps> = ({
plan: propPlan,
planGroup,
isCurrentPlan,
isCurrentTier: _isCurrentTier,
isDowngrade: _isDowngrade,
isUserProOrAbove,
currentLicenseInfo: _currentLicenseInfo,
currentTier: _currentTier,
onUpgradeClick,
onManageClick: _onManageClick,
loginEnabled: _loginEnabled,
}) => {
// Use plan from props, or extract from planGroup if proprietary is using it
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
const { t } = useTranslation();
if (!plan) return null; // Safety check
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
return (
<Card
key={plan.id}
padding="lg"
radius="sm"
withBorder
className="h-full w-[33%] relative"
>
{plan.popular && (
<Badge
variant="filled"
size="xs"
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
>
{t("plan.popular", "Popular")}
</Badge>
)}
<Stack gap="md" className="h-full">
<div>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" align="baseline">
<Text size="2xl" fw={700}>
{plan.isContactOnly
? t("plan.customPricing", "Custom")
: `${plan.currency}${plan.price}`}
</Text>
{!plan.isContactOnly && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight: string, index: number) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div className="flex-grow" />
{!shouldHideUpgrade && (
<Button
variant={
isCurrentPlan
? "filled"
: plan.isContactOnly
? "outline"
: "filled"
}
disabled={isCurrentPlan}
fullWidth
onClick={() => onUpgradeClick?.(plan)}
>
{isCurrentPlan
? t("plan.current", "Current Plan")
: plan.isContactOnly
? t("plan.contact", "Get in Touch")
: t("plan.upgrade", "Upgrade")}
</Button>
)}
</Stack>
</Card>
);
};
export default PlanCard;
@@ -151,7 +151,11 @@ function appendMcpSection(
export function createSaasConfigNavSections(
Overview: OverviewComponent,
onLogoutClick: () => void,
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
{
isDev = false,
isAnonymous = false,
t,
}: CreateSaasConfigNavSectionsOptions,
): ConfigNavSection[] {
const baseSections = createCoreConfigNavSections(false, false, false);
@@ -203,6 +207,9 @@ export function createSaasConfigNavSections(
sections = appendMcpSection(sections, t);
if (!isAnonymous) {
// The Plan tab is now the single billing surface — it internally branches
// free vs subscribed × leader vs member via useWallet(). The old separate
// "Pay-as-you-go" tab and paygEnabled / isLeader options were removed.
sections = appendBillingSection(sections, t);
}
@@ -1,63 +1,26 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useCredits } from "@app/hooks/useCredits";
import { getToolCreditCost } from "@app/utils/creditCosts";
import { openPlanSettings } from "@app/utils/appSettings";
import type { ToolId } from "@app/types/toolId";
export function useCreditCheck(operationType?: string, _endpoint?: string) {
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } =
useCredits();
const { t } = useTranslation();
/**
* Pre-flight credit-balance check, formerly run before every billable tool call.
*
* Replaced by PAYG's reactive 402 FEATURE_DEGRADED handler (see paygErrorInterceptor.ts):
* we no longer try to predict whether the user has "enough credits" before the request
* we make the request, and if the wallet hits the free-tier ceiling the BE returns 402
* with a discriminating code that the global axios interceptor turns into a toast +
* prompt-to-add-card. This is more accurate (no race between FE balance cache and the
* BE's atomic debit) and avoids the round-trip latency of a pre-flight call.
*
* The hook signature is preserved as a no-op so {@code useToolOperation} (the sole
* caller) compiles without modification. {@code checkCredits} always resolves to null
* the BE's 402 handler is now the only gate.
*/
export function useCreditCheck(
_operationType?: string,
_endpoint?: string,
): { checkCredits: (_runtimeEndpoint?: string) => Promise<string | null> } {
const checkCredits = useCallback(
async (_runtimeEndpoint?: string): Promise<string | null> => {
const requiredCredits = getToolCreditCost(operationType as ToolId);
const creditCheck = hasSufficientCredits(requiredCredits);
if (creditBalance === null) {
try {
await refreshCredits();
} catch (_e) {
void _e;
}
return t("loadingCredits", "Checking credits...");
}
if (isPro === null) {
return t("loadingProStatus", "Checking subscription status...");
}
if (!isPro && !creditCheck.hasSufficientCredits) {
const shortfall = creditCheck.shortfall || 0;
const error = t(
"insufficientCredits",
"Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}",
{
requiredCredits,
currentBalance: creditCheck.currentBalance,
shortfall,
},
);
const notice = t(
"noticeTopUpOrPlan",
"Not enough credits, please top up or upgrade to a plan",
);
openPlanSettings(notice);
return error;
}
return null;
},
[
hasSufficientCredits,
isPro,
creditBalance,
refreshCredits,
operationType,
t,
],
async (_runtimeEndpoint?: string): Promise<string | null> => null,
[],
);
return { checkCredits };
}
@@ -0,0 +1,34 @@
/**
* Dev-only render counter. Logs every render with a stable label so we can
* eyeball excessive re-renders during PAYG Plan-page work. Counts to
* {@code window.__renderCounts[label]} so a Playwright eval can assert
* "Plan rendered ≤ 3 times after a wallet refetch."
*
* <p>In production builds {@code import.meta.env.DEV} is constant-folded to
* {@code false}, so the module-level constant {@code IS_DEV} below allows
* Vite's dead-code-elimination to drop the whole hook body no extra refs,
* no extra renders, no window pollution.
*/
import { useRef } from "react";
declare global {
interface Window {
__renderCounts?: Record<string, number>;
}
}
const IS_DEV = import.meta.env.DEV;
export function useRenderCount(label: string): number {
// useRef must run unconditionally to satisfy rules-of-hooks. In production
// the rest of the body is dead-code-eliminated, leaving just this single
// ref allocation.
const count = useRef(0);
if (!IS_DEV) return 0;
count.current += 1;
if (typeof window !== "undefined") {
if (!window.__renderCounts) window.__renderCounts = {};
window.__renderCounts[label] = count.current;
}
return count.current;
}
+526
View File
@@ -0,0 +1,526 @@
/**
* Hook backing the PAYG Plan page. Wraps {@code GET /api/v1/payg/wallet}
* (served by {@code PaygWalletController} once Wave 1 BE lands; until then
* the dev preview route synthesises a wallet from localStorage) and exposes
* mutations for marking-subscribed and updating-the-cap.
*
* <h2>Render efficiency</h2>
*
* The hook is designed so {@code Plan}, {@code PaygFreeLeader/Member}, and
* {@code PaygLeader/Member} re-render only on actual data change:
*
* <ul>
* <li>{@link Wallet} snapshot is stored as a plain object but every
* successful fetch deep-compares with the previous snapshot and reuses
* the prior reference if the payload is unchanged. Consumers that hold
* a stable {@code wallet} reference get stable child memoisation.
* <li>The returned {@link UseWalletResult} keeps stable callback identities
* via {@code useCallback}. {@code Plan} can pass {@code markSubscribed}
* to {@code UpgradeModal} without forcing a remount.
* <li>{@code refetch / markSubscribed / updateCap} bump an internal counter
* that the {@code useEffect} watches no global state plumbing.
* <li>A monotonic {@code requestId} ref drops stale responses so a slow
* refetch from tick=N can't overwrite a faster one from tick=N+1
* (out-of-order resolution would otherwise show old data).
* </ul>
*
* <h2>Mutation semantics</h2>
*
* Both {@code markSubscribed} and {@code updateCap} resolve only after the
* post-mutation wallet refetch completes. So callers like the cap-editor
* "Update cap" button that gate a {@code loading} state on the returned
* promise see the UI flip exactly once the new state is visible no
* intermediate flash of the old value.
*
* <h2>Dev preview fallback</h2>
*
* When the hook is rendered outside the saas app (e.g. on {@code
* /dev/payg-preview} during local design work) the {@code AppConfigContext}
* provider is not mounted and no backend is available. The hook detects that
* via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to
* a synthesised snapshot whose subscription state is read from
* {@code localStorage} (key {@code stirling.payg.devSubscription}). Both
* conditions are required so a production tenant whose URL happens to start
* with {@code /dev/} can't trigger the fallback.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import apiClient from "@app/services/apiClient";
import { supabase } from "@app/auth/supabase";
// ─── Public types ───────────────────────────────────────────────────────
export type WalletStatus = "free" | "subscribed";
export type WalletRole = "leader" | "member";
/**
* A single team member's billing-relevant info name + email for the avatar
* row, {@code spendUnits} for their per-member usage display. Mirrors a row of
* the backend's {@code members} array on {@code WalletSnapshot} (joined with
* {@code team_memberships}).
*/
export interface WalletMember {
/** Supabase user id of the member. */
userId: string;
name: string;
email: string;
/** Member's current-period billable spend. */
spendUnits: number;
}
/**
* Per-category breakdown of current-period spend in billable units. The
* categories mirror the {@code FeatureGate} buckets the backend tracks:
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
* billableUsed} (modulo rounding in mock data).
*/
export interface WalletCategoryBreakdown {
api: number;
ai: number;
automation: number;
}
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
export interface Wallet {
/**
* The caller's primary team_id. Needed when invoking Supabase edge functions
* (create-checkout-session, etc.) that run outside Spring Security and have
* no other way to resolve the caller's team. May be null on the synthetic
* empty snapshot returned to anonymous / team-less callers.
*/
teamId: number | null;
status: WalletStatus;
role: WalletRole;
/**
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
* the calendar month for free teams.
*/
billingPeriodStart: string;
billingPeriodEnd: string;
/**
* For a free team: the one-time free documents used so far ({@code
* freeAllowance freeRemaining}). For a subscribed team: documents
* processed this month across automation + AI + API.
*/
billableUsed: number;
/**
* The team's document ceiling for the matching window: the one-time free
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
* subscribed with no cap (uncapped).
*/
billableLimit: number | null;
/**
* The team's one-time free document grant size the "N" in "X of N free".
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
* and is not lost when the team subscribes.
*/
freeAllowance: number;
/**
* One-time free documents still available to the team
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* Survives subscribing a subscribed team keeps any unused grant.
*/
freeRemaining: number;
/**
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
* fractional); null when the rate can't be resolved render "unknown",
* never substitute.
*/
pricePerDocMinor: number | null;
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
currency: string | null;
/**
* Estimated charges so far this period in minor units of currency: paid
* (Stripe-metered) documents this period × rate. The free portion was
* already netted out at charge time. Informational the Stripe invoice
* is authoritative. Null when the rate is unknown.
*/
estimatedBillMinor: number | null;
/** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */
capUsd: number | null;
/** Only meaningful when status=='subscribed'. */
noCap: boolean;
/** Stripe subscription id when subscribed; null when free. */
stripeSubscriptionId: string | null;
/** Current-period spend in billable units. */
spendUnitsThisPeriod: number;
/** Per-category spend breakdown (api / ai / automation). */
categoryBreakdown: WalletCategoryBreakdown;
/**
* Team members, populated for the leader view; empty for members or
* single-seat tenants. Leader-vs-member is still resolved via {@link
* Wallet#role} this field just carries the per-member rows the leader's
* sub-cap table needs.
*/
members: WalletMember[];
/**
* Recent billable-activity rows. V1 returns {@code []} from the backend;
* the field exists so the Plan page can render an empty state without
* branching on undefined. Each entry is a {@code Record<string, unknown>}
* because the activity-row shape is not yet finalised when the meter-
* event surface lands, this widens to a real interface.
*/
recent: Array<Record<string, unknown>>;
}
export interface UseWalletResult {
wallet: Wallet | null;
loading: boolean;
error: string | null;
/** Force a refetch — e.g. after Stripe redirects back into the app. */
refetch: () => Promise<void>;
/**
* Dev-only side-channel that simulates the Stripe webhook flipping the
* team to subscribed. Used by {@code UpgradeModal} when the backend is
* running the mock checkout the real flow waits for the webhook
* instead and the next {@code refetch} picks up the change. Resolves
* once the post-mutation refetch completes.
*/
markSubscribed: (capUsd: number | null) => Promise<void>;
/**
* Update the team's monthly cap. {@code null} means "no cap". Resolves
* once the post-mutation refetch completes so a save-button
* {@code loading} state can be safely cleared on resolution.
*/
updateCap: (capUsd: number | null) => Promise<void>;
/**
* Mint a Stripe Customer Portal session and navigate to it. Calls
* the {@code create-customer-portal-session} Supabase edge function
* directly with the user's JWT (same pattern as checkout no backend
* proxy; the function's RPC enforces team membership) and assigns
* {@code window.location} to the returned URL same-tab redirect.
* We do not use {@code window.open(...,"_blank")} after an {@code await}
* because browsers treat it as non-user-gesture and silently popup-block
* it; the portal is a full-page experience anyway and Stripe redirects
* back to {@code return_url} on close. Throws on error so the caller can
* show a friendly toast notably 404 {@code team_not_subscribed}.
*/
openPortal: () => Promise<void>;
}
// ─── Implementation ─────────────────────────────────────────────────────
const STORAGE_KEY = "stirling.payg.devSubscription";
/**
* Stable reference reuse if the new payload deep-equals the previous one,
* return the previous object so React's reference check short-circuits child
* renders. Walks the top-level scalars first (cheapest), then the nested
* {@code categoryBreakdown} object, then the {@code members} array. The
* {@code recent} array is identity-compared only Wave 1 always returns
* {@code []} so a reference-stability check is sufficient; we'll deepen
* this once the activity surface lands.
*/
function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
if (!prev) return next;
if (
prev.status !== next.status ||
prev.role !== next.role ||
prev.billingPeriodStart !== next.billingPeriodStart ||
prev.billingPeriodEnd !== next.billingPeriodEnd ||
prev.billableUsed !== next.billableUsed ||
prev.billableLimit !== next.billableLimit ||
prev.freeAllowance !== next.freeAllowance ||
prev.freeRemaining !== next.freeRemaining ||
prev.pricePerDocMinor !== next.pricePerDocMinor ||
prev.currency !== next.currency ||
prev.estimatedBillMinor !== next.estimatedBillMinor ||
prev.capUsd !== next.capUsd ||
prev.noCap !== next.noCap ||
prev.stripeSubscriptionId !== next.stripeSubscriptionId ||
prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod
) {
return next;
}
if (prev.recent.length !== next.recent.length) {
return next;
}
if (
prev.categoryBreakdown.api !== next.categoryBreakdown.api ||
prev.categoryBreakdown.ai !== next.categoryBreakdown.ai ||
prev.categoryBreakdown.automation !== next.categoryBreakdown.automation
) {
return next;
}
if (prev.members.length !== next.members.length) {
return next;
}
for (let i = 0; i < prev.members.length; i++) {
const a = prev.members[i];
const b = next.members[i];
if (
a.userId !== b.userId ||
a.name !== b.name ||
a.email !== b.email ||
a.spendUnits !== b.spendUnits
) {
return next;
}
}
// recent length-mismatch already returned `next` above; content (Wave 1 = []) is identical
// otherwise, so reuse the prior reference for stable child memoisation.
return prev;
}
/**
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
* shape the backend returns. Subscription state comes from localStorage so
* the modal's "mark subscribed" action survives a reload.
*/
function buildDevPreviewWallet(role: WalletRole): Wallet {
const subscribed =
typeof window !== "undefined" &&
(() => {
try {
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
} catch {
return false;
}
})();
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
return {
teamId: null,
status: subscribed ? "subscribed" : "free",
role,
billingPeriodStart: isoDay(periodStart),
billingPeriodEnd: isoDay(periodEnd),
billableUsed: 62,
billableLimit: subscribed ? 1250 : 500,
freeAllowance: 500,
// One-time grant: a free team has used 62 of 500 (438 left); the dev
// subscribed team is shown with its grant fully spent (kept across the
// subscribe — it just no longer gates them).
freeRemaining: subscribed ? 0 : 438,
// Free teams also carry a rate now — the backend resolves it from the
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
// PDFs/month") can render before subscribing. Mirror that here.
pricePerDocMinor: 2,
currency: "usd",
estimatedBillMinor: subscribed ? 0 : null,
capUsd: subscribed ? 25 : null,
noCap: false,
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
spendUnitsThisPeriod: 62,
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
// hero panel can split AI / automation / API. Use realistic but
// tier-distinguishable mock values so the dev preview shows a
// different visual when the localStorage flip toggles subscribed.
categoryBreakdown: subscribed
? { api: 12, ai: 35, automation: 15 }
: { api: 5, ai: 40, automation: 17 },
// Members are populated in the leader view by the real backend
// (joining team_memberships); the dev preview returns an empty
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
// so empty members just hides the sub-caps card.
members: [],
// Activity feed is V1 = [], the backend ships this in Wave 2 once
// payg_meter_event_log is read-accessible from the wallet endpoint.
recent: [],
};
}
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
function isDevPreviewContext(): boolean {
// Both checks required: production builds drop the path check, so a real
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
// fallback.
if (!import.meta.env.DEV) return false;
if (typeof window === "undefined") return false;
return window.location.pathname.startsWith("/dev/");
}
/** Best-effort role read for dev preview — flips per query string ?role=member. */
function devPreviewRole(): WalletRole {
if (typeof window === "undefined") return "leader";
const url = new URL(window.location.href);
return url.searchParams.get("role") === "member" ? "member" : "leader";
}
export function useWallet(): UseWalletResult {
const devPreview = useRef<boolean>(isDevPreviewContext()).current;
const [wallet, setWallet] = useState<Wallet | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [refetchTick, setRefetchTick] = useState(0);
// Monotonic request id — used to discard stale responses if a faster
// refetch lands first. Only the latest issued id is permitted to commit
// its result.
const latestReqId = useRef(0);
// Promise tracking the most recent in-flight load. Mutations await this
// so their resolution semantics are "the new state is visible," not
// "the request fired." Cleared when no load is pending.
const inFlight = useRef<Promise<void> | null>(null);
useEffect(() => {
const reqId = ++latestReqId.current;
let cancelled = false;
const promise = (async () => {
setLoading(true);
setError(null);
if (devPreview) {
const synth = buildDevPreviewWallet(devPreviewRole());
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, synth));
setLoading(false);
return;
}
try {
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
if (cancelled || reqId !== latestReqId.current) return;
setWallet((prev) => reuseIfEqual(prev, res.data));
} catch (e: unknown) {
if (cancelled || reqId !== latestReqId.current) return;
console.warn("[useWallet] fetch failed", e);
setError(e instanceof Error ? e.message : "Failed to load wallet");
} finally {
if (!cancelled && reqId === latestReqId.current) {
setLoading(false);
}
}
})();
inFlight.current = promise;
return () => {
cancelled = true;
// Don't clear inFlight here — let it resolve so mutations awaiting it
// still see a definitive "load completed" point. The reqId guard
// upstream ensures stale results don't commit.
};
}, [devPreview, refetchTick]);
const refetch = useCallback(async () => {
setRefetchTick((t) => t + 1);
// Snapshot the next-tick promise so the caller awaits this refetch
// specifically — the in-flight ref will be updated to it on the next
// effect run, but we can't reference that synchronously, so settle for
// a microtask handoff: await the *current* effect to flush, then await
// the new in-flight promise.
await Promise.resolve();
if (inFlight.current) {
await inFlight.current;
}
}, []);
const markSubscribed = useCallback(
async (capUsd: number | null) => {
if (devPreview) {
try {
window.localStorage.setItem(STORAGE_KEY, "subscribed");
} catch {
/* storage unavailable */
}
await refetch();
return;
}
const noCap = capUsd === null;
// The dev side-channel only exists when the BE mock service is
// running (FE-branch local dev). Once the real backend (PR #6574)
// is in play, /dev/mark-subscribed is removed and the webhook
// (customer.subscription.created) is what flips the team to
// subscribed. We swallow 404s so the modal's completion path —
// which awaits this promise before rendering the confirmation
// screen — doesn't error out on a perfectly normal "the real
// backend doesn't expose this dev hook" response. A subsequent
// refetch picks up the webhook-driven flip whenever it lands.
try {
await apiClient.post("/api/v1/payg/dev/mark-subscribed", {
capUsd: capUsd ?? 0,
noCap,
});
} catch (e: unknown) {
const status =
typeof e === "object" && e !== null && "response" in e
? (e as { response?: { status?: number } }).response?.status
: undefined;
if (status === 404) {
// Real BE in play — webhook will land the subscription
// state; log and continue. Loud-but-harmless so the dev
// notices their /dev/mark-subscribed isn't wired up.
console.info(
"[useWallet] /dev/mark-subscribed not available (404) — relying on Stripe webhook to flip subscription state",
);
} else {
throw e;
}
}
await refetch();
},
[devPreview, refetch],
);
const updateCap = useCallback(
async (capUsd: number | null) => {
const noCap = capUsd === null;
if (devPreview) {
await refetch();
return;
}
await apiClient.patch("/api/v1/payg/cap", {
capUsd: capUsd ?? 0,
noCap,
});
await refetch();
},
[devPreview, refetch],
);
const openPortal = useCallback(async () => {
if (devPreview) {
// No real Stripe in dev preview — navigate to a placeholder so the
// click still feels alive. Same-tab to match the real-flow behaviour.
window.location.assign("https://billing.stripe.com/p/login/mock");
return;
}
// Direct edge-fn invocation with the user's JWT — same pattern as
// create-checkout-session. The payg_get_checkout_context RPC inside the
// fn enforces team membership, so no backend proxy is needed; team_id
// must be a NUMBER (the fn type-checks and rejects strings).
const teamId = wallet?.teamId;
if (teamId == null) {
throw new Error("No team resolved yet");
}
const { data, error: invokeError } = await supabase.functions.invoke<{
url?: string;
error?: string;
}>("create-customer-portal-session", {
body: { team_id: teamId, return_url: window.location.href },
});
if (invokeError) {
// FunctionsHttpError etc. — the StripePortalLink caller catches and
// shows a friendly toast (404 team_not_subscribed, 403, outage).
throw invokeError;
}
if (!data?.url) {
throw new Error(data?.error ?? "Portal session response missing url");
}
// Same-tab navigation rather than window.open(...,"_blank"): Stripe's
// customer portal is a full-page experience and brings the user back
// via return_url, so a popup buys us nothing — and window.open after
// an awaited promise is treated as non-user-gesture and silently
// popup-blocked by most browsers.
window.location.assign(data.url);
}, [devPreview, wallet?.teamId]);
return {
wallet,
loading,
error,
refetch,
markSubscribed,
updateCap,
openPortal,
};
}
+30 -6
View File
@@ -3,6 +3,10 @@ import { supabase } from "@app/auth/supabase";
import { handleHttpError } from "@app/services/httpErrorHandler";
import { alert } from "@app/components/toast";
import { openPlanSettings } from "@app/utils/appSettings";
import {
classifyPaygError,
handlePaygError,
} from "@app/services/paygErrorInterceptor";
import { withBasePath } from "@app/constants/app";
// Global credit update callback - will be set by the AuthProvider
@@ -159,12 +163,6 @@ apiClient.interceptors.response.use(
);
}
}
if (response.config?.url?.includes("/api/v1/credits")) {
console.debug(
"[API Client] Credits endpoint response headers:",
response.headers,
);
}
return response;
},
async (error) => {
@@ -174,6 +172,32 @@ apiClient.interceptors.response.use(
originalRequest.url?.includes(endpoint),
);
// PAYG entitlement errors come from the EntitlementGuard on the server
// and have specific sentinels in the response body that we want to
// recognise *before* the generic 401/401-refresh logic kicks in:
//
// 402 FEATURE_DEGRADED — free-tier monthly cap exhausted; show a
// toast nudging the user to the Plan tab to upgrade.
// 401 SIGNUP_REQUIRED — anonymous user hit a billable endpoint;
// show a "Sign up to use [category]" modal instead of redirecting
// to /login (which is the default 401 behaviour). The user IS
// authenticated as anonymous — refreshing their session wouldn't
// unlock the endpoint, only signing up will.
//
// We classify the error here. If it matches either sentinel, we
// surface the appropriate UI and short-circuit the rest of the
// response interceptor so:
// - 401 SIGNUP_REQUIRED won't trigger the session-refresh / redirect-
// to-login dance below.
// - The handleHttpError() generic toast at the bottom won't fire.
// The error itself is still propagated to the caller so any
// component-level catch can react if needed.
const paygKind = classifyPaygError(error);
if (paygKind !== null) {
handlePaygError(paygKind, error);
return Promise.reject(error);
}
// On a first 401, refresh and retry — public endpoints included, since an
// expired Bearer token is rejected on any route during cold load.
if (status === 401 && !originalRequest._retry) {
@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock the toast layer and openPlanSettings so we can assert what the
// handler dispatches without needing a real DOM context for the toast
// portal. Mocks are hoisted by vitest so the module under test imports
// these in place of the real implementations.
vi.mock("@app/components/toast", () => ({
alert: vi.fn(),
}));
vi.mock("@app/utils/appSettings", () => ({
openPlanSettings: vi.fn(),
}));
import { alert } from "@app/components/toast";
import { openPlanSettings } from "@app/utils/appSettings";
import {
classifyPaygError,
extractSignupCategory,
handlePaygError,
} from "@app/services/paygErrorInterceptor";
describe("classifyPaygError", () => {
it("returns FEATURE_DEGRADED for 402 + error sentinel", () => {
const err = {
response: {
status: 402,
data: {
error: "FEATURE_DEGRADED",
missingGates: ["AUTOMATION"],
state: "DEGRADED",
periodEnd: "2026-06-30",
capUnits: 500,
spendUnits: 500,
},
},
};
expect(classifyPaygError(err)).toBe("FEATURE_DEGRADED");
});
it("returns SIGNUP_REQUIRED for 401 + error sentinel", () => {
const err = {
response: {
status: 401,
data: { error: "SIGNUP_REQUIRED", category: "AI" },
},
};
expect(classifyPaygError(err)).toBe("SIGNUP_REQUIRED");
});
it("returns null for plain 401 (session expired path is untouched)", () => {
const err = {
response: {
status: 401,
data: { error: "Unauthorized" },
},
};
expect(classifyPaygError(err)).toBeNull();
});
it("returns null for 402 without the FEATURE_DEGRADED sentinel", () => {
const err = {
response: { status: 402, data: { error: "Payment required" } },
};
expect(classifyPaygError(err)).toBeNull();
});
it("returns null when status mismatches sentinel (defence-in-depth)", () => {
// 500 + FEATURE_DEGRADED shouldn't match — server may have a bug and
// we don't want a generic 500 to silently degrade into an upgrade
// prompt. Same for 403 + SIGNUP_REQUIRED.
const a = {
response: { status: 500, data: { error: "FEATURE_DEGRADED" } },
};
const b = {
response: { status: 403, data: { error: "SIGNUP_REQUIRED" } },
};
expect(classifyPaygError(a)).toBeNull();
expect(classifyPaygError(b)).toBeNull();
});
it("returns null for malformed errors (null / non-object / no response)", () => {
expect(classifyPaygError(null)).toBeNull();
expect(classifyPaygError(undefined)).toBeNull();
expect(classifyPaygError("oops")).toBeNull();
expect(classifyPaygError({})).toBeNull();
expect(classifyPaygError({ response: null })).toBeNull();
expect(classifyPaygError({ response: { status: 402 } })).toBeNull();
expect(
classifyPaygError({ response: { status: 402, data: null } }),
).toBeNull();
expect(
classifyPaygError({ response: { status: 402, data: "bare-string" } }),
).toBeNull();
expect(
classifyPaygError({ response: { status: 402, data: { error: 123 } } }),
).toBeNull();
});
});
describe("extractSignupCategory", () => {
it("returns the category string when present", () => {
expect(
extractSignupCategory({
response: { data: { error: "SIGNUP_REQUIRED", category: "AI" } },
}),
).toBe("AI");
});
it("returns null when missing or wrong type", () => {
expect(extractSignupCategory(null)).toBeNull();
expect(extractSignupCategory({})).toBeNull();
expect(
extractSignupCategory({ response: { data: { category: 7 } } }),
).toBeNull();
expect(
extractSignupCategory({ response: { data: {} } }),
).toBeNull();
});
});
describe("handlePaygError", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows the persistent upgrade toast on FEATURE_DEGRADED", () => {
handlePaygError("FEATURE_DEGRADED", {
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
});
expect(alert).toHaveBeenCalledTimes(1);
const opts = vi.mocked(alert).mock.calls[0][0];
expect(opts.alertType).toBe("warning");
expect(opts.isPersistentPopup).toBe(true);
expect(opts.buttonText).toBe("Go to billing");
// Body should reference the 500-op free monthly allowance so the
// user understands what they hit.
expect(String(opts.body)).toMatch(/500/);
});
it("invoking the toast's buttonCallback opens the Plan settings tab", () => {
handlePaygError("FEATURE_DEGRADED", {
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
});
const opts = vi.mocked(alert).mock.calls[0][0];
expect(opts.buttonCallback).toBeDefined();
opts.buttonCallback?.();
expect(openPlanSettings).toHaveBeenCalledTimes(1);
});
it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => {
const handler = vi.fn();
window.addEventListener("payg:signupRequired", handler);
try {
handlePaygError("SIGNUP_REQUIRED", {
response: {
status: 401,
data: { error: "SIGNUP_REQUIRED", category: "AUTOMATION" },
},
});
expect(handler).toHaveBeenCalledTimes(1);
const ev = handler.mock.calls[0][0] as CustomEvent;
expect(ev.detail).toEqual({ category: "AUTOMATION" });
// No toast for SIGNUP_REQUIRED — the modal carries the message.
expect(alert).not.toHaveBeenCalled();
} finally {
window.removeEventListener("payg:signupRequired", handler);
}
});
it("dispatches with category=null when the body has no category", () => {
const handler = vi.fn();
window.addEventListener("payg:signupRequired", handler);
try {
handlePaygError("SIGNUP_REQUIRED", {
response: { status: 401, data: { error: "SIGNUP_REQUIRED" } },
});
expect(handler).toHaveBeenCalledTimes(1);
const ev = handler.mock.calls[0][0] as CustomEvent;
expect(ev.detail).toEqual({ category: null });
} finally {
window.removeEventListener("payg:signupRequired", handler);
}
});
});
@@ -0,0 +1,131 @@
/**
* Classifies and reacts to PAYG-specific error responses surfaced by the
* backend's {@code EntitlementGuard} (Wave 1 BE on PR #6574). Two sentinels
* are recognised:
*
* <ul>
* <li>{@code 402 FEATURE_DEGRADED} free-tier user has burned through
* their 500-op monthly allowance. Surface a toast that nudges them to
* the Plan tab so they can upgrade.</li>
* <li>{@code 401 SIGNUP_REQUIRED} anonymous (guest) user hit a billable
* endpoint. Open a modal explaining why they need a real account and
* where their 500-op free monthly allowance comes in. The body's
* {@code category} field ({@code AI}, {@code AUTOMATION}, {@code API})
* feeds the modal title so the user understands *which* feature they
* just hit. We dispatch a {@code CustomEvent} rather than rendering
* directly from this module because the apiClient is created outside
* the React tree and can't import JSX; the listener lives on a
* bootstrap component mounted near the app root.</li>
* </ul>
*
* The classifier is exported separately from the handler so unit tests can
* exercise the parsing logic without touching the toast / event side
* effects.
*/
import { alert } from "@app/components/toast";
import i18n from "@app/i18n";
import { openPlanSettings } from "@app/utils/appSettings";
/**
* Possible PAYG entitlement sentinels the EntitlementGuard returns.
* {@code null} when the error is not a PAYG entitlement response.
*/
export type PaygErrorKind = "FEATURE_DEGRADED" | "SIGNUP_REQUIRED";
/**
* Detail payload broadcast on {@code payg:signupRequired} when an anonymous
* user hits a billable endpoint. The listener (a Bootstrap component near
* the app root) opens a modal whose copy is parameterised by
* {@link #category}.
*/
export interface PaygSignupRequiredDetail {
/** Category that triggered the gate — {@code AI}, {@code AUTOMATION}, or {@code API}. */
category: string | null;
}
/**
* Inspect an axios-style error and decide whether it's one of the known
* PAYG sentinels. Returns the kind, or {@code null} if it isn't.
*
* The check is intentionally strict (status code AND body.error sentinel)
* so we don't hijack incidental 401/402 responses from other endpoints
* notably the existing session-expired 401 flow.
*/
export function classifyPaygError(error: unknown): PaygErrorKind | null {
if (!error || typeof error !== "object") return null;
const response = (error as { response?: unknown }).response;
if (!response || typeof response !== "object") return null;
const status = (response as { status?: unknown }).status;
const data = (response as { data?: unknown }).data;
if (typeof status !== "number") return null;
if (!data || typeof data !== "object") return null;
const sentinel = (data as { error?: unknown }).error;
if (typeof sentinel !== "string") return null;
if (status === 402 && sentinel === "FEATURE_DEGRADED") {
return "FEATURE_DEGRADED";
}
if (status === 401 && sentinel === "SIGNUP_REQUIRED") {
return "SIGNUP_REQUIRED";
}
return null;
}
/** Extract {@code data.category} (a string) from an axios error, or {@code null}. */
export function extractSignupCategory(error: unknown): string | null {
if (!error || typeof error !== "object") return null;
const response = (error as { response?: unknown }).response;
if (!response || typeof response !== "object") return null;
const data = (response as { data?: unknown }).data;
if (!data || typeof data !== "object") return null;
const category = (data as { category?: unknown }).category;
return typeof category === "string" ? category : null;
}
/**
* Surface the appropriate UI for a classified PAYG error. Toast for
* {@code FEATURE_DEGRADED}, modal-via-CustomEvent for {@code SIGNUP_REQUIRED}.
*
* Idempotent / safe to call multiple times the toast layer coalesces
* duplicates by (alertType, title, body) and the modal listener already
* dedupes by its own opened-state. Suppress-respecting: if the caller
* passed {@code suppressErrorToast: true} on the axios config (the
* established pattern for component-level error handling), we still fire
* the PAYG UI because these are user-facing gates, not transient
* error toasts the suppression flag was for the *generic* error toast,
* which we're replacing with something more actionable.
*/
export function handlePaygError(kind: PaygErrorKind, error: unknown): void {
if (kind === "FEATURE_DEGRADED") {
alert({
alertType: "warning",
title: i18n.t(
"payg.exhausted.title",
"You've hit your free monthly limit",
),
body: i18n.t(
"payg.exhausted.body",
"You've used your free 500 operations this month. Upgrade to Processor to keep going.",
),
buttonText: i18n.t("payg.exhausted.cta", "Go to billing"),
buttonCallback: () => openPlanSettings(),
isPersistentPopup: true,
location: "bottom-right",
});
return;
}
if (kind === "SIGNUP_REQUIRED") {
const category = extractSignupCategory(error);
try {
window.dispatchEvent(
new CustomEvent<PaygSignupRequiredDetail>("payg:signupRequired", {
detail: { category },
}),
);
} catch {
// SSR / test environments without a real window — no-op.
}
return;
}
}