Compare commits

...
Author SHA1 Message Date
Connor Yoh 06c51f52fc payg(c9): address review — drop @Transactional around HTTP, validate returnUrl, swap FE to Java endpoint
- PaygWalletController.createPortalSession no longer holds a JPA connection during the
  outbound RestTemplate.exchange. DB work (membership + payg_team_extensions) moves into
  a small loadPortalContext helper; the HTTP call runs after the helper returns. JOIN
  FETCH on findPrimaryMembership means no lazy access happens outside the implicit per-
  repo tx, so a slow Supabase edge fn no longer pins HikariCP for 30s.
- Add payg.portal.allowed-return-hosts allowlist (CSV). Caller-supplied return_url is
  parsed (URI) and its host is matched case-insensitively against the allowlist before
  forwarding to the edge fn; scheme is restricted to http/https so javascript:/data:
  URLs can't slip past. Empty allowlist rejects any caller-supplied return_url with
  400 INVALID_RETURN_URL — operator must opt-in explicitly. Defense in depth: doesn't
  rely on the edge fn to enforce its own allowlist.
- @RequestBody is now required = false so the FE may POST with no body and let the edge
  fn fall back to its configured default. Drops the dead req != null guard.
- ManageBillingButton.tsx now POSTs /api/v1/payg/portal-session via apiClient instead of
  calling supabase.functions.invoke('manage-billing') directly, so the new endpoint is
  actually on the live path. Maps the controller error codes (TEAM_NOT_SUBSCRIBED,
  INVALID_RETURN_URL, PORTAL_NOT_CONFIGURED, PORTAL_UNAVAILABLE) to user-facing copy.
- Tests: add INVALID_RETURN_URL coverage (off-allowlist + javascript: scheme), empty
  allowlist with null returnUrl bypass, and null-body acceptance. All 16 PaygWalletController
  tests pass; coverage targets met.
2026-06-09 15:39:48 +01:00
Connor Yoh db11a32acb payg(c9): POST /portal-session — proxy to Supabase create-customer-portal-session
Adds the third PAYG wallet endpoint that mints a Stripe-hosted billing-portal
session via the Supabase create-customer-portal-session edge fn. The FE "Manage
billing" button calls this and redirects to the returned Stripe URL.

- PaygWalletController gains POST /api/v1/payg/portal-session
- Status map: 200 + {url}, 401 anonymous, 403 no-team, 404 TEAM_NOT_SUBSCRIBED,
  502 PORTAL_UNAVAILABLE (edge fn 5xx / success=false / unreachable), 503
  PORTAL_NOT_CONFIGURED (blank endpoint config — local dev / unit tests).
- Reuses the saasRestTemplate + Bearer service-role token pattern from
  PaygMeterReportingService (C10) so portal config stays alongside the rest of
  the Supabase-fronted billing pipeline.
- isSubscribed() helper now shared between GET /wallet and POST /portal-session
  so "team has Stripe customer" is sourced from one place.
- application-saas.properties: payg.portal.endpoint + service-role-token,
  both default empty so local dev returns 503 gracefully.
- 6 new tests in PaygWalletControllerTest covering happy path (verifies
  Bearer header + team_id + return_url forwarded), team-without-customer 404,
  edge fn success=false → 502, RestTemplate throws → 502, blank endpoint
  → 503, and anonymous → 401.
2026-06-09 15:26:39 +01:00
Connor Yoh 2d19d83ce4 payg(c8): PATCH /sub-caps/{userId} — leader sets per-member spend cap
Adds the sub-cap mutation endpoint on PaygWalletController. Leaders set
team_memberships.cap_units for a member of their team; values above the
team's wallet_policy.cap_units are silently clamped (returning
clamped=true) rather than rejected, so the leader's intent ("don't let
this member spend more than X") is honoured by the effective ceiling.
A null capUnits clears the sub-cap entirely.

Authorisation: caller must be LEADER (else 403); target userId must be
a member of the same team (else 404). No team-id on the path — derived
from the caller — to keep cross-team probing impossible.

Member capUnits is already exposed in GET /wallet's members[] field
(WalletSnapshotResponse.MemberRow.capUnits from C6); no projection
change needed.

Tests: 8 new MockitoExtension cases covering below-cap, above-cap clamp,
null-clear, no-team-cap-no-clamp, member-403, different-team-404,
no-team-403, anonymous-401. All 19 PaygWalletControllerTest cases pass.
2026-06-09 15:07:15 +01:00
Connor Yoh 102994ea64 payg(c4): fix annotation gate — EntitlementGuard + PaygChargeInterceptor were short-circuiting before reading @RequiresFeature
C4 review surfaced two interlocking bugs: both interceptors gated on
`@AutoJobPostMapping` BEFORE consulting `@RequiresFeature`, so the AI
controllers' class-level `@RequiresFeature(AI_SUPPORT)` had no effect
(AI controllers carry no `@AutoJobPostMapping` — they are JSON-bodied
proxies, not multipart tool POSTs).

