Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06c51f52fc | ||
|
|
db11a32acb | ||
|
|
2d19d83ce4 | ||
|
|
102994ea64 | ||
|
|
6eb41b6931 | ||
|
|
e80e1b2190 | ||
|
|
9f5153f272 | ||
|
|
0e03dd1732 | ||
|
|
9655360e69 | ||
|
|
c4d579ad4c | ||
|
|
09860bffef |
@@ -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 {
|
||||
|
||||
|
||||
+3
@@ -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 < 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 < 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 > WARNED > 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -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]);
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+90
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+142
@@ -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
|
||||
}
|
||||
+79
@@ -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;
|
||||
|
||||
+18
@@ -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();
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+294
-8
@@ -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) {
|
||||
|
||||
+473
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+228
@@ -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;
|
||||
}
|
||||
}
|
||||
+255
-8
@@ -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. */
|
||||
|
||||
+196
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user