EntitlementGuard (major #2 — functional entitlement gap):
- A team without AI entitlement could hit `/api/v1/ai/*` freely because
  the guard's scope check returned early on routes lacking
  `@AutoJobPostMapping`.
- Fix: in-scope == (`@AutoJobPostMapping` present) OR (`@RequiresFeature`
  present, method- or class-level). Routes with neither still skip
  (admin / info / config endpoints).

PaygChargeInterceptor (major #1 — billing classification gap):
- Same short-circuit, same root cause. `determineCategory` was never
  reached for AI controllers, so the AI billing category would never be
  set even if those routes did carry multipart payloads.
- Fix: same gate widening. Routes without multipart inputs still
  short-circuit inside `doPreHandle` without touching the charge service
  — the multipart materialisation logic only runs when actual file
  parts are present.

Tests:
- `EntitlementGuardTest`: two new tests covering (a) method-level
  `@RequiresFeature` without `@AutoJobPostMapping` returns 402 when
  entitlement is missing, and (b) class-level `@RequiresFeature`
  (AiCreateController shape) returns 401 for anonymous users.
- `PaygChargeInterceptorTest`: three new tests — `@RequiresFeature`-only
  endpoint with no multipart body short-circuits without counting as
  BYPASSED; same shape with multipart body lands in `BillingCategory.AI`;
  class-level `@RequiresFeature` is resolved via beanType lookup.

Build + verification:
- `STIRLING_FLAVOR=saas :saas:compileJava :saas:compileTestJava` — clean.
- `:saas:test --tests stirling.software.saas.payg.*` — PASS, JaCoCo
  thresholds met (line 25.78%, instruction 27.14%, branch 24.40%).
2026-06-09 14:58:38 +01:00
Connor Yoh 6eb41b6931 payg(c4): annotate AI controllers + tag InternalApiClient sub-steps as AUTOMATION
The saas PaygChargeInterceptor classifies a request as AI / AUTOMATION /
API / BYPASSED using a fixed precedence: X-Stirling-Automation header >
@RequiresFeature(AUTOMATION) > @RequiresFeature(AI_SUPPORT) > API-key
auth > BYPASSED (manual UI).

Plug the AI surface and the automation sub-step path:

AI controllers (saas → easy import):
- AiCreateController, AiCreateInternalController, AiProxyController each
  get class-level @RequiresFeature(AI_SUPPORT). The interceptor already
  falls back to the bean type via AnnotationUtils.findAnnotation when no
  method-level annotation is present.

Automation sub-step header (InternalApiClient in common):
- Every caller of InternalApiClient.post() is a parent automation flow
  (PipelineProcessor, AiWorkflowService, PolicyExecutor) running a child
  tool via loopback HTTP. Tag every dispatch with
  X-Stirling-Automation: true so the child step bills as AUTOMATION
  regardless of its own annotation — i.e. an AI-OCR step inside a
  policy run is correctly billed AUTOMATION rather than AI.

Deviation (documented in test javadoc): PipelineController lives in
core and PolicyController lives in proprietary. Neither module can
import @RequiresFeature from saas without a forbidden upward dependency
(both build under STIRLING_FLAVOR=core/proprietary where saas is
absent). Promoting RequiresFeature + FeatureGate to common is a
non-trivial refactor (touches 18 files across multiple C-bucket
commits) and out of scope for C4. The X-Stirling-Automation header
covers sub-step dispatch via InternalApiClient; direct user-facing
calls to /api/v1/pipeline/handleData therefore bill as BYPASSED (WEB)
or API (api-key auth) rather than AUTOMATION. Annotating those
controllers is left to a future refactor that promotes the cap types
to common.

Tests:
- RequiresFeatureAnnotationRolloutTest pins the AI controller
  classifications so a refactor can't silently regress to BYPASSED.
- InternalApiClientTest.postTagsRequestAsAutomation captures the
  HttpHeaders submitted to RestTemplate and asserts the marker.
2026-06-09 14:48:36 +01:00
Connor Yoh e80e1b2190 payg(c10): PaygMeterReportingService + JobChargeService.close() afterCommit hook
Push billable usage to Stripe via Supabase meter-payg-units edge function after the
ledger DEBIT durably commits. Closes the "we tracked it" → "Stripe knows" loop.

PaygMeterReportingService POSTs {team_id, stripe_customer_id, units, idempotency_key,
metadata.category} to a config-driven endpoint. Both payg.meter.endpoint and
payg.meter.service-role-token default empty → no-op in local dev / tests. Never
throws; non-2xx and exceptions bump payg.meter.errors counter and log WARN.

JobChargeService.close() delegates to JobService.close() and registers an
afterCommit synchronization that fires the meter event for paid teams. Skips
when: shadow row is REFUNDED, category is BYPASSED, team has no stripeCustomerId
(free tier — ledger entry suffices), or no synchronization is active (defensive).

Idempotency key: "process:<jobId>:close" — deterministic across pod retries and
the reconciliation backfill so Stripe never bills twice for one close.

Tests:
- PaygMeterReportingServiceTest: happy path body+headers, 5xx, ConnectException,
  RuntimeException, blank/null endpoint, zero units, blank service-role-token.
- JobChargeServiceTest extended: subscribed posts; free-tier skips; refunded skips;
  bypassed skips; no shadow row skips; no team-ext row skips; meter exception is
  swallowed inside afterCommit; close() without an active transaction skips the
  meter post but still closes the job.

Free-tier detection uses stripeCustomerId == null as the established pre-#6532
stand-in (see PaygWalletController for the same pattern). Tightens to
paygSubscriptionId != null when PR #6532's payg_team_extensions column lands.
2026-06-09 14:27:34 +01:00
Connor Yoh 9f5153f272 payg(c6): real PaygWalletController + WalletCategorySummaryDao + tests
GET /api/v1/payg/wallet returns the snapshot the FE useWallet hook
consumes — derived from EntitlementService (spend / cap / period),
PaygTeamExtensions (subscription state), and the wallet_category_summary
view (per-category breakdown). Leader callers get the team roster +
per-member sub-caps; member callers see an empty roster.

PATCH /api/v1/payg/cap updates wallet_policy.cap_units (no Stripe call —
cap is application-layer, enforced via the entitlement guard) and
invalidates the team's snapshot cache so the next read reflects the
change immediately. Leader-only — authorised inside the method since the
team is derived from the caller, not a path variable.

Adds findPrimaryMembership(userId) to TeamMembershipRepository so the
wallet endpoint can resolve the caller's team + role in a single query.

Tests: 11-case pure-Mockito slice covering free vs subscribed, leader vs
member, no-cap, anonymous → 401, teamless → empty free snapshot, plus
the cap update endpoint's leader-only enforcement and cache invalidation.

Known dependency on PR #6532: stripeSubscriptionId stays null until the
payg_subscription_id column lands; "subscribed" derived from
stripe_customer_id presence as a stand-in. billableLimit fallback of 500
units mirrors EntitlementService.DEFAULT_FREE_TIER_UNITS until
pricing_policy.free_tier_units_per_cycle ships.
2026-06-09 14:14:32 +01:00
Connor Yoh 0e03dd1732 payg(c5): EntitlementService + EntitlementGuard, MINIMAL keeps OFFSITE_PROCESSING
Build the missing pieces from PR-C1's original plan:

- EntitlementService: Caffeine-cached (30s TTL, 10k entries) per-team snapshot of
  spend vs cap, with invalidate(teamId) for explicit eviction after subscription
  changes / cap edits. Reads from wallet_policy when present, falls back to a
  500-unit free-tier default until PR #6532 lands payg_subscription_id +
  pricing_policy.free_tier_units_per_cycle.

- EntitlementGuard: HandlerInterceptor on @AutoJobPostMapping routes only.
  Anonymous + billable gate (AUTOMATION / AI_SUPPORT) -> 401 SIGNUP_REQUIRED.
  Authenticated + missing gate -> 402 FEATURE_DEGRADED with state, cap, spend,
  periodEnd, missingGates. Fails open on any internal error.

- MINIMAL semantics fix: CapEvaluator.gatesFor(MINIMAL) now returns
  {OFFSITE_PROCESSING, CLIENT_SIDE}. DEGRADED state still blocks AUTOMATION +
  AI_SUPPORT, but manual server-side tools stay available -- which is the
  v2.1 PAYG framing (manual tools are free).

- RequiresFeature: extend @Target to TYPE so class-level annotation is legal
  (the charge interceptor was already checking bean type).

- Register guard in PaygWebMvcConfig at order 1100, after the charge
  interceptor (1000). 402 short-circuits handler but charge interceptor's
  afterCompletion still cleans up temp files.

Tests: 50 new test cases across EntitlementServiceTest (12), EntitlementGuardTest
(13), updated CapEvaluatorTest (25, MINIMAL assertions). All 17 payg test suites
green.
2026-06-09 13:18:12 +01:00
Connor Yoh 9655360e69 payg(c3): wire BillingCategory through interceptor + bypass fast-path
Categorise every billable tool call before the charge pipeline runs. Manual
UI tools (BYPASSED) now short-circuit ahead of multipart materialisation and
openProcess — no temp files, no shadow row, no DB writes. API-key, AI, and
automation calls thread their category through ChargeContext onto the shadow
row alongside the originating JobSource so analytics survive job pruning.

Precedence (interceptor): X-Stirling-Automation header or
@RequiresFeature(AUTOMATION) -> AUTOMATION; @RequiresFeature(AI_SUPPORT) -> AI;
ApiKeyAuthenticationToken -> API; else BYPASSED.

- PaygChargeInterceptor: determineCategory() resolves category from header +
  @RequiresFeature + auth principal. Bypass fast-path runs before doPreHandle.
  New Micrometer counter payg.filter.bypassed counts skipped manual calls.
- ChargeContext: now carries BillingCategory as a non-null record component;
  any context that reaches openProcess is API / AI / AUTOMATION.
- JobChargeService.recordShadowRow: copies billingCategory + jobSource onto
  the shadow row so post-prune analytics joins stand alone.
- Tests: extended PaygChargeInterceptorTest with bypass + category resolution
  (header / annotation / API-key / AI-with-header precedence); existing
  billable-path tests switched from JWT to API-key auth since plain JWT now
  short-circuits as BYPASSED. JobChargeServiceTest verifies the shadow row
  carries billingCategory + jobSource end-to-end.

Stripe stays on a single flat-priced meter — category is metadata only.
2026-06-09 13:03:45 +01:00
Connor Yoh c4d579ad4c payg(c1): V16 billing_category column + BillingCategory enum + entity wiring
V16 adds wallet_ledger.billing_category, payg_shadow_charge.billing_category +
job_source, and pricing_policy_stripe_price.stripe_product_id (operator
populates manually). All adds are nullable so pre-V16 rows stay NULL — the
interceptor stamps the category for new rows.

PAYG stays on a single flat-priced Stripe meter forever; this axis is purely
for in-app breakdowns and analytics. The wallet_category_summary view
pre-groups debits by team / month / category for the dashboard widget.

BillingCategory enum lists BYPASSED first as the default sentinel; no
downstream relies on ordinal(). JPA mapping uses STRING so renames stay
backward-compatible.
2026-06-09 12:53:13 +01:00
Connor Yoh 09860bffef PAYG: CapEvaluator + @RequiresFeature annotation (Bucket 5 / PR-C1 part 1)
First chunk of Bucket 5 (cap evaluator + entitlement guard + wallet
controller + cap-setting endpoints). Pure compute layer only — no DB, no
caches, no Spring. The entitlement service (next commit) supplies the
inputs from wallet_policy + payg_shadow_charge sums; this just does the
math.

Ships:

- @RequiresFeature method-level annotation declaring required FeatureGate
  values. Per the design doc the annotation is NOT required on every
  @AutoJobPostMapping — the guard's default rule is "no annotation +
  AutoJob → OFFSITE_PROCESSING". Only AUTOMATION (pipeline) and AI_SUPPORT
  (AI proxies) need the explicit annotation.

- CapEvaluator with three static helpers:
  - evaluate(spend, cap, warn%, degrade%, degradedFeatureSet)
    → Evaluation(state, featureSet, enabledGates). Single integer compare
    on the hot path; defensive against null/zero/misconfigured inputs.
  - combineTeamAndMember(teamEval, memberEval) → effective eval,
    stricter state, intersected gates. Handles the per-member sub-cap
    case from §3.6.
  - allEnabled(required, enabled) → predicate the guard calls per request.

- 22 unit tests covering: null/zero cap, warn boundary, degrade boundary,
  over-threshold, misconfigured thresholds, member-team combination,
  state ordering, gate intersection, mapping FeatureSet → gates,
  guard predicate edge cases.

Next commits (same PR):
- EntitlementService — snapshot computation with 30s Caffeine cache
- EntitlementGuard — HandlerInterceptor reading @RequiresFeature
- PaygWalletController — GET /api/v1/payg/wallet (PaygSnapshot DTO matching FE)
- CapAdminController — admin direct cap-setting endpoints

Tracked in notes/PAYG_DESIGN.md §3.6 + §3.7 + §7.6 (PR-C1) and
notes/payg-launch-checklist.html Bucket 5.
2026-06-09 12:47:59 +01:00
35 changed files with 5019 additions and 43 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<>();
@@ -40,6 +40,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;
@@ -49,6 +51,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
@Profile("saas")
@RequestMapping("/api/v1/ai/create")
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateController {
@@ -23,11 +23,14 @@ 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")
@RequestMapping("/api/v1/ai/create/internal")
@RequiredArgsConstructor
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiCreateInternalController {
@@ -25,6 +25,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;
@@ -33,6 +35,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai")
@RequiresFeature(FeatureGate.AI_SUPPORT)
@Slf4j
public class AiProxyController {
@@ -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,714 @@
package stirling.software.saas.payg.api;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
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.HttpStatus;
import org.springframework.http.MediaType;
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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
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.CategoryBreakdown;
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
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.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.WalletCategorySummaryDao;
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.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 the {@code
* wallet_category_summary} view (for the per-category breakdown widget). Leader callers also get a
* roster of team members + their sub-caps; 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><b>Known dependency on PR #6532:</b> {@code stripeSubscriptionId} is sourced from {@code
* payg_team_extensions.payg_subscription_id} once that column ships; on this branch the column
* doesn't exist yet, so the response always returns {@code null} for that field. {@link
* #STATUS_SUBSCRIBED} is determined by the presence of {@code stripeCustomerId} as a stand-in until
* #6532 lands; once it does, swap to checking {@code paygSubscriptionId} instead.
*/
@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";
/**
* Free-tier ceiling shown to un-subscribed teams. Mirrors {@link
* EntitlementService#DEFAULT_FREE_TIER_UNITS} until {@code
* pricing_policy.free_tier_units_per_cycle} lands and the value can be read live.
*/
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 TeamMembershipRepository memberRepo;
private final PaygTeamExtensionsRepository extRepo;
private final WalletPolicyRepository policyRepo;
private final WalletLedgerRepository ledgerRepo;
private final WalletCategorySummaryDao categorySummaryDao;
private final UserRepository userRepository;
private final RestTemplate saasRestTemplate;
private final String portalEndpoint;
private final String portalServiceRoleToken;
private final Set<String> portalReturnUrlAllowedHosts;
public PaygWalletController(
EntitlementService entitlementService,
TeamMembershipRepository memberRepo,
PaygTeamExtensionsRepository extRepo,
WalletPolicyRepository policyRepo,
WalletLedgerRepository ledgerRepo,
WalletCategorySummaryDao categorySummaryDao,
UserRepository userRepository,
RestTemplate saasRestTemplate,
@Value("${payg.portal.endpoint:}") String portalEndpoint,
@Value("${payg.portal.service-role-token:}") String portalServiceRoleToken,
@Value("${payg.portal.allowed-return-hosts:}") String portalReturnUrlAllowedHostsCsv) {
this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService");
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.categorySummaryDao = Objects.requireNonNull(categorySummaryDao, "categorySummaryDao");
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
this.saasRestTemplate = Objects.requireNonNull(saasRestTemplate, "saasRestTemplate");
this.portalEndpoint = portalEndpoint == null ? "" : portalEndpoint;
this.portalServiceRoleToken = portalServiceRoleToken == null ? "" : portalServiceRoleToken;
this.portalReturnUrlAllowedHosts = parseHostAllowlist(portalReturnUrlAllowedHostsCsv);
}
private static Set<String> parseHostAllowlist(String csv) {
if (csv == null || csv.isBlank()) {
return Collections.emptySet();
}
Set<String> out = new HashSet<>();
for (String raw : Arrays.asList(csv.split(","))) {
String trimmed = raw.trim().toLowerCase(Locale.ROOT);
if (!trimmed.isEmpty()) {
out.add(trimmed);
}
}
return Collections.unmodifiableSet(out);
}
// ---------------------------------------------------------------------------------------
// 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;
Optional<PaygTeamExtensions> extOpt = extRepo.findById(teamId);
Optional<WalletPolicy> policyOpt = policyRepo.findByTeamId(teamId);
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
boolean subscribed = isSubscribed(extOpt);
String status = subscribed ? STATUS_SUBSCRIBED : STATUS_FREE;
Long capUnits = policyOpt.map(WalletPolicy::getCapUnits).orElse(null);
boolean noCap = subscribed && capUnits == null;
Integer capUsd =
(subscribed && capUnits != null) ? CapMoneyUnits.unitsToUsd(capUnits) : null;
int spend = clampToInt(snap.periodSpendUnits());
int limit = resolveBillableLimit(subscribed, snap);
LocalDate periodStartDate = snap.periodStart().toLocalDate();
Map<BillingCategory, Long> byCategory =
categorySummaryDao.sumByCategory(teamId, periodStartDate);
CategoryBreakdown breakdown =
new CategoryBreakdown(
clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)),
clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)),
clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L)));
List<MemberRow> members = isLeader ? buildMemberRows(teamId) : List.of();
WalletSnapshotResponse body =
new WalletSnapshotResponse(
status,
isLeader ? ROLE_LEADER : ROLE_MEMBER,
ISO_DATE.format(periodStartDate),
ISO_DATE.format(snap.periodEnd().toLocalDate()),
spend,
limit,
capUsd,
noCap,
// Subscription id sourced from PR #6532's payg_subscription_id column;
// null on this branch until that ships.
null,
spend,
breakdown,
members,
Collections.emptyList());
return ResponseEntity.ok(body);
}
// ---------------------------------------------------------------------------------------
// 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 {
policy.setCapUnits(CapMoneyUnits.usdToUnits(req.capUsd()));
policy.setCapSourceMoney(CapMoneyUnits.usdToCents(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) {}
// ---------------------------------------------------------------------------------------
// PATCH /sub-caps/{userId} — leader sets a per-member sub-cap inside the team wallet
// ---------------------------------------------------------------------------------------
/**
* Updates {@code team_memberships.cap_units} for the given member of the caller's team.
*
* <p>Authorisation:
*
* <ul>
* <li>caller must be authenticated (else 401);
* <li>caller must be a {@code LEADER} of a team (else 403);
* <li>the target {@code userId} must be a member of the caller's team (else 404).
* </ul>
*
* <p>Clamp rule (per design): if the requested sub-cap exceeds the team-wide {@code
* wallet_policy.cap_units}, the value is silently clamped to the team cap and the response
* carries {@code clamped=true}. We do this at save time rather than rejecting so the leader
* doesn't see a confusing 4xx when they typed a number bigger than the team's own cap — the
* intent ("don't let this member spend more than X") is honoured by setting the effective
* maximum, which is the team cap. If the team has no cap (subscribed, no-cap mode), no clamping
* applies. A {@code null} {@code capUnits} clears the sub-cap entirely (member is bounded only
* by the team cap).
*/
@PatchMapping("/sub-caps/{userId}")
@PreAuthorize("isAuthenticated()")
@Transactional
public ResponseEntity<Map<String, Object>> updateSubCap(
@PathVariable Long userId,
@Valid @RequestBody UpdateSubCapRequest req,
Authentication auth) {
User caller;
try {
caller = AuthenticationUtils.getCurrentUser(auth, userRepository);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Optional<TeamMembership> primary = primaryMembership(caller.getId());
if (primary.isEmpty() || primary.get().getRole() != TeamRole.LEADER) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
Long teamId = primary.get().getTeam().getId();
// Target must be a member of the same team. Anything else (different team, unknown user)
// is 404 — leaks no information about other teams.
Optional<TeamMembership> targetOpt = memberRepo.findByTeamIdAndUserId(teamId, userId);
if (targetOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
TeamMembership target = targetOpt.get();
// Team cap: null means unlimited (subscribed no-cap). Anything else is a Long ceiling.
Long teamCap = policyRepo.findByTeamId(teamId).map(WalletPolicy::getCapUnits).orElse(null);
Long effective;
boolean clamped = false;
if (req.capUnits() == null) {
effective = null;
} else {
long requested = req.capUnits().longValue();
if (teamCap != null && requested > teamCap) {
effective = teamCap;
clamped = true;
} else {
effective = requested;
}
}
target.setCapUnits(effective);
memberRepo.save(target);
entitlementService.invalidate(teamId);
Map<String, Object> body = new HashMap<>();
body.put("success", true);
// Effective value after clamp; null means "no sub-cap" (member bounded by team cap).
body.put("capUnits", effective);
body.put("clamped", clamped);
return ResponseEntity.ok(body);
}
/**
* Request body for {@link #updateSubCap}.
*
* @param capUnits per-member sub-cap in doc units; {@code null} clears the sub-cap so the
* member is bounded only by the team cap.
*/
public record UpdateSubCapRequest(@Min(0) Integer capUnits) {}
// ---------------------------------------------------------------------------------------
// POST /portal-session — proxy to Supabase create-customer-portal-session edge function
// ---------------------------------------------------------------------------------------
/**
* Mints a Stripe-hosted billing-portal session URL for the caller's team and returns it for the
* FE to redirect to. We proxy through Supabase's {@code create-customer-portal-session} edge
* function rather than calling Stripe directly so the Stripe secret never leaves Supabase and
* portal config stays version-controlled alongside the rest of the billing pipeline.
*
* <p>Authorisation: any authenticated team member can open the portal. The portal itself shows
* billing for the whole team (Stripe customer is per-team), so this is by design — we don't
* gate to leaders the way {@code PATCH /cap} does. If product later wants leader-only access
* we'd add a {@code TeamRole.LEADER} check here.
*
* <p>We pre-check that the team actually has a Stripe customer before calling the edge fn so a
* free-tier team gets a clean 404 + {@code TEAM_NOT_SUBSCRIBED} instead of a generic 502 from a
* downstream error. {@link #isSubscribed(Optional)} stays the source of truth for "subscribed"
* across both this endpoint and {@code GET /wallet}.
*
* <p>The {@code returnUrl} (optional, body field) is validated against the {@code
* payg.portal.allowed-return-hosts} allowlist before forwarding to the edge fn — an
* authenticated attacker can otherwise mint a legit Stripe portal session that bounces the
* victim back to {@code https://evil.example} after they "Return to Stirling." Defense in
* depth: even if the Supabase edge fn enforces its own allowlist, we enforce one here too so
* the security contract is visible in this controller's tests.
*
* <p>Transaction scope: the DB lookup (team membership + {@code payg_team_extensions}) runs
* inside {@link #loadPortalContext}; each Spring Data repo call uses its own short transaction,
* and the {@code JOIN FETCH} on {@code findPrimaryMembership} eagerly hydrates the team so no
* lazy access happens later. The outbound HTTP call to Supabase therefore runs with no DB
* connection held — a slow edge fn cannot pin a HikariCP connection for the full 30s read
* timeout. Same pattern as {@code PaygMeterReportingService}, which fires from a {@code
* JobChargeService} afterCommit hook.
*
* <p>Status map:
*
* <ul>
* <li>200 + {@code {url}} — happy path.
* <li>400 + {@code {error: "INVALID_RETURN_URL"}} — caller-supplied {@code returnUrl} is
* malformed or its host isn't in {@code payg.portal.allowed-return-hosts}.
* <li>401 — anonymous / missing principal.
* <li>403 — authenticated but no team (caller can't have a portal session without one).
* <li>404 + {@code {error: "TEAM_NOT_SUBSCRIBED"}} — team has no Stripe customer yet.
* <li>502 + {@code {error: "PORTAL_UNAVAILABLE"}} — edge fn returned non-2xx or {@code
* success=false}, or the call itself threw.
* <li>503 + {@code {error: "PORTAL_NOT_CONFIGURED"}} — {@code payg.portal.endpoint} is blank
* (local dev / unit tests).
* </ul>
*/
@PostMapping("/portal-session")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Map<String, Object>> createPortalSession(
@Valid @RequestBody(required = false) PortalSessionRequest req, Authentication auth) {
if (portalEndpoint.isBlank()) {
// Local dev / unit tests without Supabase configured — return a clean 503 instead of
// letting RestTemplate explode on a blank URL.
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "PORTAL_NOT_CONFIGURED"));
}
// returnUrl is the only optional field on the body; the body itself is optional too, so a
// caller that doesn't care about a custom return URL can POST with no body at all and let
// the edge fn fall back to its configured default.
String requestedReturnUrl = req == null ? null : req.returnUrl();
if (requestedReturnUrl != null
&& !requestedReturnUrl.isBlank()
&& !isReturnUrlAllowed(requestedReturnUrl)) {
log.warn("Portal session rejected: returnUrl host not on allowlist");
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "INVALID_RETURN_URL"));
}
// DB work is in its own readOnly tx; the HTTP call runs outside any transaction.
PortalContext ctx;
try {
ctx = loadPortalContext(auth);
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (ctx.notFoundReason() != null) {
return ResponseEntity.status(ctx.status()).body(Map.of("error", ctx.notFoundReason()));
}
if (ctx.status() != HttpStatus.OK) {
return ResponseEntity.status(ctx.status()).build();
}
Long teamId = ctx.teamId();
try {
HttpHeaders headers = new HttpHeaders();
if (!portalServiceRoleToken.isBlank()) {
headers.setBearerAuth(portalServiceRoleToken);
}
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new HashMap<>();
body.put("team_id", teamId.toString());
if (requestedReturnUrl != null && !requestedReturnUrl.isBlank()) {
body.put("return_url", requestedReturnUrl);
}
ResponseEntity<Map> response =
saasRestTemplate.exchange(
portalEndpoint,
HttpMethod.POST,
new HttpEntity<>(body, headers),
Map.class);
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
log.warn(
"Portal session edge fn returned {} for team {}",
response.getStatusCode(),
teamId);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("error", "PORTAL_UNAVAILABLE"));
}
@SuppressWarnings("unchecked")
Map<String, Object> respBody = (Map<String, Object>) response.getBody();
Object successVal = respBody.get("success");
Object urlVal = respBody.get("url");
boolean success = Boolean.TRUE.equals(successVal);
if (!success || !(urlVal instanceof String) || ((String) urlVal).isBlank()) {
log.warn(
"Portal session edge fn payload invalid for team {}: success={} urlPresent={}",
teamId,
successVal,
urlVal != null);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("error", "PORTAL_UNAVAILABLE"));
}
return ResponseEntity.ok(Map.of("url", urlVal));
} catch (Exception e) {
// Edge fn unreachable, timeout, malformed response, etc. Anything that propagates up
// becomes a 502 — the caller hits "Manage billing" again or contacts support; we
// never 500 on a downstream wobble.
log.warn("Portal session edge fn call failed for team {}: {}", teamId, e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body(Map.of("error", "PORTAL_UNAVAILABLE"));
}
}
/**
* Loads the team + subscription state needed by {@link #createPortalSession}.
*
* <p>Each Spring Data repo call gets its own short transaction (managed by the repository
* proxy); {@link TeamMembershipRepository#findPrimaryMembership} uses {@code JOIN FETCH} so the
* returned {@link TeamMembership#getTeam()} is fully initialised — no lazy access happens after
* the call returns. Crucially, by the time this method returns there is <em>no</em> active
* transaction, so the outbound RestTemplate call in {@link #createPortalSession} runs with no
* DB connection held. We deliberately do <em>not</em> mark this {@code @Transactional} because
* Spring's self-invocation proxy semantics mean the annotation would be silently no-op when
* called via {@code this.loadPortalContext(...)}, which is exactly how it is called below.
* Documenting the intent here rather than relying on a non-functional annotation.
*
* <p>Returned {@link PortalContext#status} drives the caller's response: {@code OK} means
* forward to the edge fn, anything else short-circuits with that status (and the optional
* {@code notFoundReason} error body for the 404 case).
*/
PortalContext loadPortalContext(Authentication auth) {
User user = AuthenticationUtils.getCurrentUser(auth, userRepository);
Optional<TeamMembership> primary = primaryMembership(user.getId());
if (primary.isEmpty()) {
// Authenticated but no team → no Stripe customer can exist for them. 403 mirrors the
// cap endpoints' "no team to act on" response.
return new PortalContext(null, HttpStatus.FORBIDDEN, null);
}
Long teamId = primary.get().getTeam().getId();
Optional<PaygTeamExtensions> extOpt = extRepo.findById(teamId);
if (!isSubscribed(extOpt)) {
// Free-tier team — no Stripe customer means no portal to open. The FE shows "Subscribe
// first" rather than a generic error toast.
return new PortalContext(teamId, HttpStatus.NOT_FOUND, "TEAM_NOT_SUBSCRIBED");
}
return new PortalContext(teamId, HttpStatus.OK, null);
}
/** Snapshot of the DB state used by {@link #createPortalSession}. Package-private for tests. */
record PortalContext(Long teamId, HttpStatus status, String notFoundReason) {}
/**
* Returns {@code true} iff the caller-supplied {@code returnUrl} parses cleanly and its host
* appears in {@code payg.portal.allowed-return-hosts}. If the allowlist is empty we reject any
* caller-supplied {@code returnUrl} — the operator must explicitly opt-in to which hosts the
* portal may redirect through. Match is case-insensitive on host; scheme is restricted to
* {@code https} (and {@code http} for local-dev tools that pin to {@code localhost}) so a
* caller can't smuggle a {@code javascript:} or {@code data:} URL past the edge fn.
*/
private boolean isReturnUrlAllowed(String returnUrl) {
if (portalReturnUrlAllowedHosts.isEmpty()) {
return false;
}
URI uri;
try {
uri = new URI(returnUrl);
} catch (URISyntaxException e) {
return false;
}
String scheme = uri.getScheme();
String host = uri.getHost();
if (scheme == null || host == null) {
return false;
}
String lowerScheme = scheme.toLowerCase(Locale.ROOT);
if (!"https".equals(lowerScheme) && !"http".equals(lowerScheme)) {
return false;
}
return portalReturnUrlAllowedHosts.contains(host.toLowerCase(Locale.ROOT));
}
/**
* Request body for {@link #createPortalSession}. {@code returnUrl} is optional — if blank /
* null the edge function falls back to its configured default. We validate the URL's host
* against {@code payg.portal.allowed-return-hosts} before forwarding; see {@link
* #createPortalSession} for the security rationale.
*/
public record PortalSessionRequest(String returnUrl) {}
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
private Optional<TeamMembership> primaryMembership(Long userId) {
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
}
/**
* Until {@code payg_subscription_id} (PR #6532) lands we treat the presence of {@code
* stripe_customer_id} as a stand-in for "is subscribed." Once that PR merges this check
* collapses to {@code ext.getPaygSubscriptionId() != null}.
*/
private static boolean isSubscribed(Optional<PaygTeamExtensions> extOpt) {
return extOpt.map(PaygTeamExtensions::getStripeCustomerId)
.filter(s -> !s.isBlank())
.isPresent();
}
private List<MemberRow> buildMemberRows(Long teamId) {
List<TeamMembership> all = memberRepo.findByTeamId(teamId);
if (all.isEmpty()) {
return List.of();
}
LocalDateTime[] window = currentMonthWindow();
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(""),
tm.getCapUnits() != null ? tm.getCapUnits().intValue() : null,
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 static int resolveBillableLimit(boolean subscribed, EntitlementSnapshot snap) {
if (subscribed) {
// Subscribed teams have no free-tier ceiling — their cap (if any) is what bounds
// them. FE uses billableLimit only to draw the "X of Y" progress when free; for
// subscribed users it reads capUsd/noCap. We still return a sane number so any
// stale UI that reads billableLimit doesn't divide by zero — use the cap if set,
// else MAX_VALUE-shaped sentinel via Integer.MAX_VALUE.
Long cap = snap.periodCapUnits();
if (cap != null) {
return clampToInt(cap);
}
return Integer.MAX_VALUE;
}
// Free tier — prefer the per-team cap if a wallet policy already exists (rare for free
// users), otherwise the fallback free-tier ceiling. PR #6532 will replace this with
// pricing_policy.free_tier_units_per_cycle.
Long cap = snap.periodCapUnits();
if (cap != null) {
return clampToInt(cap);
}
return FREE_TIER_LIMIT_UNITS_FALLBACK;
}
private WalletSnapshotResponse emptySnapshot() {
LocalDateTime[] window = currentMonthWindow();
return new WalletSnapshotResponse(
STATUS_FREE,
ROLE_MEMBER,
ISO_DATE.format(window[0].toLocalDate()),
ISO_DATE.format(window[1].toLocalDate()),
0,
FREE_TIER_LIMIT_UNITS_FALLBACK,
null,
false,
null,
0,
new CategoryBreakdown(0, 0, 0),
List.of(),
Collections.emptyList());
}
}
@@ -0,0 +1,65 @@
package stirling.software.saas.payg.api;
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>Field shape is contract-stable: any new optional field must default to a sentinel that the
* frontend's reuse-if-equal comparison treats as "unchanged". {@code recent} is reserved for a
* future activity feed and stays empty in V1.
*
* @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.
* @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle.
* @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE.
* @param billableLimit free-tier ceiling in units. {@code 500} during V1 until {@code
* pricing_policy.free_tier_units_per_cycle} (PR #6532) lands and feeds this number live.
* @param capUsd dollar cap when {@code status == "subscribed"}; {@code null} when free or when the
* leader has opted into no-cap.
* @param noCap {@code true} when the leader has explicitly disabled the cap. Only meaningful when
* subscribed.
* @param stripeSubscriptionId Stripe subscription id; {@code null} when status is free. Sourced
* from {@code payg_team_extensions.payg_subscription_id} once PR #6532 lands; falls back to
* {@code null} in this branch (the column doesn't exist yet — flagged dependency).
* @param spendUnitsThisPeriod sum of billable units debited this cycle.
* @param categoryBreakdown per-category spend slice driven by the {@code wallet_category_summary}
* view. Zeroed when the team has no billable activity this period.
* @param members leader-only roster of team members + their per-member sub-caps. Empty for member
* callers.
* @param recent reserved for a future "recent activity" feed; empty in V1.
*/
public record WalletSnapshotResponse(
String status,
String role,
String billingPeriodStart,
String billingPeriodEnd,
int billableUsed,
int billableLimit,
Integer capUsd,
boolean noCap,
String stripeSubscriptionId,
int spendUnitsThisPeriod,
CategoryBreakdown categoryBreakdown,
List<MemberRow> members,
List<Object> recent) {
/**
* Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget.
* Sourced from {@code wallet_category_summary} (a Postgres view that filters out {@code
* BYPASSED} and {@code NULL} rows so manual / pre-V16 entries don't pollute the breakdown).
*/
public record CategoryBreakdown(int api, int ai, int automation) {}
/**
* One row of the team-members table on the leader's Plan page. {@code capUnits} is the
* per-member sub-cap (null = bounded only by the team cap).
*/
public record MemberRow(
String userId, String name, String email, Integer capUnits, int spendUnits) {}
}
@@ -0,0 +1,150 @@
package stirling.software.saas.payg.cap;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
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();
}
/**
* Combine a team-wide evaluation with a member-specific evaluation. The effective gates for a
* request are the <em>intersection</em> of the two (must be enabled in both), and the effective
* state is the stricter of the two (DEGRADED &gt; WARNED &gt; FULL). Either side may be {@code
* null} — if the member has no sub-cap configured, pass null and the team eval wins.
*/
public static Evaluation combineTeamAndMember(Evaluation team, Evaluation member) {
if (member == null) {
return team;
}
EntitlementState combinedState = strictest(team.state(), member.state());
FeatureSet combinedSet =
combinedState == EntitlementState.DEGRADED
? strictestSet(team.featureSet(), member.featureSet())
: FeatureSet.FULL;
// Intersect enabled gates so a degraded sub-cap can't accidentally let through a
// gate that the team has lost.
EnumSet<FeatureGate> intersection = EnumSet.copyOf(team.enabledGates());
intersection.retainAll(member.enabledGates());
return new Evaluation(combinedState, combinedSet, List.copyOf(intersection));
}
/**
* 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));
}
private static EntitlementState strictest(EntitlementState a, EntitlementState b) {
// Ordinal works here: FULL=0, WARNED=1, DEGRADED=2 in the enum declaration. Higher
// ordinal = stricter. If that ordering changes, this helper must change too.
return a.ordinal() >= b.ordinal() ? a : b;
}
private static FeatureSet strictestSet(FeatureSet a, FeatureSet b) {
// FULL=0, MINIMAL=1, CLIENT_ONLY=2 in the enum declaration. Higher ordinal = stricter.
return a.ordinal() >= b.ordinal() ? a : b;
}
/**
* Helper for the entitlement guard: returns true if all of {@code required} are in {@code
* enabled}. Used to answer "can this request proceed?".
*/
public static boolean allEnabled(Set<FeatureGate> required, List<FeatureGate> enabled) {
if (required == null || required.isEmpty()) {
return true;
}
return enabled != null && enabled.containsAll(required);
}
}
@@ -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,12 +23,16 @@ 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.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.shadow.PaygShadowCharge;
@@ -55,18 +61,26 @@ public class JobChargeService {
private final DocumentClassifier classifier;
private final PaygShadowChargeRepository shadowRepository;
private final ProcessingJobRepository jobRepository;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final PaygMeterReportingService meterReportingService;
public JobChargeService(
JobService jobService,
PricingPolicyService policyService,
DocumentClassifier classifier,
PaygShadowChargeRepository shadowRepository,
ProcessingJobRepository jobRepository) {
ProcessingJobRepository jobRepository,
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService) {
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");
}
/**
@@ -153,6 +167,10 @@ public class JobChargeService {
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);
}
@@ -197,6 +215,105 @@ public class JobChargeService {
}
}
/**
* Closes a process and — for paid teams — pushes a Stripe meter event for the units captured on
* the originating shadow row. Idempotent w.r.t. process state (delegates to {@link
* JobService#close(UUID)}, which silently no-ops on an already-closed row).
*
* <p>The meter POST runs in an {@code afterCommit} hook so we only tell Stripe about work that
* actually committed to our ledger (the customer's bill is authoritative — Stripe is the
* downstream invoice). A failed POST does not roll back the close; the reconciliation backfill
* (separate chunk) is the durability mechanism.
*
* <p>Skipped paths — no meter event fired:
*
* <ul>
* <li>{@code BillingCategory.BYPASSED} on the shadow row (manual UI tool — defensive; the
* interceptor never opens a process for these).
* <li>Refunded shadow row (the customer was credited; nothing to bill).
* <li>Team has no {@link PaygTeamExtensions#getStripeCustomerId() stripe_customer_id} (free
* tier; ledger entry suffices).
* <li>No shadow row at all (job opened outside the shadow path, e.g. legacy import).
* </ul>
*/
@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 {
postMeterEventForClose(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;
}
private void postMeterEventForClose(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;
}
Optional<PaygTeamExtensions> ext = teamExtensionsRepository.findById(teamId);
String stripeCustomerId = ext.map(PaygTeamExtensions::getStripeCustomerId).orElse(null);
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
// Free-tier team (no Stripe identity) — ledger entry is enough. When PR #6532 lands
// this check tightens to ext.getPaygSubscriptionId() != null, but on this branch the
// presence of stripe_customer_id is the established stand-in for "is subscribed."
log.debug(
"close({}): team {} has no stripeCustomerId → free-tier, no meter event",
jobId,
teamId);
return;
}
String idempotencyKey = "process:" + jobId + ":close";
meterReportingService.recordUsage(
teamId, stripeCustomerId, units, category, idempotencyKey);
}
/**
* 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,301 @@
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 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.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;
}
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;
}
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,227 @@
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.cap.CapEvaluator;
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;
import stirling.software.saas.payg.model.LedgerEntryType;
import stirling.software.saas.payg.policy.PaygTeamExtensions;
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;
/**
* Hot-path entitlement lookup. Returns the {@link EntitlementSnapshot} for a team, computed from
* the team's subscription state (subscribed → {@code wallet_policy.cap_units}; un-subscribed →
* free-tier units per cycle) and the team's current-period spend in the ledger.
*
* <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.
*
* <p><b>Dependencies not on this branch (PR #6532):</b> the snapshot ideally reads {@code
* pricing_policy.free_tier_units_per_cycle} and {@code payg_team_extensions.payg_subscription_id}.
* Locally we fall back to {@value #DEFAULT_FREE_TIER_UNITS} units / cycle for teams without a
* wallet policy, which preserves the "MINIMAL once over the cap" behaviour the guard relies on.
* Once PR #6532 lands and those columns exist, the {@link #resolveCapUnits} branch should switch to
* reading {@code freeTierUnitsPerCycle} when {@code paygSubscriptionId} is null.
*/
@Slf4j
@Service
@Profile("saas")
public class EntitlementService {
static final int CACHE_TTL_SECONDS = 30;
private static final int CACHE_MAX_SIZE = 10_000;
/**
* Default free-tier cap used when no {@code wallet_policy} row exists for the team and the
* {@code free_tier_units_per_cycle} column isn't available yet (pre-#6532 local schema). Keeps
* un-subscribed teams gated on a sensible default rather than uncapped.
*/
static final long DEFAULT_FREE_TIER_UNITS = 500L;
private static final int WARN_AT_PCT = 80;
private static final int DEGRADE_AT_PCT = 100;
private final PaygTeamExtensionsRepository teamExtensionsRepository;
private final WalletPolicyRepository walletPolicyRepository;
private final WalletLedgerRepository ledgerRepository;
private final Cache<Long, EntitlementSnapshot> snapshotCache;
public EntitlementService(
PaygTeamExtensionsRepository teamExtensionsRepository,
WalletPolicyRepository walletPolicyRepository,
WalletLedgerRepository ledgerRepository) {
this.teamExtensionsRepository =
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
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.
*/
public void invalidate(Long teamId) {
if (teamId != null) {
snapshotCache.invalidate(teamId);
}
}
/** Visible for tests. */
long cacheSize() {
return snapshotCache.estimatedSize();
}
@Transactional(readOnly = true)
EntitlementSnapshot computeSnapshot(Long teamId) {
Optional<PaygTeamExtensions> extensionsOpt = teamExtensionsRepository.findById(teamId);
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
Long capUnits = resolveCapUnits(extensionsOpt, walletPolicyOpt);
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);
LocalDateTime[] window = currentMonthWindow();
LocalDateTime periodStart = window[0];
LocalDateTime periodEnd = window[1];
// wallet_ledger stores debits as negative amount_units (signed). The cap evaluator wants
// positive spend, so we negate the SUM. COALESCE in the JPQL guarantees 0 for no-rows.
long signedDebitSum =
ledgerRepository.sumPeriodAmount(
teamId, LedgerEntryType.DEBIT, periodStart, periodEnd);
long spendUnits = signedDebitSum < 0 ? -signedDebitSum : 0L;
Evaluation eval =
CapEvaluator.evaluate(spendUnits, capUnits, warnAtPct, degradeAtPct, degradedSet);
return new EntitlementSnapshot(
eval.state(),
eval.featureSet(),
List.copyOf(eval.enabledGates()),
spendUnits,
capUnits,
periodStart,
periodEnd);
}
/**
* Resolve the period cap.
*
* <p>Target design (once PR #6532 lands):
*
* <ul>
* <li>{@code paygSubscriptionId != null} → {@code wallet_policy.cap_units}.
* <li>{@code paygSubscriptionId == null} → {@code pricing_policy.free_tier_units_per_cycle}.
* </ul>
*
* <p>Current branch (pre-#6532) doesn't have {@code paygSubscriptionId} or {@code
* freeTierUnitsPerCycle} columns. Fallback rule:
*
* <ul>
* <li>{@code wallet_policy} present → its {@code cap_units} (may be null → uncapped, which
* matches the "no cap configured" semantics of {@link CapEvaluator}).
* <li>{@code wallet_policy} absent → {@link #DEFAULT_FREE_TIER_UNITS} units / cycle.
* </ul>
*/
private Long resolveCapUnits(
Optional<PaygTeamExtensions> extensions, Optional<WalletPolicy> walletPolicy) {
// Suppress unused-variable warning until #6532 lands and we read paygSubscriptionId here.
if (extensions.isPresent()) {
// Intentionally read but unused: keep the lookup so the SELECT happens (warms team
// extensions caches the rest of the request path needs) and document where the
// post-#6532 branch will live.
}
if (walletPolicy.isPresent()) {
return walletPolicy.get().getCapUnits();
}
return DEFAULT_FREE_TIER_UNITS;
}
/**
* Inclusive-start / exclusive-end window for the calendar-month period. Calendar-month is the
* only period used at this stage; once {@code BILLING_CYCLE} ships the window resolution moves
* to the wallet policy.
*/
static LocalDateTime[] currentMonthWindow() {
return currentMonthWindow(LocalDateTime.now());
}
/** Test seam — accepts a clock value so tests don't race the calendar boundary. */
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};
}
/** Snapshot for an anonymous / null-team request — never billable, never degraded. */
public static EntitlementSnapshot anonymousFull() {
LocalDateTime[] w = currentMonthWindow();
return new EntitlementSnapshot(
EntitlementState.FULL,
FeatureSet.FULL,
List.of(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE),
0L,
null,
w[0],
w[1]);
}
}
@@ -0,0 +1,42 @@
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.
*/
public record EntitlementSnapshot(
EntitlementState state,
FeatureSet featureSet,
List<FeatureGate> enabledGates,
long periodSpendUnits,
Long periodCapUnits,
LocalDateTime periodStart,
LocalDateTime periodEnd) {
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;
@@ -36,11 +37,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;
@@ -51,10 +55,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
@@ -93,6 +100,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;
private final Timer durationTimer;
@@ -127,6 +135,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")
@@ -145,13 +158,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();
@@ -164,8 +203,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();
@@ -225,7 +264,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 {
@@ -417,4 +457,44 @@ 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;
}
}
@@ -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);
}
}
@@ -0,0 +1,142 @@
package stirling.software.saas.payg.meter;
import java.util.Map;
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;
/**
* 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 — the meter event log table on Supabase plus
* the reconciliation job (separate chunk) is the durability story, not 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 serviceRoleToken;
private final RestTemplate restTemplate;
private final Counter errorsCounter;
public PaygMeterReportingService(
@Value("${payg.meter.endpoint:}") String endpoint,
@Value("${payg.meter.service-role-token:}") String serviceRoleToken,
RestTemplate saasRestTemplate,
MeterRegistry meterRegistry) {
this.endpoint = endpoint;
this.serviceRoleToken = serviceRoleToken;
this.restTemplate = saasRestTemplate;
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. 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 reconciliation replay, or a double-fire from two pods never charges twice.
*
* <p>Never throws. The wallet ledger entry is the source of truth for what the customer is
* billed; if this method fails the only loss is that Stripe doesn't see this event until the
* reconciliation backfill runs.
*/
public void recordUsage(
Long teamId,
String stripeCustomerId,
int units,
BillingCategory category,
String idempotencyKey) {
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;
}
try {
HttpHeaders headers = new HttpHeaders();
if (serviceRoleToken != null && !serviceRoleToken.isBlank()) {
headers.setBearerAuth(serviceRoleToken);
}
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body =
Map.of(
"team_id",
teamId == null ? "" : teamId.toString(),
"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()) {
log.warn(
"Meter event POST returned {} for team {} key {}: {}",
response.getStatusCode(),
teamId,
idempotencyKey,
response.getBody());
errorsCounter.increment();
}
} 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 we'll backfill via reconciliation.
log.warn(
"Meter event POST failed for team {} key {}: {}",
teamId,
idempotencyKey,
e.getMessage());
errorsCounter.increment();
}
}
}
@@ -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
}
@@ -0,0 +1,79 @@
package stirling.software.saas.payg.repository;
import java.time.LocalDate;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import stirling.software.saas.payg.model.BillingCategory;
/**
* Thin JDBC accessor for the {@code wallet_category_summary} view defined in V16. The view exists
* because the in-app PAYG breakdown needs a per-team, per-month, per-category aggregate; doing it
* as a view (rather than an ad-hoc query in the controller) keeps Supabase + Postgres on the same
* surface and lets us push the {@code WHERE billing_category IS NOT NULL} filter into the planner.
*
* <p>Returns an {@link EnumMap} keyed on {@link BillingCategory} — the controller picks out the
* three buckets the FE actually renders ({@code API}, {@code AI}, {@code AUTOMATION}). {@code
* BYPASSED} debits are filtered out at the view layer; non-billable categories that slipped through
* pre-V16 backfill are filtered on read.
*/
@Repository
@Profile("saas")
public class WalletCategorySummaryDao {
private static final String QUERY =
"SELECT billing_category, COALESCE(SUM(units_debited), 0) AS units"
+ " FROM wallet_category_summary"
+ " WHERE team_id = ?"
+ " AND period_start = ?"
+ " GROUP BY billing_category";
private final JdbcTemplate jdbcTemplate;
public WalletCategorySummaryDao(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
}
/**
* Sum of units debited per {@link BillingCategory} for {@code teamId} in the calendar month
* containing {@code periodStart}. Missing categories are mapped to {@code 0}.
*
* @param teamId team to summarise
* @param periodStart first day of the calendar month — must match {@code date_trunc('month',
* occurred_at)} for the rows we want
*/
public Map<BillingCategory, Long> sumByCategory(Long teamId, LocalDate periodStart) {
Objects.requireNonNull(teamId, "teamId");
Objects.requireNonNull(periodStart, "periodStart");
Map<BillingCategory, Long> out = new EnumMap<>(BillingCategory.class);
for (BillingCategory c : BillingCategory.values()) {
out.put(c, 0L);
}
jdbcTemplate.query(
QUERY,
rs -> {
String name = rs.getString(1);
long units = rs.getLong(2);
if (name == null) {
return;
}
BillingCategory parsed;
try {
parsed = BillingCategory.valueOf(name);
} catch (IllegalArgumentException ignore) {
// Pre-V16 or out-of-band string; drop silently. View shouldn't emit these,
// but the FE doesn't render them anyway.
return;
}
out.put(parsed, units);
},
teamId,
java.sql.Date.valueOf(periodStart));
return out;
}
}
@@ -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;
/**
@@ -75,6 +77,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;
@@ -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
*
@@ -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 + SUPABASE_SERVICE_ROLE_TOKEN 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.
payg.meter.endpoint=${PAYG_METER_ENDPOINT:}
payg.meter.service-role-token=${SUPABASE_SERVICE_ROLE_TOKEN:}
# ---------- PAYG customer portal ----------
# Proxies POST /api/v1/payg/portal-session to the Supabase `create-customer-portal-session` edge
# function, which mints a Stripe-hosted billing portal session for the team. Blank endpoint =>
# the controller returns 503 PORTAL_NOT_CONFIGURED so local dev / tests don't blow up on a missing
# URL. Compose via env (PAYG_PORTAL_ENDPOINT=$SUPABASE_FUNCTIONS_URL/create-customer-portal-session)
# rather than templating here so the default-empty branch stays clean.
payg.portal.endpoint=${PAYG_PORTAL_ENDPOINT:}
payg.portal.service-role-token=${SUPABASE_SERVICE_ROLE_TOKEN:}
# Comma-separated host allowlist for caller-supplied return_url on POST /api/v1/payg/portal-session.
# Stops an authenticated user smuggling https://evil.example into the Stripe-hosted portal so the
# "Return to Stirling" link bounces a victim through a legitimate session to an attacker host. If
# blank, any caller-supplied return_url is rejected with 400 INVALID_RETURN_URL and the edge fn
# falls back to its configured default. Set in deployed envs to the production + staging hostnames
# the FE may legitimately redirect to (e.g. "app.stirlingpdf.com,staging.stirlingpdf.com").
payg.portal.allowed-return-hosts=${PAYG_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,909 @@
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.time.LocalDate;
import java.time.LocalDateTime;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
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.HttpEntity;
import org.springframework.http.HttpMethod;
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 org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
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.PortalSessionRequest;
import stirling.software.saas.payg.api.PaygWalletController.UpdateCapRequest;
import stirling.software.saas.payg.api.PaygWalletController.UpdateSubCapRequest;
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
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.policy.PaygTeamExtensions;
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
import stirling.software.saas.payg.repository.WalletCategorySummaryDao;
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 {
private static final String PORTAL_ENDPOINT =
"https://example.supabase.co/functions/v1/create-customer-portal-session";
private static final String PORTAL_TOKEN = "service-role-test-token";
private static final String PORTAL_ALLOWED_HOSTS = "app.example,staging.example";
@Mock private EntitlementService entitlementService;
@Mock private TeamMembershipRepository memberRepo;
@Mock private PaygTeamExtensionsRepository extRepo;
@Mock private WalletPolicyRepository policyRepo;
@Mock private WalletLedgerRepository ledgerRepo;
@Mock private WalletCategorySummaryDao categorySummaryDao;
@Mock private UserRepository userRepository;
@Mock private RestTemplate restTemplate;
private PaygWalletController controller;
@BeforeEach
void setUp() {
controller = newController(PORTAL_ENDPOINT, PORTAL_TOKEN, PORTAL_ALLOWED_HOSTS);
}
private PaygWalletController newController(
String portalEndpoint, String portalToken, String allowedHosts) {
return new PaygWalletController(
entitlementService,
memberRepo,
extRepo,
policyRepo,
ledgerRepo,
categorySummaryDao,
userRepository,
restTemplate,
portalEndpoint,
portalToken,
allowedHosts);
}
// -----------------------------------------------------------------------------------------
// 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(extRepo.findById(42L)).thenReturn(Optional.empty());
when(policyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(entitlementService.getSnapshot(42L)).thenReturn(snapshot(0L, null));
when(categorySummaryDao.sumByCategory(eq(42L), any(LocalDate.class)))
.thenReturn(emptyCategoryMap());
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.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_returnsCapInUsdAndEmptyMembers() {
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)));
// stripeCustomerId present → "subscribed" (until PR #6532's payg_subscription_id lands)
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(99L);
ext.setStripeCustomerId("cus_test_123");
when(extRepo.findById(99L)).thenReturn(Optional.of(ext));
WalletPolicy policy = new WalletPolicy();
policy.setTeamId(99L);
policy.setCapUnits(2500L); // == $25 at 100 units / $
when(policyRepo.findByTeamId(99L)).thenReturn(Optional.of(policy));
when(entitlementService.getSnapshot(99L)).thenReturn(snapshot(312L, 2500L));
Map<BillingCategory, Long> byCat = emptyCategoryMap();
byCat.put(BillingCategory.API, 110L);
byCat.put(BillingCategory.AI, 200L);
byCat.put(BillingCategory.AUTOMATION, 2L);
when(categorySummaryDao.sumByCategory(eq(99L), any(LocalDate.class))).thenReturn(byCat);
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.members()).isEmpty();
assertThat(body.categoryBreakdown().api()).isEqualTo(110);
assertThat(body.categoryBreakdown().ai()).isEqualTo(200);
assertThat(body.categoryBreakdown().automation()).isEqualTo(2);
// stripeSubscriptionId still null until #6532 lands.
assertThat(body.stripeSubscriptionId()).isNull();
// Member role → ledger never queried per-user.
verify(ledgerRepo, never()).sumPeriodAmountForMember(any(), any(), any(), any(), any());
}
@Test
void getWallet_subscribedNoCap_returnsNoCapTrueAndNullCapUsd() {
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)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(11L);
ext.setStripeCustomerId("cus_nocap");
when(extRepo.findById(11L)).thenReturn(Optional.of(ext));
WalletPolicy policy = new WalletPolicy();
policy.setTeamId(11L);
policy.setCapUnits(null); // explicit no-cap
when(policyRepo.findByTeamId(11L)).thenReturn(Optional.of(policy));
when(entitlementService.getSnapshot(11L)).thenReturn(snapshot(50L, null));
when(categorySummaryDao.sumByCategory(eq(11L), any(LocalDate.class)))
.thenReturn(emptyCategoryMap());
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();
}
@Test
void getWallet_leader_populatesMembers() {
User leader = userWithId(10L, UUID.randomUUID());
User member = userWithId(11L, UUID.randomUUID());
member.setUsername("alice");
member.setEmail("alice@example.com");
Team team = teamWithId(77L);
TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER);
TeamMembership memberRow = membership(team, member, TeamRole.MEMBER);
memberRow.setCapUnits(1500L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(10L)).thenReturn(List.of(leaderRow));
when(extRepo.findById(77L)).thenReturn(Optional.empty());
when(policyRepo.findByTeamId(77L)).thenReturn(Optional.empty());
when(entitlementService.getSnapshot(77L)).thenReturn(snapshot(0L, null));
when(categorySummaryDao.sumByCategory(eq(77L), any(LocalDate.class)))
.thenReturn(emptyCategoryMap());
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("alice@example.com");
assertThat(secondMember.capUnits()).isEqualTo(1500);
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, memberRepo, extRepo, policyRepo, categorySummaryDao);
}
@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());
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_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);
}
// -----------------------------------------------------------------------------------------
// PATCH /sub-caps/{userId}
// -----------------------------------------------------------------------------------------
@Test
void updateSubCap_leaderBelowTeamCap_savesAndReportsUnclamped() {
User leader = userWithId(30L, UUID.randomUUID());
User target = userWithId(31L, UUID.randomUUID());
Team team = teamWithId(50L);
TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER);
TeamMembership targetRow = membership(team, target, TeamRole.MEMBER);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(30L)).thenReturn(List.of(leaderRow));
when(memberRepo.findByTeamIdAndUserId(50L, 31L)).thenReturn(Optional.of(targetRow));
WalletPolicy policy = new WalletPolicy();
policy.setTeamId(50L);
policy.setCapUnits(5000L);
when(policyRepo.findByTeamId(50L)).thenReturn(Optional.of(policy));
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(
31L, new UpdateSubCapRequest(2000), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = resp.getBody();
assertThat(body).isNotNull();
assertThat(body.get("success")).isEqualTo(Boolean.TRUE);
assertThat(body.get("clamped")).isEqualTo(Boolean.FALSE);
assertThat(body.get("capUnits")).isEqualTo(2000L);
ArgumentCaptor<TeamMembership> saved = ArgumentCaptor.forClass(TeamMembership.class);
verify(memberRepo).save(saved.capture());
assertThat(saved.getValue().getCapUnits()).isEqualTo(2000L);
verify(entitlementService).invalidate(50L);
}
@Test
void updateSubCap_leaderAboveTeamCap_clampsToTeamCap() {
User leader = userWithId(32L, UUID.randomUUID());
User target = userWithId(33L, UUID.randomUUID());
Team team = teamWithId(51L);
TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER);
TeamMembership targetRow = membership(team, target, TeamRole.MEMBER);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(32L)).thenReturn(List.of(leaderRow));
when(memberRepo.findByTeamIdAndUserId(51L, 33L)).thenReturn(Optional.of(targetRow));
WalletPolicy policy = new WalletPolicy();
policy.setTeamId(51L);
policy.setCapUnits(1000L);
when(policyRepo.findByTeamId(51L)).thenReturn(Optional.of(policy));
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(
33L, new UpdateSubCapRequest(9999), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = resp.getBody();
assertThat(body.get("success")).isEqualTo(Boolean.TRUE);
assertThat(body.get("clamped")).isEqualTo(Boolean.TRUE);
assertThat(body.get("capUnits")).isEqualTo(1000L);
ArgumentCaptor<TeamMembership> saved = ArgumentCaptor.forClass(TeamMembership.class);
verify(memberRepo).save(saved.capture());
assertThat(saved.getValue().getCapUnits()).isEqualTo(1000L);
verify(entitlementService).invalidate(51L);
}
@Test
void updateSubCap_nullCapUnits_clearsSubCap() {
User leader = userWithId(34L, UUID.randomUUID());
User target = userWithId(35L, UUID.randomUUID());
Team team = teamWithId(52L);
TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER);
TeamMembership targetRow = membership(team, target, TeamRole.MEMBER);
targetRow.setCapUnits(500L); // had a sub-cap before
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(34L)).thenReturn(List.of(leaderRow));
when(memberRepo.findByTeamIdAndUserId(52L, 35L)).thenReturn(Optional.of(targetRow));
WalletPolicy policy = new WalletPolicy();
policy.setTeamId(52L);
policy.setCapUnits(5000L);
when(policyRepo.findByTeamId(52L)).thenReturn(Optional.of(policy));
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(
35L, new UpdateSubCapRequest(null), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> body = resp.getBody();
assertThat(body.get("clamped")).isEqualTo(Boolean.FALSE);
assertThat(body.get("capUnits")).isNull();
ArgumentCaptor<TeamMembership> saved = ArgumentCaptor.forClass(TeamMembership.class);
verify(memberRepo).save(saved.capture());
assertThat(saved.getValue().getCapUnits()).isNull();
verify(entitlementService).invalidate(52L);
}
@Test
void updateSubCap_teamHasNoCap_neverClamps() {
// Subscribed no-cap team: wallet_policy.cap_units == null → any sub-cap is accepted as-is.
User leader = userWithId(36L, UUID.randomUUID());
User target = userWithId(37L, UUID.randomUUID());
Team team = teamWithId(53L);
TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER);
TeamMembership targetRow = membership(team, target, TeamRole.MEMBER);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(36L)).thenReturn(List.of(leaderRow));
when(memberRepo.findByTeamIdAndUserId(53L, 37L)).thenReturn(Optional.of(targetRow));
WalletPolicy policy = new WalletPolicy();
policy.setTeamId(53L);
policy.setCapUnits(null); // no team cap
when(policyRepo.findByTeamId(53L)).thenReturn(Optional.of(policy));
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(
37L, new UpdateSubCapRequest(1_000_000), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody().get("clamped")).isEqualTo(Boolean.FALSE);
assertThat(resp.getBody().get("capUnits")).isEqualTo(1_000_000L);
}
@Test
void updateSubCap_memberIsForbidden() {
User member = userWithId(38L, UUID.randomUUID());
Team team = teamWithId(54L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(member));
when(memberRepo.findPrimaryMembership(38L))
.thenReturn(List.of(membership(team, member, TeamRole.MEMBER)));
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(
99L, new UpdateSubCapRequest(100), jwtAuth(member.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(memberRepo, never()).save(any());
verify(entitlementService, never()).invalidate(any());
}
@Test
void updateSubCap_targetInDifferentTeam_returns404() {
User leader = userWithId(40L, UUID.randomUUID());
Team team = teamWithId(55L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
when(memberRepo.findPrimaryMembership(40L))
.thenReturn(List.of(membership(team, leader, TeamRole.LEADER)));
// Target user 41 is not a member of team 55.
when(memberRepo.findByTeamIdAndUserId(55L, 41L)).thenReturn(Optional.empty());
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(
41L, new UpdateSubCapRequest(500), jwtAuth(leader.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
verify(memberRepo, never()).save(any());
verify(entitlementService, never()).invalidate(any());
}
@Test
void updateSubCap_noTeam_isForbidden() {
User user = userWithId(42L, UUID.randomUUID());
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(42L)).thenReturn(List.of());
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(
99L, new UpdateSubCapRequest(100), jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verify(memberRepo, never()).save(any());
}
@Test
void updateSubCap_anonymousIs401() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<Map<String, Object>> resp =
controller.updateSubCap(99L, new UpdateSubCapRequest(100), anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(memberRepo, policyRepo, entitlementService);
}
// -----------------------------------------------------------------------------------------
// POST /portal-session
// -----------------------------------------------------------------------------------------
@Test
void portalSession_subscribedTeam_returnsUrlAndCallsEdgeFnWithBearer() {
User user = userWithId(60L, UUID.randomUUID());
Team team = teamWithId(80L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(60L))
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(80L);
ext.setStripeCustomerId("cus_subscribed");
when(extRepo.findById(80L)).thenReturn(Optional.of(ext));
when(restTemplate.exchange(
eq(PORTAL_ENDPOINT),
eq(HttpMethod.POST),
any(HttpEntity.class),
eq(Map.class)))
.thenReturn(
ResponseEntity.ok(
(Map)
Map.of(
"success",
true,
"url",
"https://billing.stripe.com/p/session/xyz")));
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest("https://app.example/return"),
jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().get("url")).isEqualTo("https://billing.stripe.com/p/session/xyz");
@SuppressWarnings("rawtypes")
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate)
.exchange(
eq(PORTAL_ENDPOINT), eq(HttpMethod.POST), captor.capture(), eq(Map.class));
HttpEntity<?> sent = captor.getValue();
// Bearer service-role header forwarded so the edge fn can authorise.
assertThat(sent.getHeaders().getFirst("Authorization")).isEqualTo("Bearer " + PORTAL_TOKEN);
// Body carries team_id (string) + the optional return_url.
@SuppressWarnings("unchecked")
Map<String, Object> body = (Map<String, Object>) sent.getBody();
assertThat(body).isNotNull();
assertThat(body.get("team_id")).isEqualTo("80");
assertThat(body.get("return_url")).isEqualTo("https://app.example/return");
}
@Test
void portalSession_teamWithoutStripeCustomer_returns404() {
User user = userWithId(61L, UUID.randomUUID());
Team team = teamWithId(81L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(61L))
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
// No PaygTeamExtensions row at all → unsubscribed.
when(extRepo.findById(81L)).thenReturn(Optional.empty());
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest(null), jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().get("error")).isEqualTo("TEAM_NOT_SUBSCRIBED");
verifyNoInteractions(restTemplate);
}
@Test
void portalSession_edgeFnReturnsSuccessFalse_returns502() {
User user = userWithId(62L, UUID.randomUUID());
Team team = teamWithId(82L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(62L))
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(82L);
ext.setStripeCustomerId("cus_sad");
when(extRepo.findById(82L)).thenReturn(Optional.of(ext));
when(restTemplate.exchange(
eq(PORTAL_ENDPOINT),
eq(HttpMethod.POST),
any(HttpEntity.class),
eq(Map.class)))
.thenReturn(
ResponseEntity.ok((Map) Map.of("success", false, "error", "stripe_down")));
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest(null), jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().get("error")).isEqualTo("PORTAL_UNAVAILABLE");
}
@Test
void portalSession_edgeFnThrows_returns502() {
User user = userWithId(63L, UUID.randomUUID());
Team team = teamWithId(83L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(63L))
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(83L);
ext.setStripeCustomerId("cus_throw");
when(extRepo.findById(83L)).thenReturn(Optional.of(ext));
when(restTemplate.exchange(
eq(PORTAL_ENDPOINT),
eq(HttpMethod.POST),
any(HttpEntity.class),
eq(Map.class)))
.thenThrow(new ResourceAccessException("connect timeout"));
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest(null), jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_GATEWAY);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().get("error")).isEqualTo("PORTAL_UNAVAILABLE");
}
@Test
void portalSession_endpointBlank_returns503() {
// Rebuild controller with blank endpoint to simulate local dev / unconfigured env.
controller = newController("", PORTAL_TOKEN, PORTAL_ALLOWED_HOSTS);
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest(null), jwtAuth(UUID.randomUUID()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().get("error")).isEqualTo("PORTAL_NOT_CONFIGURED");
// Should short-circuit before resolving the user or hitting downstream.
verifyNoInteractions(userRepository, memberRepo, extRepo, restTemplate);
}
@Test
void portalSession_returnUrlNotOnAllowlist_returns400() {
// No DB / RestTemplate stubbing — rejection happens before the controller resolves the
// user, so none of those collaborators should be touched.
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest("https://evil.example/billing-return"),
jwtAuth(UUID.randomUUID()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody().get("error")).isEqualTo("INVALID_RETURN_URL");
verifyNoInteractions(userRepository, memberRepo, extRepo, restTemplate);
}
@Test
void portalSession_returnUrlMalformed_returns400() {
// Non-http(s) scheme should be rejected before any DB / HTTP work.
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest("javascript:alert(1)"),
jwtAuth(UUID.randomUUID()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(resp.getBody().get("error")).isEqualTo("INVALID_RETURN_URL");
verifyNoInteractions(userRepository, memberRepo, extRepo, restTemplate);
}
@Test
void portalSession_returnUrlEmpty_allowlistNotChecked() {
// returnUrl == null / blank → no allowlist enforcement; the edge fn falls back to its
// configured default. Use a controller with an EMPTY allowlist to prove the path bypasses
// it: with the empty allowlist any non-null URL would be rejected, but null sails through.
controller = newController(PORTAL_ENDPOINT, PORTAL_TOKEN, "");
User user = userWithId(64L, UUID.randomUUID());
Team team = teamWithId(84L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(64L))
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(84L);
ext.setStripeCustomerId("cus_blank");
when(extRepo.findById(84L)).thenReturn(Optional.of(ext));
when(restTemplate.exchange(
eq(PORTAL_ENDPOINT),
eq(HttpMethod.POST),
any(HttpEntity.class),
eq(Map.class)))
.thenReturn(
ResponseEntity.ok(
(Map)
Map.of(
"success",
true,
"url",
"https://billing.stripe.com/p/session/abc")));
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(
new PortalSessionRequest(null), jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// The forwarded body must NOT carry return_url when caller didn't supply one.
@SuppressWarnings("rawtypes")
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate)
.exchange(
eq(PORTAL_ENDPOINT), eq(HttpMethod.POST), captor.capture(), eq(Map.class));
@SuppressWarnings("unchecked")
Map<String, Object> sentBody = (Map<String, Object>) captor.getValue().getBody();
assertThat(sentBody).doesNotContainKey("return_url");
assertThat(sentBody.get("team_id")).isEqualTo("84");
}
@Test
void portalSession_nullBody_isAllowed() {
// FE may POST with no body at all — Spring binds @RequestBody(required = false) to null,
// and the controller treats that the same as a body with returnUrl=null.
User user = userWithId(65L, UUID.randomUUID());
Team team = teamWithId(85L);
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
when(memberRepo.findPrimaryMembership(65L))
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(85L);
ext.setStripeCustomerId("cus_nullbody");
when(extRepo.findById(85L)).thenReturn(Optional.of(ext));
when(restTemplate.exchange(
eq(PORTAL_ENDPOINT),
eq(HttpMethod.POST),
any(HttpEntity.class),
eq(Map.class)))
.thenReturn(
ResponseEntity.ok(
(Map)
Map.of(
"success",
true,
"url",
"https://billing.stripe.com/p/session/null")));
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(null, jwtAuth(user.getSupabaseId()));
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void portalSession_anonymous_returns401() {
Authentication anon =
new AnonymousAuthenticationToken(
"k",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
ResponseEntity<Map<String, Object>> resp =
controller.createPortalSession(new PortalSessionRequest(null), anon);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
verifyNoInteractions(restTemplate, extRepo, memberRepo);
}
// -----------------------------------------------------------------------------------------
// 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);
}
private static Map<BillingCategory, Long> emptyCategoryMap() {
EnumMap<BillingCategory, Long> m = new EnumMap<>(BillingCategory.class);
for (BillingCategory c : BillingCategory.values()) {
m.put(c, 0L);
}
return m;
}
private static Authentication jwtAuth(UUID supabaseId) {
Jwt jwt =
Jwt.withTokenValue("token")
.header("alg", "RS256")
.claim("sub", supabaseId.toString())
.claim("email", "user@example.com")
.build();
return new EnhancedJwtAuthenticationToken(
jwt, List.of(), "user@example.com", supabaseId.toString());
}
}
@@ -0,0 +1,261 @@
package stirling.software.saas.payg.cap;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Set;
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);
}
// ---------------------------------------------------------------------------------------
// combineTeamAndMember(): team-wide × member sub-cap combination
// ---------------------------------------------------------------------------------------
@Test
void combine_memberNull_teamEvalWins() {
Evaluation team = CapEvaluator.evaluate(50L, 100L, 80, 100, FeatureSet.MINIMAL);
Evaluation combined = CapEvaluator.combineTeamAndMember(team, null);
assertThat(combined).isSameAs(team);
}
@Test
void combine_memberDegradedButTeamFull_degradesEffectiveResult() {
Evaluation team = CapEvaluator.evaluate(10L, 100L, 80, 100, FeatureSet.MINIMAL);
Evaluation member = CapEvaluator.evaluate(50L, 50L, 80, 100, FeatureSet.MINIMAL);
Evaluation combined = CapEvaluator.combineTeamAndMember(team, member);
assertThat(combined.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(combined.featureSet()).isEqualTo(FeatureSet.MINIMAL);
// Intersection of FULL (4 gates) and MINIMAL (OFFSITE_PROCESSING + CLIENT_SIDE) =
// OFFSITE_PROCESSING + CLIENT_SIDE
assertThat(combined.enabledGates())
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
}
@Test
void combine_teamDegradedButMemberFull_stayDegraded() {
Evaluation team = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.MINIMAL);
Evaluation member = CapEvaluator.evaluate(10L, 1000L, 80, 100, FeatureSet.MINIMAL);
Evaluation combined = CapEvaluator.combineTeamAndMember(team, member);
assertThat(combined.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(combined.featureSet()).isEqualTo(FeatureSet.MINIMAL);
assertThat(combined.enabledGates())
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
}
@Test
void combine_bothWarned_warnedState_butFullFeatureSet() {
Evaluation team = CapEvaluator.evaluate(80L, 100L, 80, 100, FeatureSet.MINIMAL);
Evaluation member = CapEvaluator.evaluate(85L, 100L, 80, 100, FeatureSet.MINIMAL);
Evaluation combined = CapEvaluator.combineTeamAndMember(team, member);
assertThat(combined.state()).isEqualTo(EntitlementState.WARNED);
assertThat(combined.featureSet()).isEqualTo(FeatureSet.FULL);
}
@Test
void combine_bothFull_returnsFull() {
Evaluation team = CapEvaluator.evaluate(10L, 100L, 80, 100, FeatureSet.MINIMAL);
Evaluation member = CapEvaluator.evaluate(5L, 50L, 80, 100, FeatureSet.MINIMAL);
Evaluation combined = CapEvaluator.combineTeamAndMember(team, member);
assertThat(combined.state()).isEqualTo(EntitlementState.FULL);
assertThat(combined.featureSet()).isEqualTo(FeatureSet.FULL);
}
@Test
void combine_differentDegradedSets_picksStrictest() {
// If team policy says degrade-to-MINIMAL and member's hypothetical sub-policy says
// degrade-to-CLIENT_ONLY, the stricter (CLIENT_ONLY) wins. In practice today member
// sub-policies use the same set as the team, but the helper is correct in either case.
Evaluation team = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.MINIMAL);
Evaluation member = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.CLIENT_ONLY);
Evaluation combined = CapEvaluator.combineTeamAndMember(team, member);
assertThat(combined.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(combined.featureSet()).isEqualTo(FeatureSet.CLIENT_ONLY);
}
// ---------------------------------------------------------------------------------------
// 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();
}
// ---------------------------------------------------------------------------------------
// allEnabled(): the entitlement-guard predicate
// ---------------------------------------------------------------------------------------
@Test
void allEnabled_emptyRequired_returnsTrue() {
assertThat(CapEvaluator.allEnabled(Set.of(), List.of(FeatureGate.CLIENT_SIDE))).isTrue();
assertThat(CapEvaluator.allEnabled(null, List.of(FeatureGate.CLIENT_SIDE))).isTrue();
}
@Test
void allEnabled_subsetMatches_returnsTrue() {
assertThat(
CapEvaluator.allEnabled(
Set.of(FeatureGate.OFFSITE_PROCESSING),
List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE)))
.isTrue();
}
@Test
void allEnabled_missingOneOfMany_returnsFalse() {
assertThat(
CapEvaluator.allEnabled(
Set.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.AI_SUPPORT),
List.of(FeatureGate.OFFSITE_PROCESSING)))
.isFalse();
}
@Test
void allEnabled_nullEnabled_returnsFalseUnlessRequiredEmpty() {
assertThat(CapEvaluator.allEnabled(Set.of(FeatureGate.OFFSITE_PROCESSING), null)).isFalse();
}
}
@@ -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,13 +37,17 @@ 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.ProcessType;
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.shadow.PaygShadowCharge;
@@ -54,6 +62,8 @@ class JobChargeServiceTest {
private DocumentClassifier classifier;
private PaygShadowChargeRepository shadowRepo;
private ProcessingJobRepository jobRepo;
private PaygTeamExtensionsRepository teamExtRepo;
private PaygMeterReportingService meterReporter;
private JobChargeService service;
@BeforeEach
@@ -63,7 +73,26 @@ 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);
service =
new JobChargeService(
jobService,
policyService,
classifier,
shadowRepo,
jobRepo,
teamExtRepo,
meterReporter);
}
@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 +109,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);
@@ -106,7 +140,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,11 +165,42 @@ 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);
}
@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
void openProcess_openedMultiFile_usesListClassifier(@TempDir Path tmp) throws IOException {
PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10));
@@ -146,7 +216,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,7 +242,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(jobInput(tmp, "in.pdf", "application/pdf")));
assertThat(out.units()).isEqualTo(5);
@@ -187,7 +267,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 +296,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 +315,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");
@@ -330,6 +424,198 @@ 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");
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
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");
}
@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");
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());
// 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");
}
@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) {
PaygShadowCharge row = new PaygShadowCharge();
row.setJobId(jobId);
row.setTeamId(teamId);
row.setPaygUnits(units);
row.setStatus(ShadowChargeStatus.CHARGED);
row.setBillingCategory(category);
return row;
}
// --- helpers --------------------------------------------------------------------------------
private static PricingPolicy stubPolicy(int minCharge, Map<JobSource, Integer> stepLimits) {
@@ -0,0 +1,473 @@
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.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");
}
// ---------------------------------------------------------------------------------------
// 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));
}
private static EntitlementSnapshot degradedSnapshot() {
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));
}
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", "user@example.com");
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
return new EnhancedJwtAuthenticationToken(
jwt,
List.of(new SimpleGrantedAuthority("ROLE_USER")),
"user@example.com",
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,228 @@
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.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.policy.PaygTeamExtensions;
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;
/**
* Unit tests for {@link EntitlementService}: cap resolution (wallet policy vs default free-tier),
* spend window computation, cache hit/miss + invalidate, and snapshot shape for FULL / WARNED /
* DEGRADED bands.
*/
class EntitlementServiceTest {
private PaygTeamExtensionsRepository extensionsRepo;
private WalletPolicyRepository walletPolicyRepo;
private WalletLedgerRepository ledgerRepo;
private EntitlementService service;
@BeforeEach
void setUp() {
extensionsRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
walletPolicyRepo = Mockito.mock(WalletPolicyRepository.class);
ledgerRepo = Mockito.mock(WalletLedgerRepository.class);
service = new EntitlementService(extensionsRepo, walletPolicyRepo, ledgerRepo);
}
@Test
void getSnapshot_nullTeamId_throws() {
assertThatThrownBy(() -> service.getSnapshot(null))
.isInstanceOf(NullPointerException.class);
}
@Test
void noWalletPolicy_usesDefaultFreeTierCap() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(-100L); // 100 units spent (signed negative in ledger)
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodCapUnits()).isEqualTo(EntitlementService.DEFAULT_FREE_TIER_UNITS);
assertThat(snap.periodSpendUnits()).isEqualTo(100L);
// 100/500 = 20% — well below warn → FULL
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL);
}
@Test
void walletPolicyPresent_usesCapUnitsFromPolicy() {
WalletPolicy policy = walletPolicyWithCap(2000L, FeatureSet.MINIMAL);
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.of(policy));
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), 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 spendAtDegradeThreshold_returnsDegradedWithMinimalGates() {
WalletPolicy policy = walletPolicyWithCap(100L, FeatureSet.MINIMAL);
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.of(policy));
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(-100L);
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.state()).isEqualTo(EntitlementState.DEGRADED);
assertThat(snap.featureSet()).isEqualTo(FeatureSet.MINIMAL);
// 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 spendInWarnBand_returnsWarnedButFullFeatureSet() {
WalletPolicy policy = walletPolicyWithCap(100L, FeatureSet.MINIMAL);
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.of(policy));
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(-85L); // 85% — between warn(80) and degrade(100)
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.state()).isEqualTo(EntitlementState.WARNED);
assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL);
assertThat(snap.enabledGates()).hasSize(4);
}
@Test
void positiveSignedSum_treatedAsZeroSpend() {
// Defensive: if the ledger has only credits (positive SUM), spend is zero.
WalletPolicy policy = walletPolicyWithCap(100L, FeatureSet.MINIMAL);
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.of(policy));
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(50L);
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodSpendUnits()).isZero();
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
}
@Test
void cacheHit_secondCallSkipsLedgerLookup() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), 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))
.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any());
assertThat(service.cacheSize()).isEqualTo(1);
}
@Test
void invalidate_dropsCacheAndForcesRecompute() {
when(extensionsRepo.findById(42L)).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(0L);
service.getSnapshot(42L);
service.invalidate(42L);
service.getSnapshot(42L);
verify(ledgerRepo, times(2))
.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any());
}
@Test
void invalidate_otherTeamLeavesEntryAlone() {
when(extensionsRepo.findById(any())).thenReturn(Optional.empty());
when(walletPolicyRepo.findByTeamId(any())).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodAmount(any(), eq(LedgerEntryType.DEBIT), 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))
.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any());
}
@Test
void teamExtensionsPresent_doesNotChangeOutcomeOnPreSubscriptionBranch() {
// Pre-#6532 the team_extensions row carries no subscription field — its presence shouldn't
// change cap resolution.
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(42L);
when(extensionsRepo.findById(42L)).thenReturn(Optional.of(ext));
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
when(ledgerRepo.sumPeriodAmount(eq(42L), eq(LedgerEntryType.DEBIT), any(), any()))
.thenReturn(0L);
EntitlementSnapshot snap = service.getSnapshot(42L);
assertThat(snap.periodCapUnits()).isEqualTo(EntitlementService.DEFAULT_FREE_TIER_UNITS);
}
@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));
}
@Test
void anonymousFull_isFullAndUncapped() {
EntitlementSnapshot snap = EntitlementService.anonymousFull();
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
assertThat(snap.periodCapUnits()).isNull();
assertThat(snap.enabledGates())
.contains(
FeatureGate.OFFSITE_PROCESSING,
FeatureGate.AUTOMATION,
FeatureGate.AI_SUPPORT,
FeatureGate.CLIENT_SIDE);
}
private static WalletPolicy walletPolicyWithCap(Long capUnits, FeatureSet degradedSet) {
WalletPolicy p = new WalletPolicy();
p.setCapUnits(capUnits);
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));
@@ -207,7 +209,7 @@ class PaygChargeInterceptorTest {
@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));
@@ -230,7 +232,7 @@ class PaygChargeInterceptorTest {
@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 +251,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));
@@ -293,7 +295,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 +319,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));
@@ -336,6 +338,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() {
@@ -396,11 +576,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. */
@@ -0,0 +1,196 @@
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.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 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;
/**
* 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}.
*/
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 RestTemplate restTemplate;
private MeterRegistry meterRegistry;
private Counter errorsCounter;
@BeforeEach
void setUp() {
restTemplate = Mockito.mock(RestTemplate.class);
meterRegistry = new SimpleMeterRegistry();
errorsCounter = meterRegistry.counter("payg.meter.errors");
}
private PaygMeterReportingService newService(String endpoint, String token) {
return new PaygMeterReportingService(endpoint, token, restTemplate, meterRegistry);
}
@Test
void recordUsage_happyPath_postsBodyAndDoesNotIncrementErrorCounter() {
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");
@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();
assertThat(body.get("team_id")).isEqualTo("100");
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");
assertThat(errorsCounter.count()).isZero();
}
@Test
void recordUsage_5xxResponse_incrementsErrorCounterAndDoesNotThrow() {
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"))
.doesNotThrowAnyException();
assertThat(errorsCounter.count()).isEqualTo(1.0);
}
@Test
void recordUsage_connectionRefused_incrementsErrorCounterAndDoesNotThrow() {
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"))
.doesNotThrowAnyException();
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"))
.doesNotThrowAnyException();
assertThat(errorsCounter.count()).isEqualTo(1.0);
}
@Test
void recordUsage_blankEndpoint_noopsAndDoesNotCallRestTemplate() {
PaygMeterReportingService service = newService("", TOKEN);
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job5:close");
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
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");
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
assertThat(errorsCounter.count()).isZero();
}
@Test
void recordUsage_zeroUnits_noopsAndDoesNotCallRestTemplate() {
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
service.recordUsage(100L, "cus_abc", 0, BillingCategory.API, "process:job7:close");
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
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");
@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);
}
}
@@ -1,7 +1,7 @@
import { useState } from "react";
import { supabase } from "@app/auth/supabase";
import { Button } from "@mantine/core";
import { usePlans } from "@app/hooks/usePlans";
import apiClient from "@app/services/apiClient";
interface TrialStatus {
isTrialing: boolean;
@@ -38,21 +38,36 @@ export function ManageBillingButton({
setLoading(true);
setErr(null);
try {
const { data, error } = await supabase.functions.invoke<{
url: string;
error?: string;
}>("manage-billing", {
body: {
name: "Functions",
return_url: returnUrl,
},
});
if (error) throw error;
if (!data || "error" in data)
throw new Error(data?.error ?? "No portal URL");
window.location.href = data.url;
// Routes through POST /api/v1/payg/portal-session — the Java controller centralises
// authorisation (team membership + subscription state), validates returnUrl against the
// configured host allowlist, and short-circuits free-tier teams with 404 TEAM_NOT_SUBSCRIBED
// before the Supabase edge fn is even called. The previous direct supabase.functions.invoke
// call bypassed all of that.
const resp = await apiClient.post<{ url?: string; error?: string }>(
"/api/v1/payg/portal-session",
{ returnUrl },
);
const portalUrl = resp.data?.url;
if (!portalUrl) {
throw new Error(resp.data?.error ?? "No portal URL");
}
window.location.href = portalUrl;
} catch (e: unknown) {
setErr(e instanceof Error ? e.message : "Could not open billing portal");
// Axios error response body shape mirrors the controller's status map (PORTAL_UNAVAILABLE,
// PORTAL_NOT_CONFIGURED, TEAM_NOT_SUBSCRIBED, INVALID_RETURN_URL).
const responseBody = (e as { response?: { data?: { error?: string } } })?.response?.data;
const code = responseBody?.error;
const message =
code === "TEAM_NOT_SUBSCRIBED"
? "Subscribe first to manage billing"
: code === "INVALID_RETURN_URL"
? "Could not open billing portal: invalid return URL"
: code === "PORTAL_NOT_CONFIGURED"
? "Billing portal is not configured"
: e instanceof Error
? e.message
: "Could not open billing portal";
setErr(message);
} finally {
setLoading(false);
}