mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
PAYG: pay-as-you-go billing — metered automation/AI/API + one-time free grant (#6589)
## Summary Pay-as-you-go (PAYG) billing for Stirling-PDF SaaS. Manual PDF editing stays free forever; only **automation, AI, and API** usage is metered. Every team gets a **one-time lifetime free grant** (default 500 PDFs) before any billing; past that, a team adds a card and pays per metered document, with a self-set monthly spending cap. This branch combines and supersedes the in-flight BE (#6574) and FE (#6579) work plus the SaaS edge functions (Stirling-PDF-SaaS PR, now on `v3`), hardened into a single reviewable feature after a pre-merge dead-code/security review. ## Billing model - **Always free:** manual / JWT web-tool usage is `BYPASSED` — never metered, no matter where it's triggered. - **Billable categories:** `AUTOMATION`, `AI`, `API`. - **One-time lifetime free grant** (`pricing_policy.free_tier_units`, default 500): never resets, survives subscribing. It gates unsubscribed teams (billable API calls hard-stop with a 402 once exhausted) and decides the free-vs-paid split of every job. - **Subscribed:** paid documents (beyond the grant) are metered to a Stripe Billing Meter; an optional monthly spending cap degrades billable categories when reached. - **Dedup:** the same file pushed through several steps within a workflow window counts **once** (lineage join), so API/AI chaining on one file isn't double-charged. ## What's included **Database** — Flyway migrations `V11`→`V21` with matching Supabase twins: pricing policy + per-team sidecar (`payg_team_extensions`: subscription id, Stripe customer, free-grant counter), append-only `wallet_ledger`, shadow charges, subscription-state RPCs (`V14`), audit logs (`V15`), billing category (`V16`), one-time lifetime free grant (`V19`), launch-grant seed (`V20`), drop of the unused `wallet_category_summary` view (`V21`). **Charge pipeline** — `PaygChargeInterceptor` (open/join a process, split the free grant, write the ledger DEBIT), `JobChargeService` (consume the grant under a row lock, restore it on a first-step refund, meter only the paid portion on completion), `StaleJobCloser` fallback (idempotent close → meter). **Entitlement** — `EntitlementService` (per-team cached snapshot: grant-gated for free teams, monthly-cap-gated for subscribed) + `EntitlementGuard` (401 `SIGNUP_REQUIRED` / 402 `FEATURE_DEGRADED` / `PAYG_LIMIT_REACHED`). **Metering** — `PaygMeterReportingService` writes a durable `payg_meter_event_log` row around every POST to the `meter-payg-units` edge fn (pending → posted/failed); `PaygMeterReconcileScheduler` retries unposted events under the same idempotency key inside Stripe's 24h dedup window. **Billing facts** — `TeamBillingService` reads the synced `stripe.*` mirror (subscription window, per-document rate; the unsubscribed-team estimate resolves the rate by Price `lookup_key = plan:processor`). **Wallet API** — `PaygWalletController`: `GET /api/v1/payg/wallet`, `PATCH /api/v1/payg/cap`. **Frontend** — PAYG Plan page (two-card free layout + subscribed views), `useWallet`, upgrade modal with lazy-loaded Stripe Embedded Checkout and a shared `SpendCapControl`, customer-portal link, 402/401 interceptor toast, en-GB i18n. (Per-member usage shows each teammate's spend; the activity feed is behind a flag until polished.) **SaaS edge functions** (`Stirling-PDF-SaaS` `v3`) — `create-checkout-session`, `create-payg-team-subscription`, `create-customer-portal-session`, `meter-payg-units`, `payg-subscription-webhook`, `stripe-sync`, plus the stripe-sync `migrate` + scoped-`backfill` scripts. All price lookup is DB-driven (no `STRIPE_PAYG_PRICE_ID_*` env vars). ## Release prerequisites (prod) 1. Apply Flyway migrations (`V11`→`V21`) and the Supabase migration twins. 2. Stripe Sync Engine: run `stripe-sync:migrate`, then a **scoped** backfill — `product`, `price`, `customer`, `subscription` only (not `all`, which rate-limits). 3. Register 2 PAYG webhook endpoints (each its own signing secret): `stripe-sync` (product/price/customer/subscription `.*`) and `payg-subscription-webhook` (`customer.subscription.created`/`.deleted` drive state; `.updated` + `invoice.*` observed). Keep the legacy `stripe-webhook` only if credits/self-hosted flows still run. 4. Stripe Billing Meter: `event_name = payg_doc_units`, value key `processed_documents`. 5. Env: `PAYG_METER_ENDPOINT` + `SUPABASE_EDGE_FUNCTION_SECRET` (backend); the webhook signing secrets (edge fns). The default pricing policy must point at the PAYG Stripe Price(s); `V20` seeds `free_tier_units = 500`. ## Testing - `:saas:test` green, `:saas:spotlessCheck` clean, edge-fn Deno tests green, FE saas typecheck clean (the remaining errors are pre-existing `proprietary/*` + `prototypes/*`, untouched here). Cucumber shadow-mode suite + CI workflow included. ## Pre-merge review An independent dead-code/security pass came back **clean on security** (team-derived authz / no IDOR, leader-only cap mutation, no billing-category downgrade, dev/mock hooks gated to `import.meta.env.DEV` + `/dev/`, no secrets/injection, fail-open metering by design). The dead/unwired code it flagged has been removed in this branch (unenforced sub-cap control, an unused JDBC DAO + its view, dead methods). ## Follow-ups (tracked, not blocking) - **Enforce per-member sub-caps** — the control was removed because it read for display but never gated a request; the per-member usage display and `cap_units` column are retained for when enforcement is wired. - **API/AI chaining billing model + `ProcessType` enum** — confirm same-file dedup covers API chaining; define per-tool AI charging; decide whether the unused enum values stay. - **Activity feed** — hidden behind a flag until the meter-event surface is polished. --------- Co-authored-by: Reece <[email protected]>
This commit is contained in:
@@ -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<>();
|
||||
|
||||
+5
-1
@@ -87,7 +87,11 @@ public class User implements UserDetails, Serializable {
|
||||
private String email;
|
||||
|
||||
// SaaS-only: Supabase user UUID. Null in OSS / proprietary deployments.
|
||||
@Column(name = "supabase_id", unique = true)
|
||||
// Column is `supabase_auth_id` (canonical name from the initial Supabase remote
|
||||
// schema migration). An earlier Flyway V2 (PR #6384) accidentally introduced a
|
||||
// parallel `supabase_id` column that was used by Java; V17 backfilled and dropped
|
||||
// it. Field name is kept as `supabaseId` to avoid a wide refactor of callers.
|
||||
@Column(name = "supabase_auth_id", unique = true)
|
||||
private UUID supabaseId;
|
||||
|
||||
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL, mappedBy = "user")
|
||||
|
||||
@@ -43,6 +43,8 @@ import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
|
||||
import stirling.software.saas.ai.service.AiCreateProxyService;
|
||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
@@ -54,6 +56,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
||||
@Tag(name = "AI")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
||||
@Slf4j
|
||||
public class AiCreateController {
|
||||
|
||||
|
||||
+3
@@ -26,6 +26,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.saas.ai.model.AiCreateSession;
|
||||
import stirling.software.saas.ai.model.AiCreateSessionStatus;
|
||||
import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@@ -33,6 +35,7 @@ import stirling.software.saas.ai.service.AiCreateSessionService;
|
||||
@Tag(name = "AI")
|
||||
@Hidden
|
||||
@RequiredArgsConstructor
|
||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
||||
@Slf4j
|
||||
public class AiCreateInternalController {
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.ai.service.AiProxyService;
|
||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
@@ -36,6 +38,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@RequestMapping("/api/v1/ai")
|
||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
||||
@Tag(name = "AI")
|
||||
@Hidden
|
||||
@Slf4j
|
||||
|
||||
@@ -9,8 +9,10 @@ import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.saas.interceptor.UnifiedCreditInterceptor;
|
||||
|
||||
// Legacy credit-billing path. Disabled in saas-PAYG by default — activate the legacy-credits
|
||||
// profile explicitly (`--spring.profiles.active=saas,dev,legacy-credits`) if you need it back.
|
||||
@Configuration
|
||||
@Profile("saas")
|
||||
@Profile("saas & legacy-credits")
|
||||
@RequiredArgsConstructor
|
||||
public class CreditInterceptorConfig implements WebMvcConfigurer {
|
||||
|
||||
|
||||
@@ -25,8 +25,9 @@ import stirling.software.saas.service.CreditService;
|
||||
import stirling.software.saas.service.CreditService.CreditSummary;
|
||||
import stirling.software.saas.util.LogRedactionUtils;
|
||||
|
||||
// Legacy credit-billing endpoints. PAYG replaces this — gated behind legacy-credits profile.
|
||||
@RestController
|
||||
@Profile("saas")
|
||||
@Profile("saas & legacy-credits")
|
||||
@RequestMapping("/api/v1/credits")
|
||||
@Tag(name = "Credit Management", description = "Endpoints for managing user API credits")
|
||||
@RequiredArgsConstructor
|
||||
|
||||
@@ -39,8 +39,10 @@ import stirling.software.saas.util.CreditHeaderUtils;
|
||||
* Scoped to controllers annotated with {@link AutoJobPostMapping} so it doesn't hijack the global
|
||||
* exception flow.
|
||||
*/
|
||||
// Legacy credit-billing error advice. PAYG handles its own error semantics via
|
||||
// PaygChargeInterceptor — disabled by default in saas, activate legacy-credits profile if needed.
|
||||
@RestControllerAdvice(annotations = AutoJobPostMapping.class)
|
||||
@Profile("saas")
|
||||
@Profile("saas & legacy-credits")
|
||||
@Slf4j
|
||||
@Order(1)
|
||||
public class CreditErrorAdvice {
|
||||
|
||||
@@ -27,8 +27,10 @@ import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
import stirling.software.saas.util.CreditHeaderUtils;
|
||||
|
||||
// Legacy credit-billing success advice. PAYG writes its own ledger entries via
|
||||
// JobChargeService — disabled by default in saas, activate legacy-credits profile if needed.
|
||||
@RestControllerAdvice
|
||||
@Profile("saas")
|
||||
@Profile("saas & legacy-credits")
|
||||
@Slf4j
|
||||
public class CreditSuccessAdvice implements ResponseBodyAdvice<Object> {
|
||||
|
||||
|
||||
+3
-1
@@ -32,8 +32,10 @@ import stirling.software.saas.service.SaasUserExtensionService;
|
||||
import stirling.software.saas.service.TeamCreditService;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
// Legacy credit-billing interceptor. PAYG replaces this with PaygChargeInterceptor — disabled
|
||||
// by default in saas, activate legacy-credits profile to bring it back.
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@Profile("saas & legacy-credits")
|
||||
@Slf4j
|
||||
public class UnifiedCreditInterceptor implements AsyncHandlerInterceptor {
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package stirling.software.saas.payg.api;
|
||||
|
||||
/**
|
||||
* Cap conversion between the dollar amount the leader edits in the UI and the unit count stored on
|
||||
* {@code wallet_policy.cap_units}.
|
||||
*
|
||||
* <p>The cap is application-layer only — Stripe stays on a flat-priced single meter, so the
|
||||
* conversion rate here doesn't need to match any Stripe price. It only needs to be stable: a leader
|
||||
* who set "$25" should read back "$25" on the next page load.
|
||||
*
|
||||
* <p>V1 rate: {@value #UNITS_PER_USD} units = $1. This anchors the in-app cap representation to the
|
||||
* same unit count the ledger writes, so the "X of Y units used" widget in the FE lines up against
|
||||
* the cap. A future iteration can read this from {@code pricing_policy} once the per-policy money
|
||||
* conversion lands.
|
||||
*
|
||||
* <p>Both directions floor: $24.50 → 2450 units, 2450 units → $24. The FE only sends whole-dollar
|
||||
* inputs (the cap-edit field is an integer text box) so the floor on the read path is the only
|
||||
* place rounding ever shows up, and only when an admin set a non-multiple via SQL.
|
||||
*/
|
||||
public final class CapMoneyUnits {
|
||||
|
||||
/**
|
||||
* Doc-units per USD. {@code 100} = "1 cent per unit" at the in-app display layer. Tied to the
|
||||
* unit-count meter the engine writes to the ledger; not tied to Stripe pricing.
|
||||
*/
|
||||
public static final int UNITS_PER_USD = 100;
|
||||
|
||||
/** Smallest currency unit per USD (always 100 cents in USD; explicit for clarity). */
|
||||
public static final int CENTS_PER_USD = 100;
|
||||
|
||||
private CapMoneyUnits() {}
|
||||
|
||||
/** Convert a dollar cap entered by the leader to doc-units for {@code cap_units}. */
|
||||
public static long usdToUnits(int capUsd) {
|
||||
if (capUsd < 0) {
|
||||
throw new IllegalArgumentException("capUsd must be >= 0");
|
||||
}
|
||||
return (long) capUsd * UNITS_PER_USD;
|
||||
}
|
||||
|
||||
/** Convert {@code cap_units} back to dollars for the response payload. Floor on read. */
|
||||
public static int unitsToUsd(long capUnits) {
|
||||
if (capUnits < 0L) {
|
||||
return 0;
|
||||
}
|
||||
return (int) (capUnits / UNITS_PER_USD);
|
||||
}
|
||||
|
||||
/** Convert a dollar cap to smallest-currency-unit cents for {@code cap_source_money}. */
|
||||
public static long usdToCents(int capUsd) {
|
||||
if (capUsd < 0) {
|
||||
throw new IllegalArgumentException("capUsd must be >= 0");
|
||||
}
|
||||
return (long) capUsd * CENTS_PER_USD;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
package stirling.software.saas.payg.api;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow;
|
||||
import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown;
|
||||
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
|
||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
* Read + cap-mutation surface backing the FE PAYG Plan page.
|
||||
*
|
||||
* <p>{@code GET /api/v1/payg/wallet} is the single fetch the {@code useWallet} hook calls. Returns
|
||||
* a fully-populated {@link WalletSnapshotResponse} — derived from {@link EntitlementService} (for
|
||||
* spend / cap / period), {@link PaygTeamExtensions} (for subscription state), and {@link
|
||||
* WalletLedgerRepository} (for the per-category breakdown widget). Leader callers also get a roster
|
||||
* of team members + their per-member usage; member callers see an empty roster.
|
||||
*
|
||||
* <p>{@code PATCH /api/v1/payg/cap} updates {@code wallet_policy.cap_units} (no Stripe call — the
|
||||
* cap is enforced application-side via the entitlement guard) and invalidates the team's snapshot
|
||||
* cache so the next read reflects the change immediately. Only leaders may call this; the team is
|
||||
* derived from the caller, so we authorise inside the method rather than via {@code @PreAuthorize}
|
||||
* — the team id never appears on the path or query string.
|
||||
*
|
||||
* <p>Subscription state is sourced from {@code payg_team_extensions.payg_subscription_id} (added in
|
||||
* V14): {@code stripeSubscriptionId} echoes it via {@link TeamBillingService}, and a team reads as
|
||||
* {@link #STATUS_SUBSCRIBED} once {@code billing.subscribed()} is true — i.e. it has a subscription
|
||||
* id, or a Stripe customer id as the pre-webhook bridge for a just-completed checkout whose
|
||||
* subscription-created webhook hasn't landed yet (see {@code TeamBillingService.compute}).
|
||||
*/
|
||||
@Slf4j
|
||||
@Hidden
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/payg")
|
||||
@Profile("saas")
|
||||
public class PaygWalletController {
|
||||
|
||||
static final String STATUS_FREE = "free";
|
||||
static final String STATUS_SUBSCRIBED = "subscribed";
|
||||
static final String ROLE_LEADER = "leader";
|
||||
static final String ROLE_MEMBER = "member";
|
||||
|
||||
/**
|
||||
* Placeholder ceiling for the team-less empty snapshot only (authenticated caller without a
|
||||
* membership — shouldn't happen post-migration). Teams always get the live {@code
|
||||
* pricing_policy.free_tier_units} grant via {@link TeamBillingService}.
|
||||
*/
|
||||
private static final int FREE_TIER_LIMIT_UNITS_FALLBACK = 500;
|
||||
|
||||
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
|
||||
private final EntitlementService entitlementService;
|
||||
private final TeamBillingService billingService;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final PaygTeamExtensionsRepository extRepo;
|
||||
private final WalletPolicyRepository policyRepo;
|
||||
private final WalletLedgerRepository ledgerRepo;
|
||||
private final PaygShadowChargeRepository shadowRepo;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public PaygWalletController(
|
||||
EntitlementService entitlementService,
|
||||
TeamBillingService billingService,
|
||||
TeamMembershipRepository memberRepo,
|
||||
PaygTeamExtensionsRepository extRepo,
|
||||
WalletPolicyRepository policyRepo,
|
||||
WalletLedgerRepository ledgerRepo,
|
||||
PaygShadowChargeRepository shadowRepo,
|
||||
UserRepository userRepository) {
|
||||
this.entitlementService = Objects.requireNonNull(entitlementService, "entitlementService");
|
||||
this.billingService = Objects.requireNonNull(billingService, "billingService");
|
||||
this.memberRepo = Objects.requireNonNull(memberRepo, "memberRepo");
|
||||
this.extRepo = Objects.requireNonNull(extRepo, "extRepo");
|
||||
this.policyRepo = Objects.requireNonNull(policyRepo, "policyRepo");
|
||||
this.ledgerRepo = Objects.requireNonNull(ledgerRepo, "ledgerRepo");
|
||||
this.shadowRepo = Objects.requireNonNull(shadowRepo, "shadowRepo");
|
||||
this.userRepository = Objects.requireNonNull(userRepository, "userRepository");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// GET /wallet — the single FE fetch
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@GetMapping("/wallet")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Transactional(readOnly = true)
|
||||
public ResponseEntity<WalletSnapshotResponse> getWallet(Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
// SecurityException maps to 401 per the existing controller convention.
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
|
||||
Optional<TeamMembership> primary = primaryMembership(user.getId());
|
||||
if (primary.isEmpty()) {
|
||||
// Authenticated user without a team — shouldn't happen post-migration, but we don't
|
||||
// want to 500. Return a free-tier-shaped empty snapshot so the FE renders the gated UI
|
||||
// rather than blowing up on a null body.
|
||||
return ResponseEntity.ok(emptySnapshot());
|
||||
}
|
||||
|
||||
TeamMembership membership = primary.get();
|
||||
Long teamId = membership.getTeam().getId();
|
||||
boolean isLeader = membership.getRole() == TeamRole.LEADER;
|
||||
|
||||
// Billing facts (window, free allowance, per-doc rate, doc cap) and the entitlement
|
||||
// snapshot (period spend over that window) share the same composition service, so what
|
||||
// the customer sees here is exactly what the 402 guard enforces.
|
||||
TeamBillingContext billing = billingService.forTeam(teamId);
|
||||
EntitlementSnapshot snap = entitlementService.getSnapshot(teamId);
|
||||
|
||||
String status = billing.subscribed() ? STATUS_SUBSCRIBED : STATUS_FREE;
|
||||
|
||||
boolean noCap = billing.subscribed() && billing.capMoneyMinor() == null;
|
||||
Integer capMajor =
|
||||
billing.capMoneyMinor() != null
|
||||
? Math.toIntExact(billing.capMoneyMinor() / 100)
|
||||
: null;
|
||||
|
||||
// Per-state by construction (see EntitlementService.computeSnapshot): free team → spend is
|
||||
// lifetime free used, cap is the grant size; subscribed → spend is this month's net
|
||||
// billable
|
||||
// docs, cap is the monthly paid-doc ceiling (null = uncapped).
|
||||
int spend = clampToInt(snap.periodSpendUnits());
|
||||
Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null;
|
||||
|
||||
CategoryBreakdown breakdown = buildBreakdown(teamId, snap.periodStart(), snap.periodEnd());
|
||||
|
||||
// Estimated bill = paid (Stripe-metered) docs this period × rate — the free portion was
|
||||
// already netted out at charge time, so this is the metered total, not spend − grant.
|
||||
long periodPaid = shadowRepo.sumPaidUnits(teamId, snap.periodStart(), snap.periodEnd());
|
||||
Long estimatedBill = billingService.estimateBillMinor(billing, periodPaid).orElse(null);
|
||||
|
||||
List<MemberRow> members =
|
||||
isLeader
|
||||
? buildMemberRows(teamId, snap.periodStart(), snap.periodEnd())
|
||||
: List.of();
|
||||
|
||||
WalletSnapshotResponse body =
|
||||
new WalletSnapshotResponse(
|
||||
teamId,
|
||||
status,
|
||||
isLeader ? ROLE_LEADER : ROLE_MEMBER,
|
||||
ISO_DATE.format(snap.periodStart().toLocalDate()),
|
||||
ISO_DATE.format(snap.periodEnd().toLocalDate()),
|
||||
spend,
|
||||
limit,
|
||||
clampToInt(billing.freeGrantUnits()),
|
||||
clampToInt(billing.freeRemainingUnits()),
|
||||
billing.perDocMinor(),
|
||||
billing.currency(),
|
||||
estimatedBill,
|
||||
capMajor,
|
||||
noCap,
|
||||
billing.subscriptionId(),
|
||||
spend,
|
||||
breakdown,
|
||||
members,
|
||||
buildActivity(teamId));
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private CategoryBreakdown buildBreakdown(
|
||||
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
|
||||
Map<BillingCategory, Long> byCategory = new HashMap<>();
|
||||
for (Object[] row :
|
||||
ledgerRepo.sumPeriodAmountByCategory(
|
||||
teamId, LedgerEntryType.DEBIT, periodStart, periodEnd)) {
|
||||
if (row.length >= 2
|
||||
&& row[0] instanceof BillingCategory cat
|
||||
&& row[1] instanceof Number n) {
|
||||
byCategory.put(cat, n.longValue());
|
||||
}
|
||||
}
|
||||
return new CategoryBreakdown(
|
||||
clampToInt(byCategory.getOrDefault(BillingCategory.API, 0L)),
|
||||
clampToInt(byCategory.getOrDefault(BillingCategory.AI, 0L)),
|
||||
clampToInt(byCategory.getOrDefault(BillingCategory.AUTOMATION, 0L)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest ledger entries shaped for the FE activity feed. DEBITs read as usage, REFUNDs as
|
||||
* credits-back; system entries without a category render as {@code other}.
|
||||
*/
|
||||
private List<ActivityRow> buildActivity(Long teamId) {
|
||||
List<ActivityRow> out = new ArrayList<>();
|
||||
for (WalletLedgerEntry e : ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)) {
|
||||
BillingCategory category = e.getBillingCategory();
|
||||
String kind = category != null ? category.name().toLowerCase(Locale.ROOT) : "other";
|
||||
String categoryLabel = category != null ? categoryDisplayName(category) : "Document";
|
||||
String label =
|
||||
e.getEntryType() == LedgerEntryType.REFUND
|
||||
? "Refund — " + categoryLabel
|
||||
: categoryLabel + " usage";
|
||||
int docUnits = e.getAmountUnits() == null ? 0 : Math.abs(e.getAmountUnits());
|
||||
out.add(
|
||||
new ActivityRow(
|
||||
e.getId(),
|
||||
kind,
|
||||
label,
|
||||
e.getOccurredAt() != null ? e.getOccurredAt().toString() : "",
|
||||
docUnits));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String categoryDisplayName(BillingCategory category) {
|
||||
return switch (category) {
|
||||
case API -> "API";
|
||||
case AI -> "AI";
|
||||
case AUTOMATION -> "Automation";
|
||||
case BYPASSED -> "Manual";
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// PATCH /cap — leader-only, cap is application-layer, no Stripe call
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@PatchMapping("/cap")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Transactional
|
||||
public ResponseEntity<Void> updateCap(
|
||||
@Valid @RequestBody UpdateCapRequest req, Authentication auth) {
|
||||
User user;
|
||||
try {
|
||||
user = AuthenticationUtils.getCurrentUser(auth, userRepository);
|
||||
} catch (SecurityException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||
}
|
||||
Optional<TeamMembership> primary = primaryMembership(user.getId());
|
||||
if (primary.isEmpty()) {
|
||||
// No team → can't have a wallet to cap.
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
TeamMembership membership = primary.get();
|
||||
if (membership.getRole() != TeamRole.LEADER) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
Long teamId = membership.getTeam().getId();
|
||||
|
||||
WalletPolicy policy =
|
||||
policyRepo
|
||||
.findByTeamId(teamId)
|
||||
.orElseGet(
|
||||
() -> {
|
||||
WalletPolicy created = new WalletPolicy();
|
||||
created.setTeamId(teamId);
|
||||
return created;
|
||||
});
|
||||
|
||||
if (req.noCap()) {
|
||||
policy.setCapUnits(null);
|
||||
policy.setCapSourceMoney(null);
|
||||
} else {
|
||||
long capMinor = CapMoneyUnits.usdToCents(req.capUsd());
|
||||
policy.setCapSourceMoney(capMinor);
|
||||
// Derived document allowance (design §10: store both the money intent and the unit
|
||||
// translation). The live snapshot recomputes from cap_source_money + current rate;
|
||||
// this stored value is the enforcement fallback when the rate is unreachable.
|
||||
TeamBillingContext billing = billingService.forTeam(teamId);
|
||||
Optional<Long> docCap = billingService.docCapForMoney(billing, capMinor);
|
||||
if (docCap.isPresent()) {
|
||||
policy.setCapUnits(docCap.get());
|
||||
} else {
|
||||
// Rate unknown (price-info fn unconfigured / Stripe blip): keep the legacy
|
||||
// money-as-units conversion so the cap still binds rather than silently lifting.
|
||||
log.warn(
|
||||
"Per-document rate unavailable for team {}; storing legacy cap_units"
|
||||
+ " conversion.",
|
||||
teamId);
|
||||
policy.setCapUnits(CapMoneyUnits.usdToUnits(req.capUsd()));
|
||||
}
|
||||
}
|
||||
policyRepo.save(policy);
|
||||
entitlementService.invalidate(teamId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** Request body for {@link #updateCap}. */
|
||||
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private Optional<TeamMembership> primaryMembership(Long userId) {
|
||||
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
|
||||
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0));
|
||||
}
|
||||
|
||||
private List<MemberRow> buildMemberRows(
|
||||
Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) {
|
||||
List<TeamMembership> all = memberRepo.findByTeamId(teamId);
|
||||
if (all.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
LocalDateTime[] window = {periodStart, periodEnd};
|
||||
List<MemberRow> out = new ArrayList<>(all.size());
|
||||
for (TeamMembership tm : all) {
|
||||
User u = tm.getUser();
|
||||
if (u == null) {
|
||||
continue;
|
||||
}
|
||||
// We could batch these; team sizes are small (FE design assumes ≤ ~20 members per
|
||||
// team on the Plan page) so a per-member sum is fine. If teams grow we'd switch to
|
||||
// a single GROUP BY actor_user_id query.
|
||||
long spend = 0L; // sumPeriodAmountForMember stores signed debits (negative); negate.
|
||||
try {
|
||||
spend = -memberSpend(teamId, u.getId(), window[0], window[1]);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn(
|
||||
"buildMemberRows: per-member spend lookup failed for user {}",
|
||||
u.getId(),
|
||||
e);
|
||||
}
|
||||
String displayName =
|
||||
Optional.ofNullable(u.getUsername())
|
||||
.orElse(Optional.ofNullable(u.getEmail()).orElse(""));
|
||||
out.add(
|
||||
new MemberRow(
|
||||
Long.toString(u.getId()),
|
||||
displayName,
|
||||
Optional.ofNullable(u.getEmail()).orElse(""),
|
||||
clampToInt(spend)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-member period spend in signed ledger units (debits are negative). Helper so the test
|
||||
* slice can override without standing up a real database, and so the controller doesn't inline
|
||||
* the negation arithmetic at every call site.
|
||||
*/
|
||||
long memberSpend(Long teamId, Long userId, LocalDateTime start, LocalDateTime end) {
|
||||
return ledgerRepo.sumPeriodAmountForMember(
|
||||
teamId, userId, LedgerEntryType.DEBIT, start, end);
|
||||
}
|
||||
|
||||
private static LocalDateTime[] currentMonthWindow() {
|
||||
java.time.YearMonth ym = java.time.YearMonth.now();
|
||||
LocalDateTime start = ym.atDay(1).atStartOfDay();
|
||||
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
|
||||
return new LocalDateTime[] {start, end};
|
||||
}
|
||||
|
||||
private static int clampToInt(long v) {
|
||||
if (v <= 0) return 0;
|
||||
if (v >= Integer.MAX_VALUE) return Integer.MAX_VALUE;
|
||||
return (int) v;
|
||||
}
|
||||
|
||||
private WalletSnapshotResponse emptySnapshot() {
|
||||
LocalDateTime[] window = currentMonthWindow();
|
||||
return new WalletSnapshotResponse(
|
||||
null, // teamId — unknown when the caller has no team membership
|
||||
STATUS_FREE,
|
||||
ROLE_MEMBER,
|
||||
ISO_DATE.format(window[0].toLocalDate()),
|
||||
ISO_DATE.format(window[1].toLocalDate()),
|
||||
0,
|
||||
FREE_TIER_LIMIT_UNITS_FALLBACK,
|
||||
FREE_TIER_LIMIT_UNITS_FALLBACK,
|
||||
FREE_TIER_LIMIT_UNITS_FALLBACK,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
0,
|
||||
new CategoryBreakdown(0, 0, 0),
|
||||
List.of(),
|
||||
Collections.emptyList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package stirling.software.saas.payg.api;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* JSON payload returned by {@code GET /api/v1/payg/wallet}. Mirrors the {@code Wallet} type the
|
||||
* frontend {@code useWallet} hook consumes, plus the leader-only fields ({@code members},
|
||||
* breakdowns, recent activity) used by the PAYG Plan page.
|
||||
*
|
||||
* <p>Every number is real: the billing window is the Stripe subscription's current period (via Sync
|
||||
* Engine) for subscribed teams, the one-time free grant size comes from {@code
|
||||
* pricing_policy.free_tier_units} (live balance from {@code
|
||||
* payg_team_extensions.free_units_remaining}), and the per-document rate comes from the
|
||||
* subscription's Stripe Price. Fields that can't be resolved are {@code null} and the FE renders
|
||||
* "unknown" — never a substituted default.
|
||||
*
|
||||
* @param teamId the caller's primary team_id. Needed by the frontend so it can pass it to the
|
||||
* Supabase edge functions that create Stripe Checkout / portal sessions — those run outside
|
||||
* Spring Security and have no other way to resolve the caller's team.
|
||||
* @param status {@code "free"} when the team has no Stripe subscription; {@code "subscribed"} once
|
||||
* a card is on file and the engine bills meter events.
|
||||
* @param role the current caller's role within their team — {@code "leader"} or {@code "member"}.
|
||||
* Controls which UI variant the frontend renders.
|
||||
* @param billingPeriodStart inclusive ISO date (yyyy-MM-dd) for the current cycle — the Stripe
|
||||
* subscription period when subscribed, the calendar month otherwise.
|
||||
* @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle.
|
||||
* @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE. For a free
|
||||
* team this is the lifetime free documents used so far ({@code freeAllowance − freeRemaining});
|
||||
* for a subscribed team it's this month's net billable documents.
|
||||
* @param billableLimit the team's document ceiling for the matching window: the one-time free grant
|
||||
* ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month for
|
||||
* capped subscribed teams; {@code null} when subscribed with no cap (uncapped).
|
||||
* @param freeAllowance the team's one-time free document grant size (the "N" in "X of N free").
|
||||
* Never resets; survives subscribing. Applies to billable categories only.
|
||||
* @param freeRemaining one-time free documents still available to the team ({@code
|
||||
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
||||
* @param pricePerDocMinor paid per-document rate in minor units of {@code currency} (may be
|
||||
* fractional — Stripe supports sub-cent rates); {@code null} when the rate can't be resolved.
|
||||
* @param currency lower-case ISO 4217 currency of the subscription's Stripe Price; {@code null}
|
||||
* when unknown (free teams, unresolved rate).
|
||||
* @param estimatedBillMinor estimated charges so far this period in minor units of {@code
|
||||
* currency}: paid (Stripe-metered) documents this period × {@code pricePerDocMinor}. The free
|
||||
* portion was already netted out at charge time. Informational — the Stripe invoice is
|
||||
* authoritative. {@code null} when the rate is unknown.
|
||||
* @param capUsd the leader's monthly spending cap in major currency units; {@code null} when free
|
||||
* or when the leader has opted into no-cap. (Field name predates multi-currency; the FE pairs
|
||||
* it with {@code currency} for the symbol.)
|
||||
* @param noCap {@code true} when the leader has explicitly disabled the cap. Only meaningful when
|
||||
* subscribed.
|
||||
* @param stripeSubscriptionId Stripe subscription id from {@code
|
||||
* payg_team_extensions.payg_subscription_id}; {@code null} when status is free.
|
||||
* @param spendUnitsThisPeriod documents debited this cycle across billable categories.
|
||||
* @param categoryBreakdown per-category spend slice over the same billing window.
|
||||
* @param members leader-only roster of team members + their per-member sub-caps. Empty for member
|
||||
* callers.
|
||||
* @param recent latest wallet-ledger entries (newest first) for the activity feed.
|
||||
*/
|
||||
public record WalletSnapshotResponse(
|
||||
Long teamId,
|
||||
String status,
|
||||
String role,
|
||||
String billingPeriodStart,
|
||||
String billingPeriodEnd,
|
||||
int billableUsed,
|
||||
Integer billableLimit,
|
||||
int freeAllowance,
|
||||
int freeRemaining,
|
||||
BigDecimal pricePerDocMinor,
|
||||
String currency,
|
||||
Long estimatedBillMinor,
|
||||
Integer capUsd,
|
||||
boolean noCap,
|
||||
String stripeSubscriptionId,
|
||||
int spendUnitsThisPeriod,
|
||||
CategoryBreakdown categoryBreakdown,
|
||||
List<MemberRow> members,
|
||||
List<ActivityRow> recent) {
|
||||
|
||||
/** Per-category breakdown of {@code spendUnitsThisPeriod} for the in-app analytics widget. */
|
||||
public record CategoryBreakdown(int api, int ai, int automation) {}
|
||||
|
||||
/**
|
||||
* One row of the team-members table on the leader's Plan page — display-only per-member usage.
|
||||
* (Per-member sub-caps aren't enforced yet; see the follow-ups note. When they ship, a cap
|
||||
* field returns here.)
|
||||
*/
|
||||
public record MemberRow(String userId, String name, String email, int spendUnits) {}
|
||||
|
||||
/**
|
||||
* One wallet-ledger entry shaped for the FE activity feed.
|
||||
*
|
||||
* @param id ledger entry id (stable React key)
|
||||
* @param kind lower-case billing category ({@code api} / {@code ai} / {@code automation}) or
|
||||
* {@code other} for system entries
|
||||
* @param label human line, e.g. {@code "API usage"} or {@code "Refund — API"}
|
||||
* @param ts ISO-8601 local timestamp of the entry
|
||||
* @param docUnits absolute document count of the entry
|
||||
*/
|
||||
public record ActivityRow(long id, String kind, String label, String ts, int docUnits) {}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package stirling.software.saas.payg.billing;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* One team's billing facts, composed by {@link TeamBillingService}. Two independent meters live
|
||||
* here and must not be conflated:
|
||||
*
|
||||
* <ul>
|
||||
* <li>the <b>one-time lifetime free grant</b> ({@link #freeGrantUnits} total, {@link
|
||||
* #freeRemainingUnits} left) — gates an un-subscribed team and decides the free-vs-paid split
|
||||
* of every job; never resets, survives subscribing;
|
||||
* <li>the <b>monthly billing window</b> ({@link #periodStart}/{@link #periodEnd}) and the
|
||||
* optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice
|
||||
* + cap only.
|
||||
* </ul>
|
||||
*
|
||||
* @param subscribed team has an active PAYG subscription (or a Stripe customer awaiting its
|
||||
* subscription-created webhook)
|
||||
* @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free
|
||||
* @param periodStart inclusive start of the monthly billing window — the Stripe subscription's
|
||||
* current period when subscribed, calendar month otherwise
|
||||
* @param periodEnd exclusive end of the monthly billing window
|
||||
* @param freeGrantUnits the team's one-time free grant size (policy {@code free_tier_units}); the
|
||||
* denominator for "used X of N free". Never resets.
|
||||
* @param freeRemainingUnits one-time free documents still available ({@code
|
||||
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
||||
* @param perDocMinor paid per-document rate in minor units of {@link #currency()}; null when the
|
||||
* rate can't be resolved (free team, price row unsynced) — display "unknown", never substitute
|
||||
* @param currency lower-case ISO 4217 of the subscription's Price; null when unknown
|
||||
* @param capMoneyMinor leader-set monthly spending cap in minor units ({@code
|
||||
* wallet_policy.cap_source_money}); null = no cap configured
|
||||
* @param monthlyCapDocUnits the subscribed monthly paid-document ceiling — {@code floor(capMoney /
|
||||
* perDocRate)}; null = uncapped, or the team is not subscribed
|
||||
*/
|
||||
public record TeamBillingContext(
|
||||
boolean subscribed,
|
||||
String subscriptionId,
|
||||
LocalDateTime periodStart,
|
||||
LocalDateTime periodEnd,
|
||||
long freeGrantUnits,
|
||||
long freeRemainingUnits,
|
||||
BigDecimal perDocMinor,
|
||||
String currency,
|
||||
Long capMoneyMinor,
|
||||
Long monthlyCapDocUnits) {}
|
||||
@@ -0,0 +1,267 @@
|
||||
package stirling.software.saas.payg.billing;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
||||
import stirling.software.saas.payg.stripe.StripeSubscriptionDao;
|
||||
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.PriceRate;
|
||||
import stirling.software.saas.payg.stripe.StripeSubscriptionDao.SubscriptionBilling;
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
|
||||
/**
|
||||
* Single composition point for "what does billing look like for this team right now." Both the
|
||||
* entitlement hot path and the wallet endpoint read from here, so what the customer sees is what
|
||||
* the guard enforces.
|
||||
*
|
||||
* <p>Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant):
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Free grant</b> — one-time, per team. Size from {@code pricing_policy.free_tier_units};
|
||||
* live balance from the {@code payg_team_extensions.free_units_remaining} counter (maintained
|
||||
* by the charge pipeline). Never resets, survives subscribing. Gates un-subscribed teams and
|
||||
* drives the free-vs-paid split.
|
||||
* <li><b>Monthly window + cap</b> — the Stripe subscription period (calendar month otherwise) and
|
||||
* the optional money cap. Govern the subscribed invoice + spending cap only. The per-document
|
||||
* rate is the synced {@code stripe.prices.unit_amount} (PAYG prices are plain per-unit).
|
||||
* </ul>
|
||||
*
|
||||
* <p>Cached per team for {@value #CACHE_TTL_SECONDS}s. {@code EntitlementService.invalidate}
|
||||
* cascades into {@link #invalidate(Long)} so both caches drop together on cap edits / webhooks.
|
||||
* Note the cached context's {@code freeRemainingUnits} is a 30s-stale read of the counter — the
|
||||
* authoritative decrement happens in {@code JobChargeService} against the row directly; this cache
|
||||
* is for display + the entitlement gate, where 30s staleness is the accepted cap-evaluation floor.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class TeamBillingService {
|
||||
|
||||
static final int CACHE_TTL_SECONDS = 30;
|
||||
private static final int CACHE_MAX_SIZE = 10_000;
|
||||
|
||||
/**
|
||||
* In-app display/estimate currency. The app prices in dollars; Stripe handles real currency
|
||||
* selection at checkout. Used to pick the right Price for un-subscribed teams.
|
||||
*/
|
||||
private static final String DISPLAY_CURRENCY = "usd";
|
||||
|
||||
/**
|
||||
* Stripe Price {@code lookup_key} for the PAYG per-document price. The stable handle we resolve
|
||||
* an un-subscribed team's rate from (the default policy carries no price ids in the seed).
|
||||
*/
|
||||
private static final String PAYG_LOOKUP_KEY = "plan:processor";
|
||||
|
||||
private final PaygTeamExtensionsRepository extensionsRepository;
|
||||
private final WalletPolicyRepository walletPolicyRepository;
|
||||
private final PricingPolicyService pricingPolicyService;
|
||||
private final StripeSubscriptionDao subscriptionDao;
|
||||
|
||||
private final Cache<Long, TeamBillingContext> cache;
|
||||
|
||||
public TeamBillingService(
|
||||
PaygTeamExtensionsRepository extensionsRepository,
|
||||
WalletPolicyRepository walletPolicyRepository,
|
||||
PricingPolicyService pricingPolicyService,
|
||||
StripeSubscriptionDao subscriptionDao) {
|
||||
this.extensionsRepository =
|
||||
Objects.requireNonNull(extensionsRepository, "extensionsRepository");
|
||||
this.walletPolicyRepository =
|
||||
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
|
||||
this.pricingPolicyService =
|
||||
Objects.requireNonNull(pricingPolicyService, "pricingPolicyService");
|
||||
this.subscriptionDao = Objects.requireNonNull(subscriptionDao, "subscriptionDao");
|
||||
this.cache =
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(CACHE_MAX_SIZE)
|
||||
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
|
||||
.build();
|
||||
}
|
||||
|
||||
public TeamBillingContext forTeam(Long teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
return cache.get(teamId, this::compute);
|
||||
}
|
||||
|
||||
/** Drop {@code teamId}'s entry after cap edits / subscription webhooks / grant consumption. */
|
||||
public void invalidate(Long teamId) {
|
||||
if (teamId != null) {
|
||||
cache.invalidate(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
private TeamBillingContext compute(Long teamId) {
|
||||
Optional<PaygTeamExtensions> extOpt = extensionsRepository.findById(teamId);
|
||||
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
|
||||
|
||||
String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null);
|
||||
// payg_subscription_id is the designed switch; stripe_customer_id presence is the
|
||||
// pre-webhook stand-in kept so a team whose checkout completed but whose
|
||||
// subscription-created webhook hasn't landed yet still renders as subscribed.
|
||||
boolean subscribed =
|
||||
subscriptionId != null
|
||||
|| extOpt.map(PaygTeamExtensions::getStripeCustomerId)
|
||||
.filter(s -> !s.isBlank())
|
||||
.isPresent();
|
||||
|
||||
long freeGrant = resolveGrant(teamId);
|
||||
long freeRemaining =
|
||||
extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining)
|
||||
.map(Long::longValue)
|
||||
.orElse(0L);
|
||||
|
||||
Optional<SubscriptionBilling> billing =
|
||||
subscriptionId != null
|
||||
? subscriptionDao.findBilling(subscriptionId)
|
||||
: Optional.empty();
|
||||
|
||||
LocalDateTime[] window =
|
||||
billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()})
|
||||
.orElseGet(TeamBillingService::calendarMonthWindow);
|
||||
|
||||
BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null);
|
||||
String currency = billing.map(SubscriptionBilling::currency).orElse(null);
|
||||
|
||||
// Un-subscribed teams have no Stripe subscription to read a rate from, but the cap
|
||||
// estimate (the upgrade flow's "≈ N paid PDFs/month") still needs one. Resolve it from
|
||||
// the default policy's USD Price — Stripe hasn't assigned the team a currency yet, and
|
||||
// the whole app prices in dollars. Display-only: resolveMonthlyCap stays gated on
|
||||
// `subscribed`, so this never starts enforcing a cap on a free team.
|
||||
if (!subscribed && perDocMinor == null) {
|
||||
Optional<PriceRate> rate =
|
||||
subscriptionDao.findRateByLookupKey(PAYG_LOOKUP_KEY, DISPLAY_CURRENCY);
|
||||
if (rate.isPresent()) {
|
||||
perDocMinor = rate.get().perDocMinor();
|
||||
currency = rate.get().currency();
|
||||
}
|
||||
}
|
||||
|
||||
Long capMoneyMinor = walletPolicyOpt.map(WalletPolicy::getCapSourceMoney).orElse(null);
|
||||
Long legacyCapUnits = walletPolicyOpt.map(WalletPolicy::getCapUnits).orElse(null);
|
||||
|
||||
Long monthlyCapDocUnits =
|
||||
resolveMonthlyCap(subscribed, capMoneyMinor, legacyCapUnits, perDocMinor);
|
||||
|
||||
return new TeamBillingContext(
|
||||
subscribed,
|
||||
subscriptionId,
|
||||
window[0],
|
||||
window[1],
|
||||
freeGrant,
|
||||
freeRemaining,
|
||||
perDocMinor,
|
||||
currency,
|
||||
capMoneyMinor,
|
||||
monthlyCapDocUnits);
|
||||
}
|
||||
|
||||
/** The policy grant size — the "N" denominator for display; the counter is the live balance. */
|
||||
private long resolveGrant(Long teamId) {
|
||||
try {
|
||||
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
|
||||
Long grant = policy.getFreeTierUnits();
|
||||
return grant == null ? 0L : grant;
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("No effective pricing policy for team {}: {}", teamId, e.getMessage());
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The subscribed monthly paid-document ceiling; {@code null} = uncapped or not subscribed. The
|
||||
* one-time free grant is NOT added here — it's a separate lifetime pool consumed at charge
|
||||
* time. The cap purely limits how many paid documents the team will fund per billing period.
|
||||
*
|
||||
* <ul>
|
||||
* <li>not subscribed → null (the free grant, not a money cap, is what bounds them);
|
||||
* <li>subscribed, no money cap → uncapped (null), unless an admin set raw {@code cap_units};
|
||||
* <li>subscribed, money cap + known rate → {@code floor(capMoney / perDocRate)};
|
||||
* <li>subscribed, money cap but rate unknown → stored {@code cap_units} fallback (WARN).
|
||||
* </ul>
|
||||
*/
|
||||
private Long resolveMonthlyCap(
|
||||
boolean subscribed, Long capMoneyMinor, Long legacyCapUnits, BigDecimal perDocMinor) {
|
||||
if (!subscribed) {
|
||||
return null;
|
||||
}
|
||||
if (capMoneyMinor == null) {
|
||||
return legacyCapUnits; // admin-set unit cap (source money null) still applies
|
||||
}
|
||||
if (perDocMinor != null && perDocMinor.signum() > 0) {
|
||||
return BigDecimal.valueOf(capMoneyMinor)
|
||||
.divide(perDocMinor, 0, RoundingMode.FLOOR)
|
||||
.longValue();
|
||||
}
|
||||
log.warn(
|
||||
"Per-document rate unavailable; enforcing stored cap_units fallback ({}).",
|
||||
legacyCapUnits);
|
||||
return legacyCapUnits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated charges for the current period in minor units of {@link
|
||||
* TeamBillingContext#currency()}: the paid (metered) documents this period at the per-document
|
||||
* rate. Informational — the Stripe invoice is authoritative. Empty when the rate is unknown.
|
||||
*
|
||||
* @param paidUnitsThisPeriod metered documents this period ({@code payg_units −
|
||||
* free_units_consumed} summed over the period's charged jobs)
|
||||
*/
|
||||
public Optional<Long> estimateBillMinor(TeamBillingContext ctx, long paidUnitsThisPeriod) {
|
||||
if (ctx.perDocMinor() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
long paid = Math.max(0, paidUnitsThisPeriod);
|
||||
BigDecimal bill =
|
||||
ctx.perDocMinor()
|
||||
.multiply(BigDecimal.valueOf(paid))
|
||||
.setScale(0, RoundingMode.HALF_UP);
|
||||
return Optional.of(bill.longValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Documents a hypothetical monthly money cap would buy: {@code floor(capMinor / rate)}. Used by
|
||||
* the cap editor's live preview and the {@code PATCH /cap} derived write. The free grant is NOT
|
||||
* added — it's a separate one-time pool. Empty when the rate is unknown.
|
||||
*/
|
||||
public Optional<Long> docCapForMoney(TeamBillingContext ctx, long capMinor) {
|
||||
if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(
|
||||
BigDecimal.valueOf(capMinor)
|
||||
.divide(ctx.perDocMinor(), 0, RoundingMode.FLOOR)
|
||||
.longValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
|
||||
* used when there's no Stripe subscription period to anchor on.
|
||||
*/
|
||||
static LocalDateTime[] calendarMonthWindow() {
|
||||
return calendarMonthWindow(LocalDateTime.now());
|
||||
}
|
||||
|
||||
/** Test seam — accepts a clock value so tests don't race the calendar boundary. */
|
||||
static LocalDateTime[] calendarMonthWindow(LocalDateTime now) {
|
||||
java.time.YearMonth ym = java.time.YearMonth.from(now);
|
||||
return new LocalDateTime[] {
|
||||
ym.atDay(1).atStartOfDay(), ym.plusMonths(1).atDay(1).atStartOfDay()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package stirling.software.saas.payg.cap;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
|
||||
/**
|
||||
* Pure-compute cap evaluation. Given a team's (or member's) spend, cap, and warn / degrade
|
||||
* thresholds, returns the {@link EntitlementState}, the {@link FeatureSet} that should be in
|
||||
* effect, and the corresponding enabled {@link FeatureGate}s. No DB access, no caches — the caller
|
||||
* (entitlement service) supplies the inputs.
|
||||
*
|
||||
* <p>State transitions (matching {@code notes/PAYG_DESIGN.md} §3.6):
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally.
|
||||
* <li>{@code spend / cap < 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Default enabled gates for a given feature set. Kept in sync with the design doc §3.7 mapping
|
||||
* table.
|
||||
*/
|
||||
public static List<FeatureGate> gatesFor(FeatureSet set) {
|
||||
if (set == null) {
|
||||
return List.of();
|
||||
}
|
||||
return switch (set) {
|
||||
case FULL ->
|
||||
List.of(
|
||||
FeatureGate.OFFSITE_PROCESSING,
|
||||
FeatureGate.AUTOMATION,
|
||||
FeatureGate.AI_SUPPORT,
|
||||
FeatureGate.CLIENT_SIDE);
|
||||
case MINIMAL -> List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
|
||||
case CLIENT_ONLY -> List.of(FeatureGate.CLIENT_SIDE);
|
||||
};
|
||||
}
|
||||
|
||||
private static Evaluation full() {
|
||||
return new Evaluation(EntitlementState.FULL, FeatureSet.FULL, gatesFor(FeatureSet.FULL));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package stirling.software.saas.payg.cap;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
|
||||
/**
|
||||
* Declares which {@link FeatureGate}(s) a controller method requires. Read at request time by
|
||||
* {@code EntitlementGuard}; if any required gate is not in the team's currently-enabled gates the
|
||||
* request is rejected with HTTP 402.
|
||||
*
|
||||
* <p>The annotation is <em>not required</em> on every endpoint. The guard's default rule is:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code @RequiresFeature} present → use exactly those gates.
|
||||
* <li>No annotation, but the method has {@code @AutoJobPostMapping} → assume {@link
|
||||
* FeatureGate#OFFSITE_PROCESSING}.
|
||||
* <li>Neither → skip (admin endpoints, info, config — these don't accrue charges and shouldn't
|
||||
* degrade).
|
||||
* </ul>
|
||||
*
|
||||
* <p>So the only endpoints that need this annotation explicitly are those whose gate is
|
||||
* <em>different</em> from the default {@code OFFSITE_PROCESSING} — chiefly {@code
|
||||
* PipelineController} ({@link FeatureGate#AUTOMATION}) and the AI proxy layer ({@link
|
||||
* FeatureGate#AI_SUPPORT}). Per-tool proliferation of the annotation is intentional non-goal.
|
||||
*
|
||||
* <p>Multiple gates declared = ALL must be enabled (AND, not OR). Realistic usage is single-gate;
|
||||
* the array form is here for future combinations (e.g. an AI workflow inside a pipeline that needs
|
||||
* both {@code AUTOMATION} and {@code AI_SUPPORT}).
|
||||
*
|
||||
* <pre>{@code
|
||||
* @RequiresFeature(FeatureGate.AUTOMATION)
|
||||
* @AutoJobPostMapping("/pipeline")
|
||||
* public ResponseEntity<...> runPipeline(@ModelAttribute PipelineRequest req) { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface RequiresFeature {
|
||||
|
||||
/** One or more gates that must all be enabled for the request to proceed. */
|
||||
FeatureGate[] value();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.saas.payg.charge;
|
||||
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.ProcessType;
|
||||
|
||||
@@ -8,9 +9,18 @@ import stirling.software.saas.payg.model.ProcessType;
|
||||
* kind of process this is. Does NOT carry policy fields — the charge service resolves the effective
|
||||
* policy from {@code PricingPolicyService} so a stale snapshot from the caller can't desync from
|
||||
* the live policy.
|
||||
*
|
||||
* <p>{@code billingCategory} is the analytics axis for ledger + shadow rows and is determined by
|
||||
* the interceptor before this context is built. Manual UI tools never reach {@code openProcess}
|
||||
* (they short-circuit on {@link BillingCategory#BYPASSED}); any context constructed here therefore
|
||||
* carries one of {@code API}, {@code AI}, or {@code AUTOMATION}.
|
||||
*/
|
||||
public record ChargeContext(
|
||||
Long ownerUserId, Long ownerTeamId, JobSource source, ProcessType processType) {
|
||||
Long ownerUserId,
|
||||
Long ownerTeamId,
|
||||
JobSource source,
|
||||
ProcessType processType,
|
||||
BillingCategory billingCategory) {
|
||||
|
||||
public ChargeContext {
|
||||
if (ownerUserId == null) {
|
||||
@@ -22,5 +32,8 @@ public record ChargeContext(
|
||||
if (processType == null) {
|
||||
throw new IllegalArgumentException("processType is required");
|
||||
}
|
||||
if (billingCategory == null) {
|
||||
throw new IllegalArgumentException("billingCategory is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import java.util.UUID;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -21,14 +23,23 @@ import stirling.software.saas.payg.job.JobContext;
|
||||
import stirling.software.saas.payg.job.JobService;
|
||||
import stirling.software.saas.payg.job.JoinOrOpenResult;
|
||||
import stirling.software.saas.payg.job.ProcessingJob;
|
||||
import stirling.software.saas.payg.meter.PaygMeterReportingService;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
import stirling.software.saas.payg.model.LedgerBucket;
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.model.ReferenceType;
|
||||
import stirling.software.saas.payg.model.ShadowChargeStatus;
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.ProcessingJobRepository;
|
||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
||||
import stirling.software.saas.payg.shadow.PaygShadowCharge;
|
||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
|
||||
/**
|
||||
* Orchestrates a tool call's open-process decision: look up the team's effective policy, resolve
|
||||
@@ -55,18 +66,29 @@ public class JobChargeService {
|
||||
private final DocumentClassifier classifier;
|
||||
private final PaygShadowChargeRepository shadowRepository;
|
||||
private final ProcessingJobRepository jobRepository;
|
||||
private final PaygTeamExtensionsRepository teamExtensionsRepository;
|
||||
private final PaygMeterReportingService meterReportingService;
|
||||
private final WalletLedgerRepository ledgerRepository;
|
||||
|
||||
public JobChargeService(
|
||||
JobService jobService,
|
||||
PricingPolicyService policyService,
|
||||
DocumentClassifier classifier,
|
||||
PaygShadowChargeRepository shadowRepository,
|
||||
ProcessingJobRepository jobRepository) {
|
||||
ProcessingJobRepository jobRepository,
|
||||
PaygTeamExtensionsRepository teamExtensionsRepository,
|
||||
PaygMeterReportingService meterReportingService,
|
||||
WalletLedgerRepository ledgerRepository) {
|
||||
this.jobService = Objects.requireNonNull(jobService, "jobService");
|
||||
this.policyService = Objects.requireNonNull(policyService, "policyService");
|
||||
this.classifier = Objects.requireNonNull(classifier, "classifier");
|
||||
this.shadowRepository = Objects.requireNonNull(shadowRepository, "shadowRepository");
|
||||
this.jobRepository = Objects.requireNonNull(jobRepository, "jobRepository");
|
||||
this.teamExtensionsRepository =
|
||||
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
|
||||
this.meterReportingService =
|
||||
Objects.requireNonNull(meterReportingService, "meterReportingService");
|
||||
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,11 +126,70 @@ public class JobChargeService {
|
||||
int units = computeUnits(inputs, policy);
|
||||
result.job().setDocUnits(units);
|
||||
|
||||
recordShadowRow(ctx, result.job().getId(), policy.getId(), units);
|
||||
int freeUsed = consumeFreeGrant(ctx, units);
|
||||
recordShadowRow(ctx, result.job().getId(), policy.getId(), units, freeUsed);
|
||||
recordLedgerDebit(ctx, result.job().getId(), policy.getId(), units);
|
||||
|
||||
return new ChargeOutcome(result.job().getId(), units, ChargeOutcome.Disposition.OPENED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw this job's free portion from the team's one-time lifetime grant, atomically, and return
|
||||
* the units taken (0..{@code units}); the remainder is the paid portion that will be metered to
|
||||
* Stripe. Runs inside {@code openProcess}'s transaction with a pessimistic row lock so
|
||||
* concurrent same-team charges split the grant exactly — no two jobs can both claim the last
|
||||
* free unit. The grant is a soft floor: it never goes below 0, and the single job that crosses
|
||||
* the boundary takes whatever's left (its remaining units bill). Skipped for non-billable /
|
||||
* team-less calls (BYPASSED never reaches openProcess; guarded defensively).
|
||||
*/
|
||||
private int consumeFreeGrant(ChargeContext ctx, int units) {
|
||||
BillingCategory category = ctx.billingCategory();
|
||||
if (category == null || category == BillingCategory.BYPASSED || ctx.ownerTeamId() == null) {
|
||||
return 0;
|
||||
}
|
||||
Optional<PaygTeamExtensions> extOpt =
|
||||
teamExtensionsRepository.findByIdForUpdate(ctx.ownerTeamId());
|
||||
if (extOpt.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
PaygTeamExtensions ext = extOpt.get();
|
||||
long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining();
|
||||
int freeUsed = (int) Math.min(units, Math.max(0L, remaining));
|
||||
if (freeUsed > 0) {
|
||||
ext.setFreeUnitsRemaining(remaining - freeUsed);
|
||||
teamExtensionsRepository.save(ext);
|
||||
}
|
||||
return freeUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The live spend record. Everything the customer-facing side reads — the wallet endpoint's
|
||||
* {@code spendUnitsThisPeriod}, the per-category breakdown ({@code wallet_category_summary}
|
||||
* view), and the cap evaluator's period sum — derives from {@code wallet_ledger} DEBITs. Shadow
|
||||
* rows are the comparison audit trail; this row is what actually counts.
|
||||
*
|
||||
* <p>Sign convention: debits are stored NEGATIVE (the entitlement snapshot negates the sum).
|
||||
* Skipped for {@code BYPASSED} / uncategorised calls — manual UI work is never billed.
|
||||
*/
|
||||
private void recordLedgerDebit(
|
||||
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
|
||||
BillingCategory category = ctx.billingCategory();
|
||||
if (category == null || category == BillingCategory.BYPASSED) {
|
||||
return;
|
||||
}
|
||||
WalletLedgerEntry entry = new WalletLedgerEntry();
|
||||
entry.setTeamId(ctx.ownerTeamId());
|
||||
entry.setActorUserId(ctx.ownerUserId());
|
||||
entry.setEntryType(LedgerEntryType.DEBIT);
|
||||
entry.setBucket(LedgerBucket.CYCLE);
|
||||
entry.setAmountUnits(-units);
|
||||
entry.setReferenceType(ReferenceType.JOB);
|
||||
entry.setReferenceId(jobId.toString());
|
||||
entry.setPolicyId(policyId);
|
||||
entry.setBillingCategory(category);
|
||||
ledgerRepository.save(entry);
|
||||
}
|
||||
|
||||
private int resolveStepLimit(PricingPolicy policy, JobSource source) {
|
||||
Integer fromPolicy =
|
||||
policy.getStepLimits() == null ? null : policy.getStepLimits().get(source);
|
||||
@@ -142,17 +223,28 @@ public class JobChargeService {
|
||||
}
|
||||
|
||||
private void recordShadowRow(
|
||||
ChargeContext ctx, java.util.UUID jobId, Long policyId, int units) {
|
||||
ChargeContext ctx,
|
||||
java.util.UUID jobId,
|
||||
Long policyId,
|
||||
int units,
|
||||
int freeUnitsConsumed) {
|
||||
PaygShadowCharge row = new PaygShadowCharge();
|
||||
row.setTeamId(ctx.ownerTeamId());
|
||||
row.setJobId(jobId);
|
||||
row.setPolicyId(policyId);
|
||||
row.setPaygUnits(units);
|
||||
// Free-vs-paid split fixed at charge time: paid (metered) = paygUnits - freeUnitsConsumed,
|
||||
// and a refund restores freeUnitsConsumed to the team's grant.
|
||||
row.setFreeUnitsConsumed(freeUnitsConsumed);
|
||||
// No legacy comparison yet — wired when the shadow path is connected to the legacy
|
||||
// CreditService in the follow-up PR. Until then, diff stays at 0.
|
||||
row.setLegacyCreditsCharged(0);
|
||||
row.setDiffPct(0);
|
||||
row.setStatus(ShadowChargeStatus.CHARGED);
|
||||
// PAYG analytics axis + caller surface — copied from ctx so the row stays self-describing
|
||||
// after processing_job is pruned. Never affects what Stripe meters (single flat meter).
|
||||
row.setBillingCategory(ctx.billingCategory());
|
||||
row.setJobSource(ctx.source());
|
||||
shadowRepository.save(row);
|
||||
}
|
||||
|
||||
@@ -182,6 +274,31 @@ public class JobChargeService {
|
||||
row.setRefundedAt(now);
|
||||
row.setRefundReason(trimReason(refundReason));
|
||||
shadowRepository.save(row);
|
||||
// Compensate the live ledger DEBIT written at openProcess so the period spend
|
||||
// nets to zero for the failed work. Positive amount mirrors the negative debit;
|
||||
// same JOB reference ties the pair together. The idempotency guard above (only
|
||||
// on the CHARGED→REFUNDED transition) prevents double-credits on re-invocation.
|
||||
BillingCategory category = row.getBillingCategory();
|
||||
if (category != null && category != BillingCategory.BYPASSED) {
|
||||
WalletLedgerEntry refund = new WalletLedgerEntry();
|
||||
refund.setTeamId(row.getTeamId());
|
||||
refund.setEntryType(LedgerEntryType.REFUND);
|
||||
refund.setBucket(LedgerBucket.CYCLE);
|
||||
refund.setAmountUnits(row.getPaygUnits());
|
||||
refund.setReferenceType(ReferenceType.JOB);
|
||||
refund.setReferenceId(jobId.toString());
|
||||
refund.setPolicyId(row.getPolicyId());
|
||||
refund.setBillingCategory(category);
|
||||
ledgerRepository.save(refund);
|
||||
// Hand back the free units this job consumed (first-step failures are
|
||||
// pre-meter, so nothing was billed to Stripe — only the grant moved). Exactly
|
||||
// what was taken at charge time, so the counter can't drift above the grant.
|
||||
int freeConsumed =
|
||||
row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
|
||||
if (freeConsumed > 0 && row.getTeamId() != null) {
|
||||
teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +314,142 @@ public class JobChargeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes a process and — as a fallback — meters its usage. The primary meter trigger is the
|
||||
* charge interceptor's {@code afterCompletion} on a successful request (see {@link
|
||||
* #meterJobUsage(UUID)}); this close-time meter exists to catch processes that were never
|
||||
* cleanly completed (request thread died before {@code afterCompletion}) and are swept up later
|
||||
* by {@code StaleJobCloser}. The deterministic idempotency key means a job already metered at
|
||||
* completion is deduped here at Stripe, so the two paths never double-bill.
|
||||
*
|
||||
* <p>Idempotent w.r.t. process state (delegates to {@link JobService#close(UUID)}, which
|
||||
* silently no-ops on an already-closed row). The meter POST runs in an {@code afterCommit} hook
|
||||
* so a failed POST does not roll back the close; the reconciliation backfill (separate chunk)
|
||||
* is the durability mechanism.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcessingJob close(UUID jobId) {
|
||||
Objects.requireNonNull(jobId, "jobId");
|
||||
ProcessingJob closed = jobService.close(jobId);
|
||||
|
||||
// The afterCommit hook only fires if there's an active transaction (Spring's
|
||||
// @Transactional ensures that). If we're called outside one — e.g. a test using the raw
|
||||
// bean — fall through with a debug log: the close() above already happened in a
|
||||
// sub-transaction created by JobService, but the surrounding scope has no synchronization.
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
log.debug("close({}): no active synchronization; skipping meter POST", jobId);
|
||||
return closed;
|
||||
}
|
||||
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
try {
|
||||
meterJobUsage(jobId);
|
||||
} catch (RuntimeException e) {
|
||||
// PaygMeterReportingService should already swallow; defence in depth so
|
||||
// a thrown exception out of afterCommit doesn't leak past the
|
||||
// synchronization boundary and bubble into the caller.
|
||||
log.warn(
|
||||
"afterCommit meter post for job {} threw unexpectedly: {}",
|
||||
jobId,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post this job's billable usage to Stripe. The primary caller is the charge interceptor's
|
||||
* {@code afterCompletion} on a successful OPENED request — i.e. the moment the work finishes —
|
||||
* so the meter moves promptly. {@link #close(UUID)} also calls this from its {@code
|
||||
* afterCommit} hook as the fallback for processes that were never cleanly completed (e.g. the
|
||||
* request thread died); the deterministic idempotency key ({@code process:<id>:close}) makes
|
||||
* the two paths dedup at Stripe, so a job metered at completion isn't billed again when it's
|
||||
* later stale-closed.
|
||||
*
|
||||
* <p>Safe to call outside a transaction: it only reads (the job's openProcess DEBIT is already
|
||||
* committed by the time either caller runs) and the POST is best-effort. Never throws — see
|
||||
* {@link PaygMeterReportingService}.
|
||||
*
|
||||
* <p>Skips: no shadow row (not PAYG-tracked), REFUNDED row (first-step failure — never billed),
|
||||
* BYPASSED/uncategorised, zero units, free-tier team (no Stripe customer), or usage still
|
||||
* within the app-side free allowance.
|
||||
*/
|
||||
public void meterJobUsage(UUID jobId) {
|
||||
Optional<PaygShadowCharge> rowOpt = shadowRepository.findFirstByJobIdOrderByIdAsc(jobId);
|
||||
if (rowOpt.isEmpty()) {
|
||||
// No shadow row → not a PAYG-tracked job; nothing to meter.
|
||||
return;
|
||||
}
|
||||
PaygShadowCharge row = rowOpt.get();
|
||||
if (row.getStatus() == ShadowChargeStatus.REFUNDED) {
|
||||
// Refunded rows are zero-net charges; do not emit a meter event.
|
||||
return;
|
||||
}
|
||||
BillingCategory category = row.getBillingCategory();
|
||||
if (category == null || category == BillingCategory.BYPASSED) {
|
||||
// Defensive: BYPASSED rows shouldn't exist (interceptor short-circuits before
|
||||
// openProcess), but tolerate if a future caller writes one.
|
||||
log.debug("close({}): shadow row category={} → no meter event", jobId, category);
|
||||
return;
|
||||
}
|
||||
Integer units = row.getPaygUnits();
|
||||
if (units == null || units <= 0) {
|
||||
return;
|
||||
}
|
||||
Long teamId = row.getTeamId();
|
||||
if (teamId == null) {
|
||||
return;
|
||||
}
|
||||
PaygTeamExtensions ext = teamExtensionsRepository.findById(teamId).orElse(null);
|
||||
if (ext == null) {
|
||||
return;
|
||||
}
|
||||
// payg_subscription_id is the single switch that says "this team is billed" (see
|
||||
// PaygTeamExtensions). Gate on it directly now that V14 ships the column: a team with a
|
||||
// Stripe customer but no live subscription — e.g. the brief window after checkout but
|
||||
// before the subscription-created webhook lands — must not post meter events against a
|
||||
// subscription that doesn't exist. A job finishing in that window is still metered later
|
||||
// via the stale-close fallback, once the subscription has landed (same idempotency key).
|
||||
String subscriptionId = ext.getPaygSubscriptionId();
|
||||
if (subscriptionId == null || subscriptionId.isBlank()) {
|
||||
log.debug(
|
||||
"close({}): team {} has no active subscription → no meter event",
|
||||
jobId,
|
||||
teamId);
|
||||
return;
|
||||
}
|
||||
String stripeCustomerId = ext.getStripeCustomerId();
|
||||
if (stripeCustomerId == null || stripeCustomerId.isBlank()) {
|
||||
// Subscribed but no customer id is a data inconsistency — we can't address the event.
|
||||
log.warn(
|
||||
"close({}): team {} has a subscription but no stripeCustomerId → cannot meter",
|
||||
jobId,
|
||||
teamId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Paid portion = units beyond the team's one-time free grant, fixed at charge time. The
|
||||
// free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the
|
||||
// free units were already withheld when this row's free_units_consumed was set.
|
||||
int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
|
||||
int paidUnits = units - freeConsumed;
|
||||
if (paidUnits <= 0) {
|
||||
log.debug(
|
||||
"close({}): all {} units came from the free grant → no meter event",
|
||||
jobId,
|
||||
units);
|
||||
return;
|
||||
}
|
||||
String idempotencyKey = "process:" + jobId + ":close";
|
||||
meterReportingService.recordUsage(
|
||||
teamId, stripeCustomerId, paidUnits, category, idempotencyKey, jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-chain 5xx on a JOINED step: return the step slot. The {@code lastStepAt} timestamp stays
|
||||
* advanced (workflow window intentionally remains active for the next retry). No shadow-row
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
package stirling.software.saas.payg.entitlement;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.util.AuthenticationUtils;
|
||||
|
||||
/**
|
||||
* Hot-path entitlement check. Runs after {@code PaygChargeInterceptor} in the MVC chain and short-
|
||||
* circuits the request before any handler work happens when the team's snapshot is missing one of
|
||||
* the gates the route declared via {@link RequiresFeature}.
|
||||
*
|
||||
* <p>Scope: routes whose handler method (or bean type) carries either {@link AutoJobPostMapping}
|
||||
* (multipart tool POSTs) or {@link RequiresFeature} (AI controllers, future non-multipart gated
|
||||
* routes). Admin / info / config endpoints are excluded by the path-pattern in {@code
|
||||
* PaygWebMvcConfig} and are additionally skipped here when they carry neither annotation, so non-
|
||||
* billable infra never trips the guard.
|
||||
*
|
||||
* <p>Decision matrix:
|
||||
*
|
||||
* <table>
|
||||
* <tr><th>auth</th><th>required gates</th><th>snapshot enabled?</th><th>outcome</th></tr>
|
||||
* <tr><td>anonymous</td><td>AUTOMATION or AI_SUPPORT</td><td>n/a</td><td>401 SIGNUP_REQUIRED</td></tr>
|
||||
* <tr><td>anonymous</td><td>OFFSITE_PROCESSING / CLIENT_SIDE</td><td>n/a</td><td>200 (pass through)</td></tr>
|
||||
* <tr><td>authenticated</td><td>required ⊆ enabled</td><td>yes</td><td>200</td></tr>
|
||||
* <tr><td>authenticated</td><td>required ⊄ enabled</td><td>no</td><td>402 FEATURE_DEGRADED</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>Fail-open: any unexpected exception is logged at WARN and the request passes through. The cap
|
||||
* pipeline must never block a customer because the guard tripped on a transient DB error.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Profile("saas")
|
||||
public class EntitlementGuard implements HandlerInterceptor {
|
||||
|
||||
private static final FeatureGate[] DEFAULT_REQUIRED_GATES = {FeatureGate.OFFSITE_PROCESSING};
|
||||
|
||||
private final EntitlementService entitlementService;
|
||||
private final UserRepository userRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final Counter passCounter;
|
||||
private final Counter deniedDegradedCounter;
|
||||
private final Counter deniedPaygLimitCounter;
|
||||
private final Counter deniedSignupRequiredCounter;
|
||||
private final Counter errorsCounter;
|
||||
private final Counter skippedNoAnnotationCounter;
|
||||
|
||||
public EntitlementGuard(
|
||||
EntitlementService entitlementService,
|
||||
UserRepository userRepository,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.entitlementService = entitlementService;
|
||||
this.userRepository = userRepository;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
|
||||
this.passCounter =
|
||||
Counter.builder("payg.entitlement.guard")
|
||||
.tag("outcome", "pass")
|
||||
.register(meterRegistry);
|
||||
this.deniedDegradedCounter =
|
||||
Counter.builder("payg.entitlement.guard")
|
||||
.tag("outcome", "denied_degraded")
|
||||
.register(meterRegistry);
|
||||
this.deniedPaygLimitCounter =
|
||||
Counter.builder("payg.entitlement.guard")
|
||||
.tag("outcome", "denied_payg_limit")
|
||||
.register(meterRegistry);
|
||||
this.deniedSignupRequiredCounter =
|
||||
Counter.builder("payg.entitlement.guard")
|
||||
.tag("outcome", "denied_signup_required")
|
||||
.register(meterRegistry);
|
||||
this.skippedNoAnnotationCounter =
|
||||
Counter.builder("payg.entitlement.guard")
|
||||
.tag("outcome", "skipped")
|
||||
.register(meterRegistry);
|
||||
this.errorsCounter =
|
||||
Counter.builder("payg.entitlement.guard.errors")
|
||||
.description("EntitlementGuard internal failures (fail-open)")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(
|
||||
HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
if (!(handler instanceof HandlerMethod hm)) {
|
||||
return true;
|
||||
}
|
||||
// Scope: AutoJobPostMapping routes (multipart tool POSTs) OR routes that explicitly
|
||||
// declare @RequiresFeature (e.g. AI controllers — JSON-bodied, no AutoJobPostMapping).
|
||||
// Admin / info / config endpoints carry neither annotation and never trip the guard.
|
||||
boolean hasAutoJobPostMapping =
|
||||
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|
||||
|| AnnotationUtils.findAnnotation(
|
||||
hm.getBeanType(), AutoJobPostMapping.class)
|
||||
!= null;
|
||||
boolean hasRequiresFeature =
|
||||
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|
||||
|| AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class)
|
||||
!= null;
|
||||
if (!hasAutoJobPostMapping && !hasRequiresFeature) {
|
||||
skippedNoAnnotationCounter.increment();
|
||||
return true;
|
||||
}
|
||||
|
||||
FeatureGate[] required = resolveRequiredGates(hm);
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
|
||||
boolean anonymous = isAnonymous(auth);
|
||||
boolean billable = isBillable(required);
|
||||
|
||||
if (anonymous) {
|
||||
if (billable) {
|
||||
return write401SignupRequired(response, required);
|
||||
}
|
||||
// Anonymous user calling a manual / OFFSITE-only tool — let it through; PAYG only
|
||||
// charges authenticated requests.
|
||||
passCounter.increment();
|
||||
return true;
|
||||
}
|
||||
|
||||
Long teamId;
|
||||
try {
|
||||
teamId = resolveTeamId(auth);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("EntitlementGuard resolveTeamId failed; passing through", e);
|
||||
errorsCounter.increment();
|
||||
return true;
|
||||
}
|
||||
if (teamId == null) {
|
||||
// Defensive: authenticated principal with no team — shouldn't happen post-migration,
|
||||
// but we don't want to lock those users out. PaygChargeInterceptor short-circuits the
|
||||
// same shape upstream.
|
||||
passCounter.increment();
|
||||
return true;
|
||||
}
|
||||
|
||||
EntitlementSnapshot snapshot;
|
||||
try {
|
||||
snapshot = entitlementService.getSnapshot(teamId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("EntitlementGuard getSnapshot failed for team {}; passing through", teamId, e);
|
||||
errorsCounter.increment();
|
||||
return true;
|
||||
}
|
||||
|
||||
// API-key calls are always billable usage (BillingCategory.API) — there is no "free
|
||||
// manual" path for a programmatic client the way there is for a JWT/web user, whose
|
||||
// everyday tool calls are BYPASSED and never reach a gate. So once the team is over its
|
||||
// free allowance / spending cap (DEGRADED), every API-key call hard-stops, regardless of
|
||||
// which gate the route declares. The gate loop below would otherwise wave through an API
|
||||
// call to a plain server tool (it needs only OFFSITE_PROCESSING, which survives DEGRADED),
|
||||
// letting an unsubscribed team keep consuming the API for free past its allowance.
|
||||
if (auth instanceof ApiKeyAuthenticationToken && snapshot.isDegraded()) {
|
||||
return write402PaygLimitReached(response, snapshot);
|
||||
}
|
||||
|
||||
List<FeatureGate> enabled = snapshot.enabledGates();
|
||||
for (FeatureGate gate : required) {
|
||||
if (enabled == null || !enabled.contains(gate)) {
|
||||
return write402FeatureDegraded(response, required, snapshot);
|
||||
}
|
||||
}
|
||||
passCounter.increment();
|
||||
return true;
|
||||
}
|
||||
|
||||
static FeatureGate[] resolveRequiredGates(HandlerMethod hm) {
|
||||
RequiresFeature ann = AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class);
|
||||
if (ann == null) {
|
||||
ann = AnnotationUtils.findAnnotation(hm.getBeanType(), RequiresFeature.class);
|
||||
}
|
||||
if (ann != null && ann.value().length > 0) {
|
||||
return ann.value();
|
||||
}
|
||||
return DEFAULT_REQUIRED_GATES;
|
||||
}
|
||||
|
||||
private static boolean isAnonymous(Authentication auth) {
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return true;
|
||||
}
|
||||
// Spring's anonymous filter installs a token whose name is "anonymousUser".
|
||||
return "anonymousUser".equals(auth.getName());
|
||||
}
|
||||
|
||||
private static boolean isBillable(FeatureGate[] required) {
|
||||
for (FeatureGate g : required) {
|
||||
if (g == FeatureGate.AUTOMATION || g == FeatureGate.AI_SUPPORT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private Long resolveTeamId(Authentication auth) {
|
||||
if (auth instanceof ApiKeyAuthenticationToken
|
||||
&& auth.getPrincipal() instanceof User apiUser) {
|
||||
return apiUser.getTeam() == null ? null : apiUser.getTeam().getId();
|
||||
}
|
||||
String supabaseId = AuthenticationUtils.extractSupabaseId(auth);
|
||||
if (supabaseId == null) {
|
||||
return null;
|
||||
}
|
||||
UUID supabaseUuid;
|
||||
try {
|
||||
supabaseUuid = UUID.fromString(supabaseId);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Username-style principals (legacy local accounts) — no Supabase ID to look up. Skip.
|
||||
return null;
|
||||
}
|
||||
return userRepository
|
||||
.findBySupabaseId(supabaseUuid)
|
||||
.map(u -> u.getTeam() == null ? null : u.getTeam().getId())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private boolean write401SignupRequired(HttpServletResponse response, FeatureGate[] required) {
|
||||
deniedSignupRequiredCounter.increment();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", "SIGNUP_REQUIRED");
|
||||
body.put("category", inferCategory(required));
|
||||
writeJson(response, HttpStatus.UNAUTHORIZED, body);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 402 for a billable API-key call once the team is over its allowance / cap. The message is
|
||||
* tailored by subscription state: an un-subscribed team is told to subscribe (their free
|
||||
* allowance is spent); a subscribed team is told it hit its own spending cap. Programmatic
|
||||
* clients get a stable {@code error} code plus the spend/cap numbers so they can surface
|
||||
* something actionable.
|
||||
*/
|
||||
private boolean write402PaygLimitReached(
|
||||
HttpServletResponse response, EntitlementSnapshot snapshot) {
|
||||
deniedPaygLimitCounter.increment();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", "PAYG_LIMIT_REACHED");
|
||||
body.put("subscribed", snapshot.subscribed());
|
||||
body.put(
|
||||
"message",
|
||||
snapshot.subscribed()
|
||||
? "Your team has reached its monthly spending cap. Raise the cap to"
|
||||
+ " continue, or wait for it to reset next billing period."
|
||||
: "Your team has used its free document allowance."
|
||||
+ " Subscribe to continue using the API.");
|
||||
body.put("state", snapshot.state().name());
|
||||
body.put("spendUnits", snapshot.periodSpendUnits());
|
||||
body.put("capUnits", snapshot.periodCapUnits());
|
||||
body.put(
|
||||
"periodEnd",
|
||||
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
|
||||
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean write402FeatureDegraded(
|
||||
HttpServletResponse response, FeatureGate[] required, EntitlementSnapshot snapshot) {
|
||||
deniedDegradedCounter.increment();
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("error", "FEATURE_DEGRADED");
|
||||
body.put("missingGates", missingGates(required, snapshot.enabledGates()));
|
||||
body.put("state", snapshot.state().name());
|
||||
body.put(
|
||||
"periodEnd",
|
||||
Optional.ofNullable(snapshot.periodEnd()).map(Object::toString).orElse(null));
|
||||
body.put("capUnits", snapshot.periodCapUnits());
|
||||
body.put("spendUnits", snapshot.periodSpendUnits());
|
||||
writeJson(response, HttpStatus.PAYMENT_REQUIRED, body);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static List<String> missingGates(FeatureGate[] required, List<FeatureGate> enabled) {
|
||||
List<FeatureGate> enabledOrEmpty = enabled == null ? Collections.emptyList() : enabled;
|
||||
return Arrays.stream(required)
|
||||
.filter(g -> !enabledOrEmpty.contains(g))
|
||||
.map(Enum::name)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static String inferCategory(FeatureGate[] required) {
|
||||
// Mirrors PaygChargeInterceptor.determineCategory precedence: AUTOMATION dominates AI.
|
||||
for (FeatureGate g : required) {
|
||||
if (g == FeatureGate.AUTOMATION) {
|
||||
return "AUTOMATION";
|
||||
}
|
||||
}
|
||||
for (FeatureGate g : required) {
|
||||
if (g == FeatureGate.AI_SUPPORT) {
|
||||
return "AI";
|
||||
}
|
||||
}
|
||||
return "OFFSITE_PROCESSING";
|
||||
}
|
||||
|
||||
private void writeJson(
|
||||
HttpServletResponse response, HttpStatus status, Map<String, Object> body) {
|
||||
response.setStatus(status.value());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
try {
|
||||
byte[] payload = objectMapper.writeValueAsBytes(body);
|
||||
response.setHeader(HttpHeaders.CONTENT_LENGTH, Integer.toString(payload.length));
|
||||
response.getOutputStream().write(payload);
|
||||
response.getOutputStream().flush();
|
||||
} catch (IOException e) {
|
||||
// Container will fall back to its default error page — we did set the status code,
|
||||
// so the client still sees the right HTTP code even if the body fails to write.
|
||||
log.warn("EntitlementGuard write response body failed", e);
|
||||
errorsCounter.increment();
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package stirling.software.saas.payg.entitlement;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.YearMonth;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||
import stirling.software.saas.payg.cap.CapEvaluator;
|
||||
import stirling.software.saas.payg.cap.CapEvaluator.Evaluation;
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
|
||||
/**
|
||||
* Hot-path entitlement lookup. Returns the {@link EntitlementSnapshot} for a team: the billing
|
||||
* facts (window, free allowance, document cap) come from {@link TeamBillingService}; this service
|
||||
* layers the period spend (ledger SUM over that window) and the warn/degrade evaluation on top.
|
||||
*
|
||||
* <p>Backed by a per-team Caffeine cache with {@value #CACHE_TTL_SECONDS}s TTL and {@value
|
||||
* #CACHE_MAX_SIZE}-entry cap. The TTL is the correctness floor — a cap change becomes visible on
|
||||
* every instance within that window without coordination. Mutators (wallet policy admin updates,
|
||||
* subscription webhook handlers) call {@link #invalidate(Long)} to drop a single team's entry
|
||||
* immediately on the originating instance.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@Profile("saas")
|
||||
public class EntitlementService {
|
||||
|
||||
static final int CACHE_TTL_SECONDS = 30;
|
||||
private static final int CACHE_MAX_SIZE = 10_000;
|
||||
|
||||
private static final int WARN_AT_PCT = 80;
|
||||
private static final int DEGRADE_AT_PCT = 100;
|
||||
|
||||
private final TeamBillingService teamBillingService;
|
||||
private final WalletPolicyRepository walletPolicyRepository;
|
||||
private final WalletLedgerRepository ledgerRepository;
|
||||
|
||||
private final Cache<Long, EntitlementSnapshot> snapshotCache;
|
||||
|
||||
public EntitlementService(
|
||||
TeamBillingService teamBillingService,
|
||||
WalletPolicyRepository walletPolicyRepository,
|
||||
WalletLedgerRepository ledgerRepository) {
|
||||
this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService");
|
||||
this.walletPolicyRepository =
|
||||
Objects.requireNonNull(walletPolicyRepository, "walletPolicyRepository");
|
||||
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
|
||||
this.snapshotCache =
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(CACHE_MAX_SIZE)
|
||||
.expireAfterWrite(Duration.ofSeconds(CACHE_TTL_SECONDS))
|
||||
.recordStats()
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entitlement snapshot for {@code teamId}. Caches per-team for {@value
|
||||
* #CACHE_TTL_SECONDS}s — burst requests share a single SUM query against the ledger.
|
||||
*
|
||||
* <p>{@code null} teamId throws — the guard short-circuits team-less requests upstream so a
|
||||
* null reach here is a programming error.
|
||||
*/
|
||||
public EntitlementSnapshot getSnapshot(Long teamId) {
|
||||
Objects.requireNonNull(teamId, "teamId");
|
||||
return snapshotCache.get(teamId, this::computeSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops {@code teamId}'s cache entry. Call after subscription state changes (webhook handlers),
|
||||
* cap edits, or manual ledger adjustments so the next read recomputes immediately rather than
|
||||
* waiting out the TTL. Also drops the underlying billing context so window/cap facts recompute
|
||||
* together with the spend.
|
||||
*/
|
||||
public void invalidate(Long teamId) {
|
||||
if (teamId != null) {
|
||||
snapshotCache.invalidate(teamId);
|
||||
teamBillingService.invalidate(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible for tests. */
|
||||
long cacheSize() {
|
||||
return snapshotCache.estimatedSize();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
EntitlementSnapshot computeSnapshot(Long teamId) {
|
||||
TeamBillingContext billing = teamBillingService.forTeam(teamId);
|
||||
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
|
||||
|
||||
FeatureSet degradedSet =
|
||||
walletPolicyOpt.map(WalletPolicy::getDegradedFeatureSet).orElse(FeatureSet.MINIMAL);
|
||||
int warnAtPct =
|
||||
walletPolicyOpt
|
||||
.map(WalletPolicy::getWarnAtPct)
|
||||
.filter(Objects::nonNull)
|
||||
.orElse(WARN_AT_PCT);
|
||||
int degradeAtPct =
|
||||
walletPolicyOpt
|
||||
.map(WalletPolicy::getDegradeAtPct)
|
||||
.filter(Objects::nonNull)
|
||||
.orElse(DEGRADE_AT_PCT);
|
||||
|
||||
// Subscription-anchored window when subscribed; calendar month otherwise. Used for the
|
||||
// subscribed monthly cap + the displayed billing period.
|
||||
LocalDateTime periodStart = billing.periodStart();
|
||||
LocalDateTime periodEnd = billing.periodEnd();
|
||||
|
||||
Evaluation eval;
|
||||
long snapshotSpend;
|
||||
Long snapshotCap;
|
||||
|
||||
if (billing.subscribed()) {
|
||||
// Subscribed: gate on the monthly spending cap. Spend = this period's net billable
|
||||
// documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The one-time
|
||||
// free grant doesn't gate a paying team — it only reduced what they were metered.
|
||||
long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd);
|
||||
long periodSpend = signedNet < 0 ? -signedNet : 0L;
|
||||
Long cap = billing.monthlyCapDocUnits();
|
||||
eval = CapEvaluator.evaluate(periodSpend, cap, warnAtPct, degradeAtPct, degradedSet);
|
||||
snapshotSpend = periodSpend;
|
||||
snapshotCap = cap;
|
||||
} else {
|
||||
// Unsubscribed: gate on the one-time lifetime free grant. Exhausted (remaining ≤ 0, or
|
||||
// no grant configured) → DEGRADED so billable categories hard-stop; otherwise evaluate
|
||||
// the warn/degrade band on used-of-grant.
|
||||
long grant = billing.freeGrantUnits();
|
||||
long remaining = billing.freeRemainingUnits();
|
||||
long used = Math.max(0L, grant - remaining);
|
||||
if (remaining <= 0L) {
|
||||
eval =
|
||||
new Evaluation(
|
||||
EntitlementState.DEGRADED,
|
||||
degradedSet,
|
||||
CapEvaluator.gatesFor(degradedSet));
|
||||
} else {
|
||||
eval = CapEvaluator.evaluate(used, grant, warnAtPct, degradeAtPct, degradedSet);
|
||||
}
|
||||
snapshotSpend = used;
|
||||
snapshotCap = grant;
|
||||
}
|
||||
|
||||
return new EntitlementSnapshot(
|
||||
eval.state(),
|
||||
eval.featureSet(),
|
||||
List.copyOf(eval.enabledGates()),
|
||||
snapshotSpend,
|
||||
snapshotCap,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
billing.subscribed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Inclusive-start / exclusive-end window for the calendar-month period. Test seam — takes a
|
||||
* clock value so tests don't race the calendar boundary. The live snapshot window comes from
|
||||
* {@link TeamBillingService}; this remains for the forthcoming {@code BILLING_CYCLE} work.
|
||||
*/
|
||||
static LocalDateTime[] currentMonthWindow(LocalDateTime now) {
|
||||
YearMonth ym = YearMonth.from(now);
|
||||
LocalDateTime start = ym.atDay(1).atStartOfDay();
|
||||
LocalDateTime end = ym.plusMonths(1).atDay(1).atStartOfDay();
|
||||
return new LocalDateTime[] {start, end};
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package stirling.software.saas.payg.entitlement;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
|
||||
/**
|
||||
* Immutable snapshot of a team's entitlement state as of a single point in time. Returned by {@link
|
||||
* EntitlementService#getSnapshot(Long)} and consumed by {@code EntitlementGuard}.
|
||||
*
|
||||
* <p>Contrast with {@link WalletEntitlementSnapshot}: the JPA entity is the <em>persisted</em>
|
||||
* snapshot that the recompute path writes (one row per team, optionally per member). This record is
|
||||
* the <em>computed-now</em> view the hot-path guard reads — backed by a 30s Caffeine cache so a
|
||||
* request burst doesn't hammer the ledger SUM.
|
||||
*
|
||||
* @param state aggregate state — FULL, WARNED, or DEGRADED.
|
||||
* @param featureSet bundle name in effect (FULL on no-cap / warn band; degraded set on DEGRADED).
|
||||
* @param enabledGates the gates the guard checks against — request proceeds only if every required
|
||||
* gate is in this list.
|
||||
* @param periodSpendUnits sum of debited units in {@code [periodStart, periodEnd)}, in canonical
|
||||
* doc-units (positive).
|
||||
* @param periodCapUnits the cap applied — free-tier units for un-subscribed teams, {@code
|
||||
* wallet_policy.cap_units} for subscribed teams. {@code null} means uncapped.
|
||||
* @param periodStart inclusive start of the current cap period.
|
||||
* @param periodEnd exclusive end of the current cap period.
|
||||
* @param subscribed whether the team has an active PAYG subscription. Drives the messaging when a
|
||||
* billable call is hard-stopped: an un-subscribed team is told to subscribe; a subscribed team
|
||||
* that hit its self-set spending cap is told to raise it.
|
||||
*/
|
||||
public record EntitlementSnapshot(
|
||||
EntitlementState state,
|
||||
FeatureSet featureSet,
|
||||
List<FeatureGate> enabledGates,
|
||||
long periodSpendUnits,
|
||||
Long periodCapUnits,
|
||||
LocalDateTime periodStart,
|
||||
LocalDateTime periodEnd,
|
||||
boolean subscribed) {
|
||||
|
||||
public boolean isDegraded() {
|
||||
return state == EntitlementState.DEGRADED;
|
||||
}
|
||||
}
|
||||
+116
-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;
|
||||
@@ -37,11 +38,14 @@ import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
||||
import stirling.software.saas.payg.charge.ChargeContext;
|
||||
import stirling.software.saas.payg.charge.ChargeOutcome;
|
||||
import stirling.software.saas.payg.charge.JobChargeService;
|
||||
import stirling.software.saas.payg.charge.JobInput;
|
||||
import stirling.software.saas.payg.job.JobService;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.JobStepStatus;
|
||||
import stirling.software.saas.payg.model.ProcessType;
|
||||
@@ -52,10 +56,13 @@ import stirling.software.saas.util.AuthenticationUtils;
|
||||
* after it in {@code PaygWebMvcConfig} so legacy credit-rejection short-circuits before we waste
|
||||
* work hashing inputs.
|
||||
*
|
||||
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping}, reads the parsed multipart parts,
|
||||
* materialises each input to a {@code TempFile}, and asks {@link JobChargeService#openProcess} to
|
||||
* open (or join) a process. The resulting {@link ChargeOutcome} plus input temp-files are stashed
|
||||
* as request attributes for {@code afterCompletion}.
|
||||
* <p>{@code preHandle}: gates on {@code @AutoJobPostMapping} OR {@code @RequiresFeature} (the
|
||||
* latter lets AI controllers — JSON-bodied, no AutoJobPostMapping — bill correctly), reads the
|
||||
* parsed multipart parts, materialises each input to a {@code TempFile}, and asks {@link
|
||||
* JobChargeService#openProcess} to open (or join) a process. The resulting {@link ChargeOutcome}
|
||||
* plus input temp-files are stashed as request attributes for {@code afterCompletion}. Routes
|
||||
* without multipart inputs short-circuit inside {@code doPreHandle} without touching the charge
|
||||
* service.
|
||||
*
|
||||
* <p>{@code afterCompletion}: branches on HTTP status — 2xx hashes the response body for OUTPUT
|
||||
* lineage; 4xx records a step append for audit; 5xx triggers refund-and-close (OPENED) or
|
||||
@@ -105,6 +112,7 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
||||
private final Counter callsOpened;
|
||||
private final Counter callsJoined;
|
||||
private final Counter callsShortCircuit;
|
||||
private final Counter callsBypassed;
|
||||
private final Counter refundsCounter;
|
||||
|
||||
/** preHandle wall-clock per request. Separate from afterCompletion — different populations. */
|
||||
@@ -144,6 +152,11 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
||||
Counter.builder("payg.filter.calls")
|
||||
.tag("disposition", "SHORT_CIRCUIT")
|
||||
.register(meterRegistry);
|
||||
this.callsBypassed =
|
||||
Counter.builder("payg.filter.bypassed")
|
||||
.description(
|
||||
"Manual UI tool calls that skipped openProcess (BillingCategory.BYPASSED)")
|
||||
.register(meterRegistry);
|
||||
this.refundsCounter =
|
||||
Counter.builder("payg.filter.refunds")
|
||||
.description("First-step 5xx refunds applied to shadow rows")
|
||||
@@ -168,13 +181,39 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
||||
if (!properties.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
if (!(handler instanceof HandlerMethod hm)
|
||||
|| hm.getMethodAnnotation(AutoJobPostMapping.class) == null) {
|
||||
if (!(handler instanceof HandlerMethod hm)) {
|
||||
callsShortCircuit.increment();
|
||||
return true;
|
||||
}
|
||||
// In-scope when the handler carries @AutoJobPostMapping (multipart tool POSTs) OR
|
||||
// @RequiresFeature (AI controllers, future non-multipart gated routes). Without one of
|
||||
// these the interceptor short-circuits — admin / info / static routes never run
|
||||
// determineCategory.
|
||||
boolean hasAutoJobPostMapping =
|
||||
AnnotationUtils.findAnnotation(hm.getMethod(), AutoJobPostMapping.class) != null
|
||||
|| AnnotationUtils.findAnnotation(
|
||||
hm.getBeanType(), AutoJobPostMapping.class)
|
||||
!= null;
|
||||
boolean hasRequiresFeature =
|
||||
AnnotationUtils.findAnnotation(hm.getMethod(), RequiresFeature.class) != null
|
||||
|| AnnotationUtils.findAnnotation(
|
||||
hm.getBeanType(), RequiresFeature.class)
|
||||
!= null;
|
||||
if (!hasAutoJobPostMapping && !hasRequiresFeature) {
|
||||
callsShortCircuit.increment();
|
||||
return true;
|
||||
}
|
||||
// Bypass fast-path: determine the BillingCategory BEFORE any multipart
|
||||
// materialisation or openProcess call. Manual UI tool calls (BYPASSED) skip the
|
||||
// entire ledger/shadow pipeline — no temp files, no DB writes.
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
BillingCategory category = determineCategory(hm, request, auth);
|
||||
if (category == BillingCategory.BYPASSED) {
|
||||
callsBypassed.increment();
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
doPreHandle(request);
|
||||
doPreHandle(request, auth, category);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("PAYG preHandle failed; passing through unbilled", e);
|
||||
errorsCounter.increment();
|
||||
@@ -187,8 +226,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
private void doPreHandle(HttpServletRequest request) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
private void doPreHandle(
|
||||
HttpServletRequest request, Authentication auth, BillingCategory category) {
|
||||
User currentUser = resolveUser(auth);
|
||||
if (currentUser == null) {
|
||||
callsShortCircuit.increment();
|
||||
@@ -248,7 +287,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
||||
currentUser.getId(),
|
||||
currentUser.getTeam() == null ? null : currentUser.getTeam().getId(),
|
||||
determineSource(request, auth),
|
||||
ProcessType.SINGLE_TOOL);
|
||||
ProcessType.SINGLE_TOOL,
|
||||
category);
|
||||
|
||||
ChargeOutcome outcome;
|
||||
try {
|
||||
@@ -331,12 +371,38 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
||||
}
|
||||
if (status >= 400) {
|
||||
// 4xx: customer paid for the attempt. No OUTPUT recording, no refund.
|
||||
// Still a successful-from-billing-standpoint OPENED process — meter it below.
|
||||
meterIfOpened(jobId, disposition);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success: this is the moment the billable work finished, so this is when we tell Stripe.
|
||||
// Only the OPENED request meters — JOINED follow-up steps (chained tools on the same
|
||||
// document) added no units and must not re-meter. The process stays OPEN for further
|
||||
// lineage joins; StaleJobCloser closing it later is a no-op at Stripe thanks to the shared
|
||||
// idempotency key. metering is best-effort and must never break the response teardown.
|
||||
meterIfOpened(jobId, disposition);
|
||||
recordOutputs(request, response, jobId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the Stripe meter for a just-finished process, but only when this request OPENED it. Runs
|
||||
* on the request-teardown thread (the response is already flushed to the client); {@code
|
||||
* meterJobUsage} is best-effort and swallows its own failures, but we still guard here so a
|
||||
* meter hiccup can't disturb lineage/cleanup that follows.
|
||||
*/
|
||||
private void meterIfOpened(UUID jobId, ChargeOutcome.Disposition disposition) {
|
||||
if (disposition != ChargeOutcome.Disposition.OPENED) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
chargeService.meterJobUsage(jobId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Meter-on-completion failed for job {}: {}", jobId, e.getMessage());
|
||||
errorsCounter.increment();
|
||||
}
|
||||
}
|
||||
|
||||
private void recordOutputs(
|
||||
HttpServletRequest request, HttpServletResponse response, UUID jobId) {
|
||||
PaygResponseBodyWrapper wrapper =
|
||||
@@ -445,6 +511,46 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
|
||||
return JobSource.WEB;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the {@link BillingCategory} for this request. Precedence: {@code
|
||||
* X-Stirling-Automation: true} or {@code @RequiresFeature(AUTOMATION)} → AUTOMATION;
|
||||
* {@code @RequiresFeature(AI_SUPPORT)} → AI; API-key auth → API; otherwise BYPASSED (manual UI
|
||||
* tool — short-circuited in {@link #preHandle}).
|
||||
*
|
||||
* <p>Method-level {@code @RequiresFeature} wins over class-level. Multiple gates: AUTOMATION
|
||||
* dominates AI within a single annotation.
|
||||
*/
|
||||
private static BillingCategory determineCategory(
|
||||
HandlerMethod handler, HttpServletRequest request, Authentication auth) {
|
||||
String automationHeader = request.getHeader(AUTOMATION_HEADER);
|
||||
if (automationHeader != null && "true".equalsIgnoreCase(automationHeader.trim())) {
|
||||
return BillingCategory.AUTOMATION;
|
||||
}
|
||||
RequiresFeature ann =
|
||||
AnnotationUtils.findAnnotation(handler.getMethod(), RequiresFeature.class);
|
||||
if (ann == null) {
|
||||
ann = AnnotationUtils.findAnnotation(handler.getBeanType(), RequiresFeature.class);
|
||||
}
|
||||
if (ann != null) {
|
||||
boolean ai = false;
|
||||
for (FeatureGate gate : ann.value()) {
|
||||
if (gate == FeatureGate.AUTOMATION) {
|
||||
return BillingCategory.AUTOMATION;
|
||||
}
|
||||
if (gate == FeatureGate.AI_SUPPORT) {
|
||||
ai = true;
|
||||
}
|
||||
}
|
||||
if (ai) {
|
||||
return BillingCategory.AI;
|
||||
}
|
||||
}
|
||||
if (auth instanceof ApiKeyAuthenticationToken) {
|
||||
return BillingCategory.API;
|
||||
}
|
||||
return BillingCategory.BYPASSED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the {@code tool_id} value stored on {@code processing_job_step}. Prefers the route
|
||||
* pattern (e.g. {@code /api/v1/security/add-password}) over the raw URI so audit rollups
|
||||
|
||||
@@ -9,6 +9,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.saas.payg.entitlement.EntitlementGuard;
|
||||
|
||||
/**
|
||||
* Wires the PAYG filter + interceptor into Spring MVC. Two registrations:
|
||||
*
|
||||
@@ -28,6 +30,7 @@ import lombok.RequiredArgsConstructor;
|
||||
public class PaygWebMvcConfig implements WebMvcConfigurer {
|
||||
|
||||
private final PaygChargeInterceptor paygChargeInterceptor;
|
||||
private final EntitlementGuard entitlementGuard;
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<PaygResponseBodyWrapperFilter>
|
||||
@@ -46,6 +49,16 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
||||
*/
|
||||
public static final int INTERCEPTOR_ORDER = 1000;
|
||||
|
||||
/**
|
||||
* The entitlement guard runs AFTER the charge interceptor so cap-rejected requests still leave
|
||||
* the charge interceptor's preHandle state in the consistent open-or-bypassed shape (and so the
|
||||
* guard's 402 reaches the client without the charge interceptor needing to know about it).
|
||||
* Spring runs interceptors in registration order on the way in, reverse order on the way out;
|
||||
* the guard's preHandle short-circuit ({@code return false}) prevents the handler from running
|
||||
* but Spring still invokes the charge interceptor's afterCompletion to clean up temp files.
|
||||
*/
|
||||
public static final int ENTITLEMENT_GUARD_ORDER = 1100;
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(paygChargeInterceptor)
|
||||
@@ -56,5 +69,14 @@ public class PaygWebMvcConfig implements WebMvcConfigurer {
|
||||
"/api/v1/info/**",
|
||||
"/api/v1/admin/**")
|
||||
.order(INTERCEPTOR_ORDER);
|
||||
|
||||
registry.addInterceptor(entitlementGuard)
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns(
|
||||
"/api/v1/credits/**",
|
||||
"/api/v1/config/**",
|
||||
"/api/v1/info/**",
|
||||
"/api/v1/admin/**")
|
||||
.order(ENTITLEMENT_GUARD_ORDER);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,69 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.charge.JobChargeService;
|
||||
|
||||
/**
|
||||
* Auto-closes {@code OPEN} jobs whose {@code last_step_at} is older than the workflow window. Runs
|
||||
* every minute. API users never have to call {@code close()} explicitly — this scheduler is the
|
||||
* safety net.
|
||||
* safety net, and (for metered teams) the point at which the Stripe meter event is posted.
|
||||
*
|
||||
* <p>Each stale job is closed individually through {@link JobChargeService#close(java.util.UUID)}
|
||||
* rather than {@code JobService.closeStale()} (a bulk status flip). That routing matters: {@code
|
||||
* JobChargeService.close} registers the {@code afterCommit} hook that posts the billable usage to
|
||||
* Stripe via {@code PaygMeterReportingService}. A bulk flip would close the rows but never meter
|
||||
* them — usage would accrue in the wallet ledger yet never reach the customer's invoice.
|
||||
*
|
||||
* <p>Per-job transactions + failure isolation: each {@code chargeService.close(id)} runs in its own
|
||||
* transaction (cross-bean proxied call from this non-transactional scheduled method), so the
|
||||
* afterCommit meter POST fires once per job and one job's failure can't abort the rest of the
|
||||
* sweep. The meter event's idempotency key ({@code process:<id>:close}) makes a re-run on the next
|
||||
* tick safe even if a close half-completed.
|
||||
*
|
||||
* <p>Single-fire only at V1: not {@code @SchedulerLock}'d, consistent with the other
|
||||
* {@code @Scheduled} tasks in {@code :saas} (none of them are guarded against multi-pod
|
||||
* double-fires today either). Multi-pod cluster-correctness for all schedulers is tracked in design
|
||||
* § 9 as a separate cleanup. The underlying {@code closeStale()} call is idempotent — duplicate
|
||||
* firings read an empty stale set on the second pod, no data corruption risk.
|
||||
* {@code @Scheduled} tasks in {@code :saas}. Multi-pod cluster-correctness for all schedulers is
|
||||
* tracked in design § 9 as a separate cleanup; the per-job close + meter idempotency key mean a
|
||||
* double-fire across pods reads a shrinking stale set and never double-bills.
|
||||
*/
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class StaleJobCloser {
|
||||
|
||||
private final JobService jobService;
|
||||
private final JobChargeService chargeService;
|
||||
|
||||
public StaleJobCloser(JobService jobService, JobChargeService chargeService) {
|
||||
this.jobService = jobService;
|
||||
this.chargeService = chargeService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRateString = "${payg.job.stale-close-interval-ms:60000}")
|
||||
public void closeStale() {
|
||||
int closed = jobService.closeStale();
|
||||
List<ProcessingJob> stale = jobService.findStale();
|
||||
if (stale.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int closed = 0;
|
||||
for (ProcessingJob job : stale) {
|
||||
try {
|
||||
// Routes through the charge service so the afterCommit meter hook fires for
|
||||
// metered teams. Idempotent: a job already closed by a racing tick no-ops.
|
||||
chargeService.close(job.getId());
|
||||
closed++;
|
||||
} catch (RuntimeException e) {
|
||||
// Isolate per job — a single bad row (or a transient meter-path issue) must not
|
||||
// strand the rest of the stale set open. Next tick retries.
|
||||
log.warn("StaleJobCloser failed to close job {}: {}", job.getId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
if (closed > 0) {
|
||||
log.info("StaleJobCloser closed {} job(s) idle past the workflow window.", closed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package stirling.software.saas.payg.meter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* Backend-side audit row for one Stripe meter-event POST attempt ({@code payg_meter_event_log},
|
||||
* V15). A row is written <em>pending</em> ({@code posted_to_stripe_at} NULL) just before the POST
|
||||
* and stamped on success; a failed POST leaves it unposted with the Stripe error captured. Rows
|
||||
* still unposted after a short delay are retried by {@link PaygMeterReconcileScheduler} — this is
|
||||
* the durability mechanism behind the fail-open meter path, so a Stripe blip never silently
|
||||
* under-bills.
|
||||
*
|
||||
* <p>{@code idempotency_key} is UNIQUE and identical to the key sent to Stripe ({@code
|
||||
* process:<jobId>:close}); the unique constraint gives safe at-least-once semantics across the dual
|
||||
* meter triggers (completion + stale-close) and reconcile retries.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "payg_meter_event_log")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class PaygMeterEventLog {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "event_id")
|
||||
private Long eventId;
|
||||
|
||||
@Column(name = "team_id", nullable = false)
|
||||
private Long teamId;
|
||||
|
||||
@Column(name = "job_id")
|
||||
private UUID jobId;
|
||||
|
||||
@Column(name = "idempotency_key", nullable = false, unique = true, length = 128)
|
||||
private String idempotencyKey;
|
||||
|
||||
@Column(name = "units", nullable = false)
|
||||
private Integer units;
|
||||
|
||||
/**
|
||||
* Insert time; the DB column defaults to {@code CURRENT_TIMESTAMP} (set by {@code
|
||||
* insertPending}).
|
||||
*/
|
||||
@Column(name = "occurred_at", nullable = false, insertable = false, updatable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
/** NULL while pending; stamped when the meter-payg-units edge fn returns success. */
|
||||
@Column(name = "posted_to_stripe_at")
|
||||
private LocalDateTime postedToStripeAt;
|
||||
|
||||
@Column(name = "stripe_error_code", length = 64)
|
||||
private String stripeErrorCode;
|
||||
|
||||
@Column(name = "stripe_error_body", columnDefinition = "text")
|
||||
private String stripeErrorBody;
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package stirling.software.saas.payg.meter;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
|
||||
/**
|
||||
* Retries PAYG meter events that were logged but never confirmed posted to Stripe — the durability
|
||||
* half of the fail-open meter path. {@link PaygMeterReportingService} writes a pending {@code
|
||||
* payg_meter_event_log} row before each POST and stamps it on success; anything left unposted
|
||||
* (Stripe blip, pod crash between POST and stamp, edge-fn outage) is picked up here and re-sent
|
||||
* under the <em>same</em> idempotency key, so Stripe dedups rather than double-charging.
|
||||
*
|
||||
* <p>Only retries rows inside Stripe's 24h idempotency window — past that a same-key retry is no
|
||||
* longer guaranteed to dedup, so stuck rows are logged for manual reconciliation rather than
|
||||
* risking a double charge. Skips teams that have since unsubscribed (nothing to bill). Like {@link
|
||||
* stirling.software.saas.payg.lineage.LineagePruneScheduler} it is not {@code @SchedulerLock}'d: a
|
||||
* duplicate firing on a multi-pod deploy re-sends the same keys, which dedup at Stripe —
|
||||
* idempotent, wasted IO at worst.
|
||||
*/
|
||||
@Component
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class PaygMeterReconcileScheduler {
|
||||
|
||||
/** Stripe's meter-event idempotency window — a same-key retry past this may double-charge. */
|
||||
private static final Duration STRIPE_IDEMPOTENCY_WINDOW = Duration.ofHours(24);
|
||||
|
||||
private final PaygMeterEventLogRepository eventLogRepository;
|
||||
private final PaygTeamExtensionsRepository teamExtensionsRepository;
|
||||
private final PaygMeterReportingService meterReportingService;
|
||||
private final boolean enabled;
|
||||
private final Duration retryDelay;
|
||||
private final int batchSize;
|
||||
private final Counter retriedCounter;
|
||||
|
||||
public PaygMeterReconcileScheduler(
|
||||
PaygMeterEventLogRepository eventLogRepository,
|
||||
PaygTeamExtensionsRepository teamExtensionsRepository,
|
||||
PaygMeterReportingService meterReportingService,
|
||||
@Value("${payg.meter.reconcile.enabled:true}") boolean enabled,
|
||||
@Value("${payg.meter.reconcile.retry-delay:PT5M}") Duration retryDelay,
|
||||
@Value("${payg.meter.reconcile.batch-size:100}") int batchSize,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.eventLogRepository = Objects.requireNonNull(eventLogRepository, "eventLogRepository");
|
||||
this.teamExtensionsRepository =
|
||||
Objects.requireNonNull(teamExtensionsRepository, "teamExtensionsRepository");
|
||||
this.meterReportingService =
|
||||
Objects.requireNonNull(meterReportingService, "meterReportingService");
|
||||
this.enabled = enabled;
|
||||
this.retryDelay = Objects.requireNonNull(retryDelay, "retryDelay");
|
||||
this.batchSize = batchSize > 0 ? batchSize : 100;
|
||||
this.retriedCounter =
|
||||
Counter.builder("payg.meter.reconcile.retried")
|
||||
.description("PAYG meter events re-posted to Stripe by the reconcile job")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${payg.meter.reconcile-cron:0 */15 * * * *}", zone = "UTC")
|
||||
public void reconcile() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
// Give the live POST a moment to land before retrying; stay inside the 24h dedup window.
|
||||
LocalDateTime cutoff = now.minus(retryDelay);
|
||||
LocalDateTime floor = now.minus(STRIPE_IDEMPOTENCY_WINDOW);
|
||||
|
||||
List<PaygMeterEventLog> retryable =
|
||||
eventLogRepository.findRetryable(cutoff, floor, PageRequest.of(0, batchSize));
|
||||
|
||||
// Batch-fetch this page's team extensions in one query (keyed by team id) rather than a
|
||||
// findById per row — avoids an N+1 when the page spans several teams.
|
||||
List<Long> teamIds =
|
||||
retryable.stream().map(PaygMeterEventLog::getTeamId).distinct().toList();
|
||||
Map<Long, PaygTeamExtensions> extById =
|
||||
teamExtensionsRepository.findAllById(teamIds).stream()
|
||||
.collect(Collectors.toMap(PaygTeamExtensions::getTeamId, ext -> ext));
|
||||
|
||||
int retried = 0;
|
||||
for (PaygMeterEventLog row : retryable) {
|
||||
PaygTeamExtensions ext = extById.get(row.getTeamId());
|
||||
if (ext == null) {
|
||||
continue;
|
||||
}
|
||||
String subscriptionId = ext.getPaygSubscriptionId();
|
||||
String stripeCustomerId = ext.getStripeCustomerId();
|
||||
if (subscriptionId == null
|
||||
|| subscriptionId.isBlank()
|
||||
|| stripeCustomerId == null
|
||||
|| stripeCustomerId.isBlank()) {
|
||||
// Team unsubscribed since the event was logged — nothing to bill; leave the row.
|
||||
continue;
|
||||
}
|
||||
// Same idempotency key → Stripe dedups if the original actually landed. recordUsage
|
||||
// re-inserts pending as a no-op, re-POSTs, and stamps the row on success. Category is
|
||||
// not re-derived (analytics metadata only); units + key are what bill.
|
||||
meterReportingService.recordUsage(
|
||||
row.getTeamId(),
|
||||
stripeCustomerId,
|
||||
row.getUnits() == null ? 0 : row.getUnits(),
|
||||
null,
|
||||
row.getIdempotencyKey(),
|
||||
row.getJobId());
|
||||
retried++;
|
||||
}
|
||||
if (retried > 0) {
|
||||
retriedCounter.increment(retried);
|
||||
log.info("PaygMeterReconcileScheduler retried {} unposted meter event(s).", retried);
|
||||
}
|
||||
|
||||
long stuck = eventLogRepository.countStuck(floor);
|
||||
if (stuck > 0) {
|
||||
log.warn(
|
||||
"{} PAYG meter event(s) stuck unposted past Stripe's {}h idempotency window —"
|
||||
+ " manual reconciliation needed.",
|
||||
stuck,
|
||||
STRIPE_IDEMPOTENCY_WINDOW.toHours());
|
||||
}
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package stirling.software.saas.payg.meter;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
|
||||
|
||||
/**
|
||||
* POSTs PAYG billable usage to the Supabase {@code meter-payg-units} edge function. Called from
|
||||
* {@code JobChargeService.close()} in an {@code afterCommit} hook, so the wallet ledger DEBIT (the
|
||||
* customer's authoritative bill) is already durable before we tell Stripe about it.
|
||||
*
|
||||
* <p>Stripe is on a single flat-priced meter forever. {@link BillingCategory} ships as metadata for
|
||||
* analytics — pricing never reads it. Free-tier teams (no Stripe subscription) skip this call
|
||||
* entirely; the ledger entry is the only record needed.
|
||||
*
|
||||
* <p>Failure mode: we owe Stripe an event but the customer's bill via the ledger is correct. Log
|
||||
* WARN, bump {@code payg.meter.errors}, and swallow — durability comes from the {@code
|
||||
* payg_meter_event_log} row written around every attempt (pending → posted/failed) and {@link
|
||||
* PaygMeterReconcileScheduler}, which retries unposted rows, not from retries here. Caller's {@code
|
||||
* close()} must not roll back because Stripe wobbled.
|
||||
*
|
||||
* <p>Both config keys default to empty so unit tests / local dev never crash on missing env. When
|
||||
* blank, this service no-ops at WARN-debug level — useful for SaaS smoke tests that don't want to
|
||||
* touch the real edge function.
|
||||
*/
|
||||
@Service
|
||||
@Profile("saas")
|
||||
@Slf4j
|
||||
public class PaygMeterReportingService {
|
||||
|
||||
private final String endpoint;
|
||||
private final String authToken;
|
||||
private final RestTemplate restTemplate;
|
||||
private final PaygMeterEventLogRepository eventLogRepository;
|
||||
private final Counter errorsCounter;
|
||||
|
||||
/** Stripe error bodies can be large; the column is TEXT but we cap to keep rows sane. */
|
||||
private static final int MAX_ERROR_BODY = 4000;
|
||||
|
||||
public PaygMeterReportingService(
|
||||
@Value("${payg.meter.endpoint:}") String endpoint,
|
||||
@Value("${payg.meter.auth-token:}") String authToken,
|
||||
RestTemplate saasRestTemplate,
|
||||
PaygMeterEventLogRepository eventLogRepository,
|
||||
MeterRegistry meterRegistry) {
|
||||
this.endpoint = endpoint;
|
||||
this.authToken = authToken;
|
||||
this.restTemplate = saasRestTemplate;
|
||||
this.eventLogRepository = eventLogRepository;
|
||||
this.errorsCounter =
|
||||
Counter.builder("payg.meter.errors")
|
||||
.description("Failures POSTing PAYG meter events to Supabase edge function")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort POST of a single billable event, wrapped in a durable audit row. Idempotency on
|
||||
* the Supabase side is keyed on {@code idempotency_key} — supply a deterministic value (e.g.
|
||||
* {@code "process:<uuid>:close"}) so a retry, a reconcile replay, or a double-fire from two
|
||||
* pods never charges twice.
|
||||
*
|
||||
* <p>Flow: write a pending {@code payg_meter_event_log} row (idempotent), POST to the edge fn,
|
||||
* then stamp the row posted or record the Stripe error. The row is what {@link
|
||||
* PaygMeterReconcileScheduler} retries, so a failed POST is recoverable rather than silently
|
||||
* dropped.
|
||||
*
|
||||
* <p>Never throws. The wallet ledger entry is the source of truth for what the customer is
|
||||
* billed; if the POST fails the only loss is that Stripe doesn't see this event until reconcile
|
||||
* retries it.
|
||||
*/
|
||||
public void recordUsage(
|
||||
Long teamId,
|
||||
String stripeCustomerId,
|
||||
int units,
|
||||
BillingCategory category,
|
||||
String idempotencyKey,
|
||||
UUID jobId) {
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
log.debug(
|
||||
"payg.meter.endpoint not configured; skipping meter event for team {} key {}",
|
||||
teamId,
|
||||
idempotencyKey);
|
||||
return;
|
||||
}
|
||||
if (units <= 0) {
|
||||
// Zero-unit events would inflate event count without changing the bill — defensive.
|
||||
log.debug(
|
||||
"Skipping meter event with units={} for team {} key {}",
|
||||
units,
|
||||
teamId,
|
||||
idempotencyKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Durable pending row before the POST so a failure leaves a record the reconcile scheduler
|
||||
// can retry. Idempotent insert (ON CONFLICT DO NOTHING) — the completion + stale-close
|
||||
// triggers and reconcile retries all share the key. Best-effort: a logging failure must
|
||||
// never stop us from actually metering.
|
||||
try {
|
||||
eventLogRepository.insertPending(teamId, jobId, idempotencyKey, units);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"payg_meter_event_log pending insert failed for key {} (still metering): {}",
|
||||
idempotencyKey,
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
PostOutcome outcome =
|
||||
postToStripe(teamId, stripeCustomerId, units, category, idempotencyKey);
|
||||
|
||||
try {
|
||||
if (outcome.success()) {
|
||||
eventLogRepository.markPosted(idempotencyKey);
|
||||
} else {
|
||||
eventLogRepository.markFailed(
|
||||
idempotencyKey, outcome.errorCode(), outcome.errorBody());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"payg_meter_event_log result update failed for key {}: {}",
|
||||
idempotencyKey,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the event to the edge fn. Never throws; returns success / the captured Stripe error.
|
||||
* Increments {@code payg.meter.errors} on any non-2xx or exception (unchanged metric contract).
|
||||
*/
|
||||
private PostOutcome postToStripe(
|
||||
Long teamId,
|
||||
String stripeCustomerId,
|
||||
int units,
|
||||
BillingCategory category,
|
||||
String idempotencyKey) {
|
||||
try {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (authToken != null && !authToken.isBlank()) {
|
||||
headers.setBearerAuth(authToken);
|
||||
}
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
|
||||
Map<String, Object> body =
|
||||
Map.of(
|
||||
"team_id",
|
||||
// JSON number — the edge fn type-checks and ignores strings.
|
||||
teamId == null ? -1L : teamId,
|
||||
"stripe_customer_id",
|
||||
stripeCustomerId == null ? "" : stripeCustomerId,
|
||||
"units",
|
||||
units,
|
||||
"idempotency_key",
|
||||
idempotencyKey,
|
||||
"metadata",
|
||||
Map.of("category", category == null ? "UNKNOWN" : category.name()));
|
||||
|
||||
ResponseEntity<String> response =
|
||||
restTemplate.exchange(
|
||||
endpoint,
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
String.class);
|
||||
|
||||
if (response.getStatusCode().is2xxSuccessful()) {
|
||||
return PostOutcome.ok();
|
||||
}
|
||||
log.warn(
|
||||
"Meter event POST returned {} for team {} key {}: {}",
|
||||
response.getStatusCode(),
|
||||
teamId,
|
||||
idempotencyKey,
|
||||
response.getBody());
|
||||
errorsCounter.increment();
|
||||
return PostOutcome.error(
|
||||
String.valueOf(response.getStatusCode().value()), response.getBody());
|
||||
} catch (Exception e) {
|
||||
// Catch-all by design: this method MUST NOT propagate. The customer's bill via the
|
||||
// ledger is correct; we just owe Stripe an event the reconcile scheduler will retry.
|
||||
log.warn(
|
||||
"Meter event POST failed for team {} key {}: {}",
|
||||
teamId,
|
||||
idempotencyKey,
|
||||
e.getMessage());
|
||||
errorsCounter.increment();
|
||||
return PostOutcome.error("exception", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome of one edge-fn POST attempt. */
|
||||
private record PostOutcome(boolean success, String errorCode, String errorBody) {
|
||||
static PostOutcome ok() {
|
||||
return new PostOutcome(true, null, null);
|
||||
}
|
||||
|
||||
static PostOutcome error(String code, String body) {
|
||||
String trimmedCode = code != null && code.length() > 64 ? code.substring(0, 64) : code;
|
||||
String trimmedBody =
|
||||
body != null && body.length() > MAX_ERROR_BODY
|
||||
? body.substring(0, MAX_ERROR_BODY)
|
||||
: body;
|
||||
return new PostOutcome(false, trimmedCode, trimmedBody);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package stirling.software.saas.payg.model;
|
||||
|
||||
/**
|
||||
* Analytics / in-app breakdown axis stamped on every billable ledger entry and shadow charge. PAYG
|
||||
* stays on a single flat-priced Stripe meter — category is metadata only, never affects pricing.
|
||||
*
|
||||
* <p>Precedence at translation time (interceptor): AUTOMATION → AI → API → BYPASSED. {@link
|
||||
* #BYPASSED} is the default for manual UI tool calls that never hit a billable code path.
|
||||
*
|
||||
* <p>Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream
|
||||
* relies on {@code ordinal()}.
|
||||
*/
|
||||
public enum BillingCategory {
|
||||
BYPASSED,
|
||||
API,
|
||||
AI,
|
||||
AUTOMATION
|
||||
}
|
||||
@@ -71,6 +71,16 @@ public class PaygTeamExtensions implements Serializable {
|
||||
@Column(name = "payg_subscription_id", unique = true, length = 128)
|
||||
private String paygSubscriptionId;
|
||||
|
||||
/**
|
||||
* Remaining one-time free documents for this team (the lifetime grant). Seeded from the
|
||||
* effective pricing policy's {@code free_tier_units} when this row is created (V14 trigger,
|
||||
* updated in V19); decremented by the charge pipeline when a billable charge is written and
|
||||
* restored on a first-step refund. Never replenishes; survives subscribing. This counter — not
|
||||
* the wallet ledger — is the source of truth for the grant, so old ledger rows can be pruned.
|
||||
*/
|
||||
@Column(name = "free_units_remaining", nullable = false)
|
||||
private Long freeUnitsRemaining = 0L;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -74,18 +74,14 @@ public class PricingPolicy implements Serializable {
|
||||
private Integer fileUnitCap = 1000;
|
||||
|
||||
/**
|
||||
* Free-tier allowance — doc units a team on this policy can consume per cycle before they must
|
||||
* add a card. {@code 0} (default) means no free tier; the team is blocked at 402 from their
|
||||
* very first tool call until they pay. New-signup defaults will set this to a sensible positive
|
||||
* value; the special launch policy used by the day-1 legacy migration script can override with
|
||||
* a different size if product wants the original 10 customers to feel special.
|
||||
*
|
||||
* <p>Enforced by {@code PaygTeamUsageService} (PR-SB-4) on every tool call, gated on {@code
|
||||
* payg_team_extensions.payg_subscription_id IS NULL} — teams with an active subscription bypass
|
||||
* this check entirely.
|
||||
* One-time lifetime free document grant handed to a team on creation. {@code 0} (default) means
|
||||
* no free grant. NOT per-cycle: it never replenishes and a team keeps any unused portion after
|
||||
* subscribing. The value is copied into {@code payg_team_extensions.free_units_remaining} when
|
||||
* the team's sidecar row is created (V14 trigger, updated in V19); from then on the per-team
|
||||
* counter is authoritative and this column is only the seed for new teams.
|
||||
*/
|
||||
@Column(name = "free_tier_units_per_cycle", nullable = false)
|
||||
private Long freeTierUnitsPerCycle = 0L;
|
||||
@Column(name = "free_tier_units", nullable = false)
|
||||
private Long freeTierUnits = 0L;
|
||||
|
||||
/**
|
||||
* Max tool steps allowed in one process before it splits, keyed by the caller's {@link
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package stirling.software.saas.payg.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import stirling.software.saas.payg.meter.PaygMeterEventLog;
|
||||
|
||||
@Repository
|
||||
public interface PaygMeterEventLogRepository extends JpaRepository<PaygMeterEventLog, Long> {
|
||||
|
||||
/**
|
||||
* Idempotent pending insert. The two meter triggers (charge-completion + stale-close) share the
|
||||
* idempotency key, and a reconcile retry re-runs the same path, so {@code ON CONFLICT DO
|
||||
* NOTHING} keeps the audit row at exactly one per key. {@code occurred_at} defaults to {@code
|
||||
* CURRENT_TIMESTAMP} at the DB; {@code posted_to_stripe_at} stays NULL until success.
|
||||
* Transactional because it's called from the non-transactional meter-reporting path.
|
||||
*/
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query(
|
||||
value =
|
||||
"INSERT INTO payg_meter_event_log (team_id, job_id, idempotency_key, units)"
|
||||
+ " VALUES (:teamId, :jobId, :key, :units)"
|
||||
+ " ON CONFLICT (idempotency_key) DO NOTHING",
|
||||
nativeQuery = true)
|
||||
void insertPending(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("jobId") UUID jobId,
|
||||
@Param("key") String key,
|
||||
@Param("units") int units);
|
||||
|
||||
/** Stamp an event posted; clears any prior error. No-op if already posted. */
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE PaygMeterEventLog e"
|
||||
+ " SET e.postedToStripeAt = CURRENT_TIMESTAMP,"
|
||||
+ " e.stripeErrorCode = NULL, e.stripeErrorBody = NULL"
|
||||
+ " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL")
|
||||
int markPosted(@Param("key") String key);
|
||||
|
||||
/** Record the latest Stripe error against a still-pending event. */
|
||||
@Transactional
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE PaygMeterEventLog e"
|
||||
+ " SET e.stripeErrorCode = :code, e.stripeErrorBody = :body"
|
||||
+ " WHERE e.idempotencyKey = :key AND e.postedToStripeAt IS NULL")
|
||||
int markFailed(
|
||||
@Param("key") String key, @Param("code") String code, @Param("body") String body);
|
||||
|
||||
/**
|
||||
* Events still unposted whose attempt is older than {@code cutoff} (give the live POST time to
|
||||
* land first) but within {@code floor} — Stripe's 24h idempotency window — so a retry under the
|
||||
* same key safely dedups rather than risking a double charge. Oldest first.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT e FROM PaygMeterEventLog e"
|
||||
+ " WHERE e.postedToStripeAt IS NULL"
|
||||
+ " AND e.occurredAt < :cutoff AND e.occurredAt >= :floor"
|
||||
+ " ORDER BY e.occurredAt ASC")
|
||||
List<PaygMeterEventLog> findRetryable(
|
||||
@Param("cutoff") LocalDateTime cutoff,
|
||||
@Param("floor") LocalDateTime floor,
|
||||
Pageable pageable);
|
||||
|
||||
/** Events stuck unposted past the safe retry window — surfaced for manual reconciliation. */
|
||||
@Query(
|
||||
"SELECT COUNT(e) FROM PaygMeterEventLog e"
|
||||
+ " WHERE e.postedToStripeAt IS NULL AND e.occurredAt < :floor")
|
||||
long countStuck(@Param("floor") LocalDateTime floor);
|
||||
}
|
||||
+16
@@ -29,4 +29,20 @@ public interface PaygShadowChargeRepository extends JpaRepository<PaygShadowChar
|
||||
* defensively if a duplicate ever appears.
|
||||
*/
|
||||
Optional<PaygShadowCharge> findFirstByJobIdOrderByIdAsc(UUID jobId);
|
||||
|
||||
/**
|
||||
* Paid (Stripe-metered) documents for a team in a period: {@code SUM(payg_units −
|
||||
* free_units_consumed)} over CHARGED rows. This is exactly what was reported to Stripe in the
|
||||
* window, so the wallet's "estimated bill so far" is the metered total × rate. REFUNDED rows
|
||||
* are excluded.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(s.paygUnits - s.freeUnitsConsumed), 0) FROM PaygShadowCharge s"
|
||||
+ " WHERE s.teamId = :teamId"
|
||||
+ " AND s.status = stirling.software.saas.payg.model.ShadowChargeStatus.CHARGED"
|
||||
+ " AND s.occurredAt >= :from AND s.occurredAt < :to")
|
||||
long sumPaidUnits(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to);
|
||||
}
|
||||
|
||||
+28
@@ -3,12 +3,40 @@ package stirling.software.saas.payg.repository;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
|
||||
@Repository
|
||||
public interface PaygTeamExtensionsRepository extends JpaRepository<PaygTeamExtensions, Long> {
|
||||
|
||||
Optional<PaygTeamExtensions> findByStripeCustomerId(String stripeCustomerId);
|
||||
|
||||
/**
|
||||
* Pessimistic-write load of the sidecar row, used by the charge pipeline to deduct the one-time
|
||||
* free grant atomically. The lock serialises concurrent charges <em>for the same team</em> so
|
||||
* the per-job {@code free_units_consumed} split (and therefore the metered paid portion) is
|
||||
* exact — two simultaneous jobs can't both believe they drew from the same remaining unit.
|
||||
* Different teams never contend; the lock is held only for the {@code openProcess} transaction.
|
||||
*/
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT e FROM PaygTeamExtensions e WHERE e.teamId = :teamId")
|
||||
Optional<PaygTeamExtensions> findByIdForUpdate(@Param("teamId") Long teamId);
|
||||
|
||||
/**
|
||||
* Atomically returns {@code freeUnitsConsumed} to the team's grant on a refund. Increment is
|
||||
* commutative so no lock is needed; the amount restored is exactly what the job consumed, so it
|
||||
* can never exceed the original grant.
|
||||
*/
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE PaygTeamExtensions e SET e.freeUnitsRemaining = e.freeUnitsRemaining + :units"
|
||||
+ " WHERE e.teamId = :teamId")
|
||||
int restoreFreeUnits(@Param("teamId") Long teamId, @Param("units") long units);
|
||||
}
|
||||
|
||||
+42
-1
@@ -14,7 +14,30 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
@Repository
|
||||
public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry, Long> {
|
||||
|
||||
List<WalletLedgerEntry> findByTeamIdOrderByOccurredAtDesc(Long teamId);
|
||||
/** Most recent entries for the Plan page activity feed. */
|
||||
List<WalletLedgerEntry> findTop20ByTeamIdOrderByIdDesc(Long teamId);
|
||||
|
||||
/**
|
||||
* Per-category debit totals over an arbitrary window, as positive units. Replaces the
|
||||
* calendar-month {@code wallet_category_summary} view on the wallet endpoint — subscribed
|
||||
* teams' billing windows are anchored to the Stripe subscription period, not month starts. Rows
|
||||
* with {@code NULL} category (system entries) are excluded; BYPASSED never reaches the ledger
|
||||
* by construction.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT e.billingCategory AS category, COALESCE(SUM(-e.amountUnits), 0) AS units"
|
||||
+ " FROM WalletLedgerEntry e"
|
||||
+ " WHERE e.teamId = :teamId"
|
||||
+ " AND e.entryType = :entryType"
|
||||
+ " AND e.billingCategory IS NOT NULL"
|
||||
+ " AND e.occurredAt >= :periodStart"
|
||||
+ " AND e.occurredAt < :periodEnd"
|
||||
+ " GROUP BY e.billingCategory")
|
||||
List<Object[]> sumPeriodAmountByCategory(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("entryType") LedgerEntryType entryType,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("periodEnd") LocalDateTime periodEnd);
|
||||
|
||||
/** Sum of signed amounts over a team's entries — the wallet's current balance in units. */
|
||||
@Query(
|
||||
@@ -34,6 +57,24 @@ public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("periodEnd") LocalDateTime periodEnd);
|
||||
|
||||
/**
|
||||
* Net signed period balance over billable entries (DEBIT negative + REFUND positive). Negate
|
||||
* for positive spend. Unlike {@link #sumPeriodAmount} (DEBIT only) this nets refunds, so a
|
||||
* refunded job no longer reads as spent — the headline period-spend figure for the subscribed
|
||||
* monthly bill + cap.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
|
||||
+ " WHERE e.teamId = :teamId"
|
||||
+ " AND e.entryType IN (stirling.software.saas.payg.model.LedgerEntryType.DEBIT,"
|
||||
+ " stirling.software.saas.payg.model.LedgerEntryType.REFUND)"
|
||||
+ " AND e.occurredAt >= :periodStart"
|
||||
+ " AND e.occurredAt < :periodEnd")
|
||||
long sumPeriodNetBillable(
|
||||
@Param("teamId") Long teamId,
|
||||
@Param("periodStart") LocalDateTime periodStart,
|
||||
@Param("periodEnd") LocalDateTime periodEnd);
|
||||
|
||||
/** Per-member period spend (only when the member has a sub-cap configured). */
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
|
||||
|
||||
@@ -19,6 +19,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.ShadowChargeStatus;
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,15 @@ public class PaygShadowCharge implements Serializable {
|
||||
@Column(name = "payg_units", nullable = false)
|
||||
private Integer paygUnits;
|
||||
|
||||
/**
|
||||
* How many of {@link #paygUnits} were drawn from the team's one-time free grant at charge time.
|
||||
* The paid (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores
|
||||
* this many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19
|
||||
* rows and for jobs that consumed no free units (team's grant already exhausted).
|
||||
*/
|
||||
@Column(name = "free_units_consumed", nullable = false)
|
||||
private Integer freeUnitsConsumed = 0;
|
||||
|
||||
@Column(name = "legacy_credits_charged", nullable = false)
|
||||
private Integer legacyCreditsCharged;
|
||||
|
||||
@@ -75,6 +86,24 @@ public class PaygShadowCharge implements Serializable {
|
||||
@Column(name = "refund_reason", length = 128)
|
||||
private String refundReason;
|
||||
|
||||
/**
|
||||
* PAYG analytics axis copied from the request that produced this shadow row. {@code null} for
|
||||
* pre-V16 rows; populated by the charge interceptor going forward. Never affects what Stripe
|
||||
* would meter — the flat-meter assumption holds, this is breakdown metadata.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "billing_category", length = 16)
|
||||
private BillingCategory billingCategory;
|
||||
|
||||
/**
|
||||
* Caller surface that originated the job (copied from {@code processing_job.source} at write
|
||||
* time so the row stays self-describing after the job table is pruned). {@code null} for
|
||||
* pre-V16 rows backfilled when {@code processing_job} is no longer present.
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "job_source", length = 32)
|
||||
private JobSource jobSource;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "occurred_at", nullable = false, updatable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package stirling.software.saas.payg.stripe;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Read-only accessor for the Stripe Sync Engine schema ({@code stripe.*}). Gives the PAYG layer the
|
||||
* team's real billing window and the per-document rate of the Price its subscription bills against
|
||||
* — both live in Stripe, mirrored into Postgres by the sync engine, and are NOT duplicated in
|
||||
* {@code stirling_pdf} (design §10: money lives in Stripe).
|
||||
*
|
||||
* <p>PAYG prices are plain {@code per_unit} metered prices, so {@code stripe.prices.unit_amount}
|
||||
* carries the rate directly. The free grant is deliberately NOT in Stripe — it's the one-time
|
||||
* {@code pricing_policy.free_tier_units} pool, applied app-side (free units are never metered),
|
||||
* because un-subscribed teams get the same grant and have no Stripe Price at all.
|
||||
*
|
||||
* <p>Defensive by construction: the {@code stripe} schema only exists where the sync engine has run
|
||||
* (dev + prod Supabase, not unit-test H2). Any {@link DataAccessException} — missing schema,
|
||||
* missing row, connectivity blip — degrades to {@link Optional#empty()} with a WARN so callers fall
|
||||
* back to calendar-month windows rather than 500ing the wallet endpoint.
|
||||
*/
|
||||
@Slf4j
|
||||
@Repository
|
||||
@Profile("saas")
|
||||
public class StripeSubscriptionDao {
|
||||
|
||||
/**
|
||||
* Billing window + per-document rate of one subscription. Epoch seconds come back from sync
|
||||
* engine as INTEGER columns; converted to {@link LocalDateTime} in the system zone so they
|
||||
* compare cleanly against {@code wallet_ledger.occurred_at} (written by
|
||||
* {@code @CreationTimestamp} with JVM-local semantics).
|
||||
*
|
||||
* @param priceId the Stripe Price the subscription's (sole) item bills against; null if the
|
||||
* item row hasn't synced yet
|
||||
* @param currency lower-case ISO 4217 of that Price; null when the price row is missing
|
||||
* @param perDocMinor per-document rate in minor units (may be fractional via {@code
|
||||
* unit_amount_decimal}); null when the price row is missing or carries no usable amount
|
||||
* (e.g. a tiered price, which PAYG doesn't use)
|
||||
*/
|
||||
public record SubscriptionBilling(
|
||||
LocalDateTime periodStart,
|
||||
LocalDateTime periodEnd,
|
||||
String priceId,
|
||||
String status,
|
||||
String currency,
|
||||
BigDecimal perDocMinor) {}
|
||||
|
||||
/**
|
||||
* The per-document rate of a Price looked up directly (not via a subscription) — used to price
|
||||
* the cap estimate for un-subscribed teams, whose default policy points at Stripe Prices that
|
||||
* carry the same {@code unit_amount} they'd be billed at on subscribing.
|
||||
*
|
||||
* @param priceId the resolved Stripe Price id
|
||||
* @param currency lower-case ISO 4217 of that Price
|
||||
* @param perDocMinor per-document rate in minor units (may be fractional); never null — a row
|
||||
* with no usable amount is filtered out rather than returned with a null rate
|
||||
*/
|
||||
public record PriceRate(String priceId, String currency, BigDecimal perDocMinor) {}
|
||||
|
||||
private static final String QUERY =
|
||||
"SELECT s.current_period_start, s.current_period_end, s.status::text AS status,"
|
||||
+ " p.id AS price_id, p.currency AS currency,"
|
||||
+ " p.unit_amount AS unit_amount, p.unit_amount_decimal AS unit_amount_decimal"
|
||||
+ " FROM stripe.subscriptions s"
|
||||
+ " LEFT JOIN LATERAL ("
|
||||
+ " SELECT si.price FROM stripe.subscription_items si"
|
||||
+ " WHERE si.subscription = s.id AND COALESCE(si.deleted, false) = false"
|
||||
+ " ORDER BY si.created DESC NULLS LAST LIMIT 1"
|
||||
+ " ) item ON true"
|
||||
+ " LEFT JOIN stripe.prices p ON p.id = item.price"
|
||||
+ " WHERE s.id = ?";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public StripeSubscriptionDao(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate");
|
||||
}
|
||||
|
||||
/** Billing window + rate for {@code subscriptionId}; empty if unsynced or schema absent. */
|
||||
public Optional<SubscriptionBilling> findBilling(String subscriptionId) {
|
||||
if (subscriptionId == null || subscriptionId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
List<SubscriptionBilling> rows =
|
||||
jdbcTemplate.query(
|
||||
QUERY,
|
||||
(rs, i) -> {
|
||||
long startEpoch = rs.getLong("current_period_start");
|
||||
boolean startNull = rs.wasNull();
|
||||
long endEpoch = rs.getLong("current_period_end");
|
||||
boolean endNull = rs.wasNull();
|
||||
if (startNull || endNull) {
|
||||
return null;
|
||||
}
|
||||
return new SubscriptionBilling(
|
||||
toLocal(startEpoch),
|
||||
toLocal(endEpoch),
|
||||
rs.getString("price_id"),
|
||||
rs.getString("status"),
|
||||
rs.getString("currency"),
|
||||
extractRate(rs));
|
||||
},
|
||||
subscriptionId);
|
||||
return rows.stream().filter(Objects::nonNull).findFirst();
|
||||
} catch (DataAccessException e) {
|
||||
// Missing stripe schema (sync engine not provisioned) or transient DB issue. The
|
||||
// caller falls back to a calendar-month window; usage still accrues correctly.
|
||||
log.warn(
|
||||
"stripe.subscriptions lookup failed for {}: {}",
|
||||
subscriptionId,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-document rate of the active {@code stripe.prices} row with the given {@code lookupKey} in
|
||||
* {@code currency} — the elegant mirror of {@link #findBilling}, reading the same synced table
|
||||
* by Stripe Price {@code lookup_key} instead of via a subscription. The PAYG layer uses this to
|
||||
* price the cap estimate for an un-subscribed team: there's no subscription to read a rate off,
|
||||
* but the PAYG Price (lookup key {@code plan:processor}) carries the very rate they'd be billed
|
||||
* at. We resolve by lookup_key rather than the default policy's price ids because those aren't
|
||||
* seeded — the lookup key is the stable, env-agnostic handle (same one the price-lookup edge
|
||||
* function uses).
|
||||
*
|
||||
* <p>Empty when the {@code stripe} schema is absent (H2 unit tests), no active matching row
|
||||
* exists, or the row carries no usable per-unit amount (e.g. a tiered price, which PAYG doesn't
|
||||
* use). Callers degrade to "no estimate" exactly as the subscribed path degrades to a
|
||||
* calendar-month window.
|
||||
*/
|
||||
public Optional<PriceRate> findRateByLookupKey(String lookupKey, String currency) {
|
||||
if (lookupKey == null || lookupKey.isBlank() || currency == null || currency.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String sql =
|
||||
"SELECT p.id AS price_id, p.currency AS currency,"
|
||||
+ " p.unit_amount AS unit_amount,"
|
||||
+ " p.unit_amount_decimal AS unit_amount_decimal"
|
||||
+ " FROM stripe.prices p"
|
||||
+ " WHERE p.lookup_key = ? AND p.currency = ?"
|
||||
+ " AND COALESCE(p.active, true) = true"
|
||||
+ " ORDER BY p.created DESC NULLS LAST LIMIT 1";
|
||||
try {
|
||||
List<PriceRate> rows =
|
||||
jdbcTemplate.query(
|
||||
sql,
|
||||
(rs, i) -> {
|
||||
BigDecimal rate = extractRate(rs);
|
||||
if (rate == null) {
|
||||
return null;
|
||||
}
|
||||
return new PriceRate(
|
||||
rs.getString("price_id"), rs.getString("currency"), rate);
|
||||
},
|
||||
lookupKey,
|
||||
currency.toLowerCase());
|
||||
return rows.stream().filter(Objects::nonNull).findFirst();
|
||||
} catch (DataAccessException e) {
|
||||
log.warn(
|
||||
"stripe.prices rate lookup failed for lookup_key {} / currency {}: {}",
|
||||
lookupKey,
|
||||
currency,
|
||||
e.getMessage());
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the per-document rate off a {@code stripe.prices} row. Prefers {@code
|
||||
* unit_amount_decimal} (sub-minor-unit precision, e.g. half-cent rates), falls back to the
|
||||
* integer {@code unit_amount}, and returns null when neither is usable or the amount is ≤ 0
|
||||
* (tiered/zero prices PAYG doesn't bill on). Both queries alias the columns identically so this
|
||||
* is shared verbatim.
|
||||
*/
|
||||
private static BigDecimal extractRate(ResultSet rs) throws SQLException {
|
||||
BigDecimal rate = null;
|
||||
String decimal = rs.getString("unit_amount_decimal");
|
||||
if (decimal != null && !decimal.isBlank()) {
|
||||
try {
|
||||
rate = new BigDecimal(decimal);
|
||||
} catch (NumberFormatException ignore) {
|
||||
rate = null;
|
||||
}
|
||||
}
|
||||
if (rate == null) {
|
||||
long unitAmount = rs.getLong("unit_amount");
|
||||
if (!rs.wasNull()) {
|
||||
rate = BigDecimal.valueOf(unitAmount);
|
||||
}
|
||||
}
|
||||
if (rate != null && rate.signum() <= 0) {
|
||||
rate = null;
|
||||
}
|
||||
return rate;
|
||||
}
|
||||
|
||||
private static LocalDateTime toLocal(long epochSeconds) {
|
||||
return LocalDateTime.ofInstant(Instant.ofEpochSecond(epochSeconds), ZoneId.systemDefault());
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.LedgerBucket;
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.model.ReferenceType;
|
||||
@@ -76,6 +77,15 @@ public class WalletLedgerEntry implements Serializable {
|
||||
@Column(name = "stripe_event_id", length = 128)
|
||||
private String stripeEventId;
|
||||
|
||||
/**
|
||||
* PAYG analytics axis. {@code null} for system entries (grants, resets) and pre-V16 rows; set
|
||||
* by the charge interceptor for billable debits. Stripe pricing never reads this — it's a
|
||||
* single flat meter — so the column stays soft-typed (no NOT NULL, no FK).
|
||||
*/
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "billing_category", length = 16)
|
||||
private BillingCategory billingCategory;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "occurred_at", nullable = false, updatable = false)
|
||||
private LocalDateTime occurredAt;
|
||||
|
||||
+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
|
||||
*
|
||||
|
||||
@@ -91,7 +91,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
|
||||
+ " END, "
|
||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
||||
+ " last_api_usage = now() "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
|
||||
+ " AND (cycle_credits_remaining + bought_credits_remaining >= :creditAmount)",
|
||||
nativeQuery = true)
|
||||
int consumeCreditBySupabaseId(
|
||||
@@ -109,7 +109,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
|
||||
+ " cycle_credits_remaining = cycle_credits_remaining - :amount, "
|
||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
||||
+ " last_api_usage = now() "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
|
||||
+ " AND cycle_credits_remaining >= :amount",
|
||||
nativeQuery = true)
|
||||
int consumeCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
@@ -123,7 +123,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
|
||||
+ " bought_credits_remaining = bought_credits_remaining - :amount, "
|
||||
+ " total_api_calls_made = total_api_calls_made + 1, "
|
||||
+ " last_api_usage = now() "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId) "
|
||||
+ "WHERE user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId) "
|
||||
+ " AND bought_credits_remaining >= :amount",
|
||||
nativeQuery = true)
|
||||
int consumeBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
@@ -133,7 +133,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
|
||||
value =
|
||||
"SELECT CASE WHEN uc.cycle_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
|
||||
+ "FROM user_credits uc "
|
||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
|
||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
|
||||
nativeQuery = true)
|
||||
Boolean hasCycleCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
|
||||
@@ -142,7 +142,7 @@ public interface UserCreditRepository extends JpaRepository<UserCredit, Long> {
|
||||
value =
|
||||
"SELECT CASE WHEN uc.bought_credits_remaining >= :amount THEN TRUE ELSE FALSE END "
|
||||
+ "FROM user_credits uc "
|
||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_id = :supabaseId)",
|
||||
+ "WHERE uc.user_id = (SELECT u.user_id FROM users u WHERE u.supabase_auth_id = :supabaseId)",
|
||||
nativeQuery = true)
|
||||
Boolean hasBoughtCredits(@Param("supabaseId") UUID supabaseId, @Param("amount") int amount);
|
||||
}
|
||||
|
||||
@@ -23,3 +23,10 @@ spring.datasource.hikari.data-source-properties.ApplicationName=stirling-consoli
|
||||
logging.level.stirling.software.saas=DEBUG
|
||||
logging.level.org.springframework.security.oauth2.jwt=WARN
|
||||
logging.level.org.springframework.security.oauth2.server.resource=WARN
|
||||
|
||||
# Supabase meter edge fn the Java backend calls (server-to-server, on job close).
|
||||
# URL is not a secret; auth rides the existing SUPABASE_EDGE_FUNCTION_SECRET (same
|
||||
# shared secret the team-invitation flow uses — no service-role key in the Java env).
|
||||
# Blank secret → the meter service no-ops with a WARN, so the app still boots.
|
||||
# The billing portal is NOT here — the FE calls create-customer-portal-session directly.
|
||||
payg.meter.endpoint=https://qacaivhsjtftfwtgjvva.supabase.co/functions/v1/meter-payg-units
|
||||
|
||||
@@ -35,6 +35,31 @@ app.supabase.clock-skew-seconds=${app.jwt.clock-skew-seconds:120}
|
||||
app.supabase.edge-function-url=https://${app.supabase.project-ref}.supabase.co/functions/v1
|
||||
app.supabase.edge-function-secret=${SUPABASE_EDGE_FUNCTION_SECRET:}
|
||||
|
||||
# ---------- PAYG meter reporting ----------
|
||||
# Posts billable usage to the Supabase `meter-payg-units` edge function in the JobChargeService
|
||||
# close() afterCommit hook. Defaults to empty so unit tests / local dev are no-ops; set
|
||||
# PAYG_METER_ENDPOINT in deployed envs to enable. Compose with the Supabase functions base via
|
||||
# env (e.g. PAYG_METER_ENDPOINT=$SUPABASE_FUNCTIONS_URL/meter-payg-units) rather than templating
|
||||
# here — concatenating an empty default would produce a half-valid URL.
|
||||
# Auth rides the existing backend<->edge-fn shared secret (SUPABASE_EDGE_FUNCTION_SECRET, same
|
||||
# one team-invitation-email uses) — the backend never holds the RLS-bypassing service-role key.
|
||||
payg.meter.endpoint=${PAYG_METER_ENDPOINT:}
|
||||
payg.meter.auth-token=${app.supabase.edge-function-secret:}
|
||||
|
||||
# Reconcile job: retries meter events logged to payg_meter_event_log but never confirmed posted
|
||||
# (Stripe blip, edge-fn outage, pod crash mid-POST). Re-sends under the same idempotency key so
|
||||
# Stripe dedups; only within Stripe's 24h window. Defaults are sensible; tune/disable via env.
|
||||
payg.meter.reconcile.enabled=${PAYG_METER_RECONCILE_ENABLED:true}
|
||||
payg.meter.reconcile.retry-delay=${PAYG_METER_RECONCILE_RETRY_DELAY:PT5M}
|
||||
payg.meter.reconcile.batch-size=${PAYG_METER_RECONCILE_BATCH_SIZE:100}
|
||||
payg.meter.reconcile-cron=${PAYG_METER_RECONCILE_CRON:0 */15 * * * *}
|
||||
|
||||
# ---------- PAYG customer portal ----------
|
||||
# The Stripe billing-portal session is minted FE-direct: the Plan page calls the Supabase
|
||||
# `create-customer-portal-session` edge function with the user's JWT (same pattern as checkout),
|
||||
# and the function's SECURITY DEFINER RPC enforces team membership. No Java proxy, no backend
|
||||
# config — return_url host allowlisting lives in the edge fn (PORTAL_ALLOWED_RETURN_HOSTS).
|
||||
|
||||
supabase.url=https://${app.supabase.project-ref}.supabase.co
|
||||
|
||||
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://${app.supabase.project-ref}.supabase.co/auth/v1/.well-known/jwks.json
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- PAYG analytics axis: stamp every billable ledger entry / shadow row with the category that
|
||||
-- produced it (API | AI | AUTOMATION | BYPASSED). PAYG stays on a single flat-priced Stripe meter
|
||||
-- forever — this column is for in-app breakdowns and analytics, never for Stripe pricing.
|
||||
--
|
||||
-- All adds are nullable: pre-V16 rows have no category and stay NULL; the interceptor populates
|
||||
-- it for new rows going forward.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. wallet_ledger.billing_category
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE wallet_ledger ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL;
|
||||
COMMENT ON COLUMN wallet_ledger.billing_category IS
|
||||
'API | AI | AUTOMATION | BYPASSED. NULL = system entry or pre-V16 backfill.';
|
||||
|
||||
-- Partial index — only billable rows ever read this column, and NULLs would just bloat the tree.
|
||||
CREATE INDEX IF NOT EXISTS idx_wallet_ledger_team_category_period
|
||||
ON wallet_ledger (team_id, billing_category, occurred_at)
|
||||
WHERE billing_category IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. payg_shadow_charge.billing_category + job_source
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE payg_shadow_charge
|
||||
ADD COLUMN IF NOT EXISTS billing_category VARCHAR(16) NULL,
|
||||
ADD COLUMN IF NOT EXISTS job_source VARCHAR(32) NULL;
|
||||
|
||||
-- Backfill job_source from processing_job (best-effort — rows whose job has already been pruned
|
||||
-- stay NULL, which is fine: the shadow row is self-describing post-V16 and only legacy ones lack
|
||||
-- the column.)
|
||||
UPDATE payg_shadow_charge sc
|
||||
SET job_source = pj.source
|
||||
FROM processing_job pj
|
||||
WHERE pj.job_id = sc.job_id
|
||||
AND sc.job_source IS NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. pricing_policy_stripe_price.stripe_product_id
|
||||
-- Operator populates this manually per row when seeding new policies. Nullable for backward
|
||||
-- compatibility with existing rows that don't carry a Product reference.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
ALTER TABLE pricing_policy_stripe_price
|
||||
ADD COLUMN IF NOT EXISTS stripe_product_id VARCHAR(128) NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. wallet_category_summary view — pre-grouped per-team, per-month, per-category aggregate that
|
||||
-- the in-app breakdown widget reads. Recomputed live on every SELECT; cheap thanks to the
|
||||
-- partial index above.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW wallet_category_summary AS
|
||||
SELECT
|
||||
team_id,
|
||||
date_trunc('month', occurred_at) AS period_start,
|
||||
billing_category,
|
||||
SUM(CASE WHEN amount_units < 0 THEN -amount_units ELSE 0 END) AS units_debited,
|
||||
COUNT(*) FILTER (WHERE entry_type = 'DEBIT') AS debit_count
|
||||
FROM wallet_ledger
|
||||
WHERE billing_category IS NOT NULL
|
||||
GROUP BY team_id, date_trunc('month', occurred_at), billing_category;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Consolidate users.supabase_id into users.supabase_auth_id.
|
||||
--
|
||||
-- The `supabase_auth_id` column is the canonical link to Supabase Auth — it was
|
||||
-- created by the initial Supabase schema migration (Sep 2025) and is referenced
|
||||
-- by every RLS policy in the Supabase side of the world (V14's
|
||||
-- payg_team_ext_select / payg_team_ext_leader_update, the public.payg_*
|
||||
-- SECURITY DEFINER RPCs, etc.).
|
||||
--
|
||||
-- PR #6384 ("SaaS Consolidation") accidentally added a parallel `supabase_id`
|
||||
-- column via Flyway V2 — same purpose, different name. Java's User entity then
|
||||
-- mapped to this new column. The result was a split-brain:
|
||||
-- * Pre-#6384 users had supabase_auth_id populated, supabase_id NULL.
|
||||
-- * Post-#6384 users had supabase_id populated, supabase_auth_id NULL.
|
||||
-- * RLS policies + RPCs always check supabase_auth_id, so post-#6384 users
|
||||
-- failed every membership check.
|
||||
--
|
||||
-- This migration:
|
||||
-- 1. Backfills supabase_auth_id from supabase_id where the former is NULL.
|
||||
-- 2. Drops the supabase_id column and its unique index.
|
||||
--
|
||||
-- The Java User entity has been switched to @Column(name = "supabase_auth_id")
|
||||
-- in the same change-set; this migration assumes the new code is already
|
||||
-- deployed (or will be deployed together with this migration).
|
||||
|
||||
-- 1. Backfill the canonical column from the duplicate, where needed.
|
||||
UPDATE users
|
||||
SET supabase_auth_id = supabase_id
|
||||
WHERE supabase_auth_id IS NULL
|
||||
AND supabase_id IS NOT NULL;
|
||||
|
||||
-- 2. Drop the duplicate column. IF EXISTS guards against environments where
|
||||
-- the column was already removed manually.
|
||||
DROP INDEX IF EXISTS uk_users_supabase_id;
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS supabase_id;
|
||||
@@ -0,0 +1,95 @@
|
||||
-- PAYG free allowance: monthly per-cycle allowance → one-time LIFETIME grant.
|
||||
--
|
||||
-- Product decision (2026-06-11): every team gets a one-time free document grant. It does NOT
|
||||
-- replenish monthly and is NOT lost when the team subscribes — they keep whatever is unused.
|
||||
--
|
||||
-- Mechanics: the grant is tracked as a running counter on the team sidecar
|
||||
-- (payg_team_extensions.free_units_remaining), seeded once from the team's effective pricing
|
||||
-- policy and maintained by the charge pipeline (deducted when a billable DEBIT is written,
|
||||
-- restored on a first-step refund). Because the counter is authoritative, the wallet_ledger is
|
||||
-- no longer the source of truth for the grant and its old rows can be pruned after a retention
|
||||
-- window (separate future job).
|
||||
--
|
||||
-- See notes/payg-lifetime-free-grant-plan-2026-06-11.html.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. Rename the policy column — it is no longer "per cycle", it's the one-time grant size.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.pricing_policy
|
||||
RENAME COLUMN free_tier_units_per_cycle TO free_tier_units;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.pricing_policy.free_tier_units IS
|
||||
'One-time lifetime free document grant handed to a team on creation (copied into '
|
||||
'payg_team_extensions.free_units_remaining). NOT per-cycle: it never replenishes and '
|
||||
'survives subscribing. 0 = no free grant (block / meter from the first document).';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. The running counter on the team sidecar.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_team_extensions
|
||||
ADD COLUMN IF NOT EXISTS free_units_remaining BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.payg_team_extensions.free_units_remaining IS
|
||||
'Remaining one-time free documents for this team. Seeded from the effective pricing '
|
||||
'policy''s free_tier_units at row creation; decremented by min(jobUnits, remaining) when a '
|
||||
'billable charge is written; restored on a first-step refund. Lifetime — never resets. '
|
||||
'Authoritative source for the free grant (independent of wallet_ledger retention).';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 3. Per-job free/paid split on the shadow row — makes metering + refunds exact and removes
|
||||
-- any need to SUM the ledger over a team's lifetime.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
ALTER TABLE stirling_pdf.payg_shadow_charge
|
||||
ADD COLUMN IF NOT EXISTS free_units_consumed INT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN stirling_pdf.payg_shadow_charge.free_units_consumed IS
|
||||
'How many of this job''s payg_units came out of the team''s free grant at charge time. '
|
||||
'Paid (metered) units = payg_units - free_units_consumed. A refund restores this many '
|
||||
'units to payg_team_extensions.free_units_remaining.';
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 4. Seed the counter at team creation. Replace the V14 trigger function so new teams get the
|
||||
-- default policy's grant from minute one. (The trigger itself still points at this function.)
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION stirling_pdf.payg_create_team_extensions_trigger()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO stirling_pdf.payg_team_extensions(team_id, free_units_remaining)
|
||||
VALUES (
|
||||
NEW.team_id,
|
||||
COALESCE(
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.is_default = TRUE LIMIT 1),
|
||||
0)
|
||||
)
|
||||
ON CONFLICT (team_id) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 5. Backfill existing teams. remaining = max(0, grant - lifetime_consumed). lifetime_consumed
|
||||
-- is -SUM(amount_units) over the team's DEBIT+REFUND ledger entries (debits negative, refunds
|
||||
-- positive), so grant + SUM(amount_units) collapses to grant - consumed. One-time read of the
|
||||
-- ledger; after this the counter stands alone. Grant = team override policy, else the default.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
|
||||
UPDATE stirling_pdf.payg_team_extensions ext
|
||||
SET free_units_remaining = GREATEST(
|
||||
0,
|
||||
COALESCE(
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.policy_id = ext.pricing_policy_id),
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.is_default = TRUE LIMIT 1),
|
||||
0)
|
||||
+ COALESCE(
|
||||
(SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl
|
||||
WHERE wl.team_id = ext.team_id
|
||||
AND wl.entry_type IN ('DEBIT', 'REFUND')),
|
||||
0));
|
||||
@@ -0,0 +1,42 @@
|
||||
-- PAYG launch free grant: give the default pricing policy a real one-time grant.
|
||||
--
|
||||
-- V14 added pricing_policy.free_tier_units with DEFAULT 0, and the default policy seeded in V12
|
||||
-- predates the column — so on a fresh deploy every team's free_units_remaining seeds to 0 and
|
||||
-- V19's "every team gets a one-time free grant" intent ships dead (teams are gated / metered from
|
||||
-- the very first billable document). This migration sets the launch grant on the default policy
|
||||
-- and re-seeds existing teams that V19 left at 0 (V19 ran its backfill while the grant was still
|
||||
-- 0, so every then-existing team computed to 0).
|
||||
--
|
||||
-- The launch value lives on the default policy row; tune it there (or via a future admin surface).
|
||||
-- Both updates are guarded so a deliberately-tuned value — e.g. a smaller test grant — is never
|
||||
-- clobbered.
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 1. Launch grant on the default policy, only where it's still the accidental 0.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
UPDATE stirling_pdf.pricing_policy
|
||||
SET free_tier_units = 500
|
||||
WHERE is_default = TRUE
|
||||
AND free_tier_units = 0;
|
||||
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
-- 2. Re-seed existing teams V19 left at 0. Same recompute as V19's backfill — remaining =
|
||||
-- max(0, grant + net signed DEBIT/REFUND) — now that the grant is non-zero. Guarded to
|
||||
-- free_units_remaining = 0: a team with a deliberately-set positive balance is left alone, and
|
||||
-- a team that genuinely exhausted a real grant also recomputes to 0, so the guard is safe.
|
||||
-- ---------------------------------------------------------------------------------------------
|
||||
UPDATE stirling_pdf.payg_team_extensions ext
|
||||
SET free_units_remaining = GREATEST(
|
||||
0,
|
||||
COALESCE(
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.policy_id = ext.pricing_policy_id),
|
||||
(SELECT pp.free_tier_units FROM stirling_pdf.pricing_policy pp
|
||||
WHERE pp.is_default = TRUE LIMIT 1),
|
||||
0)
|
||||
+ COALESCE(
|
||||
(SELECT SUM(wl.amount_units) FROM stirling_pdf.wallet_ledger wl
|
||||
WHERE wl.team_id = ext.team_id
|
||||
AND wl.entry_type IN ('DEBIT', 'REFUND')),
|
||||
0))
|
||||
WHERE ext.free_units_remaining = 0;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-- Drop the unused wallet_category_summary view.
|
||||
--
|
||||
-- V16 created this view to back the wallet's per-category spend breakdown via
|
||||
-- WalletCategorySummaryDao. That DAO was never wired up — the breakdown is built from the JPA
|
||||
-- repository (WalletLedgerRepository.sumPeriodAmountByCategory) instead — so both the DAO and this
|
||||
-- view have zero readers. The DAO is deleted in the same change; this drops the dead view.
|
||||
--
|
||||
-- Done as a new migration (not by editing V16) so Flyway's checksum validation doesn't fail on
|
||||
-- databases that already applied V16. IF EXISTS keeps it safe on DBs where V16 hasn't run.
|
||||
|
||||
DROP VIEW IF EXISTS stirling_pdf.wallet_category_summary;
|
||||
@@ -0,0 +1,489 @@
|
||||
package stirling.software.saas.payg.api;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.model.TeamMembership;
|
||||
import stirling.software.saas.payg.api.PaygWalletController.UpdateCapRequest;
|
||||
import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow;
|
||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementService;
|
||||
import stirling.software.saas.payg.entitlement.EntitlementSnapshot;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
import stirling.software.saas.repository.TeamMembershipRepository;
|
||||
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Pure-Mockito unit tests for {@link PaygWalletController}. Covers the documented role / state
|
||||
* matrix — free vs subscribed, leader vs member, anonymous — plus the cap update endpoint's
|
||||
* leader-only enforcement and cache invalidation.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PaygWalletControllerTest {
|
||||
|
||||
@Mock private EntitlementService entitlementService;
|
||||
@Mock private TeamBillingService billingService;
|
||||
@Mock private TeamMembershipRepository memberRepo;
|
||||
@Mock private PaygTeamExtensionsRepository extRepo;
|
||||
@Mock private WalletPolicyRepository policyRepo;
|
||||
@Mock private WalletLedgerRepository ledgerRepo;
|
||||
@Mock private PaygShadowChargeRepository shadowRepo;
|
||||
@Mock private UserRepository userRepository;
|
||||
|
||||
private PaygWalletController controller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
controller =
|
||||
new PaygWalletController(
|
||||
entitlementService,
|
||||
billingService,
|
||||
memberRepo,
|
||||
extRepo,
|
||||
policyRepo,
|
||||
ledgerRepo,
|
||||
shadowRepo,
|
||||
userRepository);
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-team billing context: the one-time grant is fully unused (remaining == grant), no
|
||||
* subscription facts, no monthly cap. The displayed limit comes from the snapshot, not here.
|
||||
*/
|
||||
private static TeamBillingContext freeBilling(long teamFreeGrant) {
|
||||
LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay();
|
||||
return new TeamBillingContext(
|
||||
false,
|
||||
null,
|
||||
start,
|
||||
start.plusMonths(1),
|
||||
teamFreeGrant,
|
||||
teamFreeGrant,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribed billing context with a money cap (minor units) and a known per-doc rate. The grant
|
||||
* is treated as exhausted (remaining 0) — typical for a team that has subscribed; {@code
|
||||
* monthlyCapDocUnits} is the paid-doc ceiling {@code floor(capMoney / rate)}.
|
||||
*/
|
||||
private static TeamBillingContext subscribedBilling(
|
||||
String subscriptionId, Long capMoneyMinor, Long monthlyCapDocUnits) {
|
||||
LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay();
|
||||
return new TeamBillingContext(
|
||||
true,
|
||||
subscriptionId,
|
||||
start,
|
||||
start.plusMonths(1),
|
||||
500L,
|
||||
0L,
|
||||
BigDecimal.valueOf(2),
|
||||
"usd",
|
||||
capMoneyMinor,
|
||||
monthlyCapDocUnits);
|
||||
}
|
||||
|
||||
private void stubEmptyLedgerReads(long teamId) {
|
||||
when(ledgerRepo.sumPeriodAmountByCategory(
|
||||
eq(teamId), eq(LedgerEntryType.DEBIT), any(), any()))
|
||||
.thenReturn(List.of());
|
||||
when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(teamId)).thenReturn(List.of());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// GET /wallet
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void getWallet_freeTier_returnsFreeShape() {
|
||||
User user = userWithId(7L, UUID.randomUUID());
|
||||
Team team = teamWithId(42L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||
when(memberRepo.findPrimaryMembership(7L))
|
||||
.thenReturn(List.of(membership(team, user, TeamRole.MEMBER)));
|
||||
when(billingService.forTeam(42L)).thenReturn(freeBilling(500L));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(snapshot(0L, 500L));
|
||||
stubEmptyLedgerReads(42L);
|
||||
|
||||
ResponseEntity<WalletSnapshotResponse> resp =
|
||||
controller.getWallet(jwtAuth(user.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
WalletSnapshotResponse body = resp.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
assertThat(body.status()).isEqualTo("free");
|
||||
assertThat(body.role()).isEqualTo("member");
|
||||
assertThat(body.capUsd()).isNull();
|
||||
assertThat(body.stripeSubscriptionId()).isNull();
|
||||
assertThat(body.noCap()).isFalse();
|
||||
assertThat(body.billableUsed()).isZero();
|
||||
assertThat(body.billableLimit()).isEqualTo(500);
|
||||
assertThat(body.freeAllowance()).isEqualTo(500);
|
||||
assertThat(body.pricePerDocMinor()).isNull();
|
||||
assertThat(body.currency()).isNull();
|
||||
assertThat(body.estimatedBillMinor()).isNull();
|
||||
assertThat(body.members()).isEmpty();
|
||||
assertThat(body.recent()).isEmpty();
|
||||
assertThat(body.categoryBreakdown().api()).isZero();
|
||||
assertThat(body.categoryBreakdown().ai()).isZero();
|
||||
assertThat(body.categoryBreakdown().automation()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWallet_subscribedMember_returnsCapAndBreakdownFromLedger() {
|
||||
User user = userWithId(8L, UUID.randomUUID());
|
||||
Team team = teamWithId(99L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||
when(memberRepo.findPrimaryMembership(8L))
|
||||
.thenReturn(List.of(membership(team, user, TeamRole.MEMBER)));
|
||||
// $25 cap (2500 minor) at $0.02/doc → 1250 paid docs/month (the one-time grant is a
|
||||
// separate pool, not added to the cap).
|
||||
when(billingService.forTeam(99L))
|
||||
.thenReturn(subscribedBilling("sub_test_99", 2500L, 1250L));
|
||||
// 312 paid (metered) docs this period → estimate 312 × $0.02 = $6.24 (624 minor).
|
||||
when(shadowRepo.sumPaidUnits(eq(99L), any(), any())).thenReturn(312L);
|
||||
when(billingService.estimateBillMinor(any(), eq(312L))).thenReturn(Optional.of(624L));
|
||||
when(entitlementService.getSnapshot(99L)).thenReturn(snapshot(312L, 1250L));
|
||||
when(ledgerRepo.sumPeriodAmountByCategory(eq(99L), eq(LedgerEntryType.DEBIT), any(), any()))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new Object[] {BillingCategory.API, 110L},
|
||||
new Object[] {BillingCategory.AI, 200L},
|
||||
new Object[] {BillingCategory.AUTOMATION, 2L}));
|
||||
when(ledgerRepo.findTop20ByTeamIdOrderByIdDesc(99L)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<WalletSnapshotResponse> resp =
|
||||
controller.getWallet(jwtAuth(user.getSupabaseId()));
|
||||
|
||||
WalletSnapshotResponse body = resp.getBody();
|
||||
assertThat(body.status()).isEqualTo("subscribed");
|
||||
assertThat(body.role()).isEqualTo("member");
|
||||
assertThat(body.capUsd()).isEqualTo(25);
|
||||
assertThat(body.noCap()).isFalse();
|
||||
assertThat(body.billableUsed()).isEqualTo(312);
|
||||
assertThat(body.spendUnitsThisPeriod()).isEqualTo(312);
|
||||
assertThat(body.billableLimit()).isEqualTo(1250);
|
||||
assertThat(body.freeAllowance()).isEqualTo(500);
|
||||
assertThat(body.pricePerDocMinor()).isEqualByComparingTo(BigDecimal.valueOf(2));
|
||||
assertThat(body.currency()).isEqualTo("usd");
|
||||
assertThat(body.estimatedBillMinor()).isEqualTo(624L);
|
||||
assertThat(body.members()).isEmpty();
|
||||
assertThat(body.categoryBreakdown().api()).isEqualTo(110);
|
||||
assertThat(body.categoryBreakdown().ai()).isEqualTo(200);
|
||||
assertThat(body.categoryBreakdown().automation()).isEqualTo(2);
|
||||
assertThat(body.stripeSubscriptionId()).isEqualTo("sub_test_99");
|
||||
// Member role → ledger never queried per-user.
|
||||
verify(ledgerRepo, never()).sumPeriodAmountForMember(any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWallet_subscribedNoCap_returnsNoCapTrueAndNullLimit() {
|
||||
User user = userWithId(9L, UUID.randomUUID());
|
||||
Team team = teamWithId(11L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||
when(memberRepo.findPrimaryMembership(9L))
|
||||
.thenReturn(List.of(membership(team, user, TeamRole.LEADER)));
|
||||
when(billingService.forTeam(11L)).thenReturn(subscribedBilling("sub_nocap", null, null));
|
||||
when(entitlementService.getSnapshot(11L)).thenReturn(snapshot(50L, null));
|
||||
stubEmptyLedgerReads(11L);
|
||||
when(memberRepo.findByTeamId(11L)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<WalletSnapshotResponse> resp =
|
||||
controller.getWallet(jwtAuth(user.getSupabaseId()));
|
||||
|
||||
WalletSnapshotResponse body = resp.getBody();
|
||||
assertThat(body.status()).isEqualTo("subscribed");
|
||||
assertThat(body.role()).isEqualTo("leader");
|
||||
assertThat(body.capUsd()).isNull();
|
||||
assertThat(body.noCap()).isTrue();
|
||||
// Uncapped → no document ceiling to draw a bar against.
|
||||
assertThat(body.billableLimit()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWallet_leader_populatesMembers() {
|
||||
User leader = userWithId(10L, UUID.randomUUID());
|
||||
User member = userWithId(11L, UUID.randomUUID());
|
||||
member.setUsername("alice");
|
||||
member.setEmail("[email protected]");
|
||||
Team team = teamWithId(77L);
|
||||
TeamMembership leaderRow = membership(team, leader, TeamRole.LEADER);
|
||||
TeamMembership memberRow = membership(team, member, TeamRole.MEMBER);
|
||||
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
|
||||
when(memberRepo.findPrimaryMembership(10L)).thenReturn(List.of(leaderRow));
|
||||
when(billingService.forTeam(77L)).thenReturn(freeBilling(500L));
|
||||
when(entitlementService.getSnapshot(77L)).thenReturn(snapshot(0L, 500L));
|
||||
stubEmptyLedgerReads(77L);
|
||||
when(memberRepo.findByTeamId(77L)).thenReturn(List.of(leaderRow, memberRow));
|
||||
// Ledger returns signed (negative) debits.
|
||||
when(ledgerRepo.sumPeriodAmountForMember(eq(77L), any(), any(), any(), any()))
|
||||
.thenReturn(-42L);
|
||||
|
||||
ResponseEntity<WalletSnapshotResponse> resp =
|
||||
controller.getWallet(jwtAuth(leader.getSupabaseId()));
|
||||
|
||||
WalletSnapshotResponse body = resp.getBody();
|
||||
assertThat(body.role()).isEqualTo("leader");
|
||||
assertThat(body.members()).hasSize(2);
|
||||
MemberRow secondMember =
|
||||
body.members().stream()
|
||||
.filter(m -> "alice".equals(m.name()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(secondMember.email()).isEqualTo("[email protected]");
|
||||
assertThat(secondMember.spendUnits()).isEqualTo(42);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWallet_anonymousIsRejected() {
|
||||
Authentication anon =
|
||||
new AnonymousAuthenticationToken(
|
||||
"k",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
|
||||
|
||||
ResponseEntity<WalletSnapshotResponse> resp = controller.getWallet(anon);
|
||||
|
||||
// AuthenticationUtils.getCurrentUser throws SecurityException for "anonymousUser" since it
|
||||
// has no Supabase id and is not a User principal — the controller maps that to 401.
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(entitlementService, billingService, memberRepo, extRepo, policyRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWallet_authenticatedNoTeam_returnsEmptyFreeShape() {
|
||||
User user = userWithId(12L, UUID.randomUUID());
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||
when(memberRepo.findPrimaryMembership(12L)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<WalletSnapshotResponse> resp =
|
||||
controller.getWallet(jwtAuth(user.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(resp.getBody().status()).isEqualTo("free");
|
||||
assertThat(resp.getBody().members()).isEmpty();
|
||||
// Entitlement service must not be queried for a teamless user (avoids null-key NPE).
|
||||
verifyNoInteractions(entitlementService);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// PATCH /cap
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void updateCap_leaderUpdatesUnitsAndInvalidates() {
|
||||
User leader = userWithId(20L, UUID.randomUUID());
|
||||
Team team = teamWithId(33L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
|
||||
when(memberRepo.findPrimaryMembership(20L))
|
||||
.thenReturn(List.of(membership(team, leader, TeamRole.LEADER)));
|
||||
when(policyRepo.findByTeamId(33L)).thenReturn(Optional.empty());
|
||||
|
||||
ResponseEntity<Void> resp =
|
||||
controller.updateCap(
|
||||
new UpdateCapRequest(40, false), jwtAuth(leader.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
ArgumentCaptor<WalletPolicy> saved = ArgumentCaptor.forClass(WalletPolicy.class);
|
||||
verify(policyRepo).save(saved.capture());
|
||||
// Rate unknown in this test (docCapForMoney → empty) → legacy conversion fallback so
|
||||
// the cap stays enforced rather than silently lifting.
|
||||
assertThat(saved.getValue().getCapUnits()).isEqualTo(4000L); // 40 USD * 100 units/USD
|
||||
assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(4000L); // 40 USD == 4000 cents
|
||||
verify(entitlementService, times(1)).invalidate(33L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateCap_withKnownRate_storesDerivedDocCap() {
|
||||
User leader = userWithId(24L, UUID.randomUUID());
|
||||
Team team = teamWithId(36L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
|
||||
when(memberRepo.findPrimaryMembership(24L))
|
||||
.thenReturn(List.of(membership(team, leader, TeamRole.LEADER)));
|
||||
when(policyRepo.findByTeamId(36L)).thenReturn(Optional.empty());
|
||||
TeamBillingContext billing = subscribedBilling("sub_36", null, null);
|
||||
when(billingService.forTeam(36L)).thenReturn(billing);
|
||||
// $28 cap at $0.02/doc → 1400 paid documents/month (the grant is a separate pool).
|
||||
when(billingService.docCapForMoney(billing, 2800L)).thenReturn(Optional.of(1400L));
|
||||
|
||||
ResponseEntity<Void> resp =
|
||||
controller.updateCap(
|
||||
new UpdateCapRequest(28, false), jwtAuth(leader.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
ArgumentCaptor<WalletPolicy> saved = ArgumentCaptor.forClass(WalletPolicy.class);
|
||||
verify(policyRepo).save(saved.capture());
|
||||
assertThat(saved.getValue().getCapSourceMoney()).isEqualTo(2800L);
|
||||
assertThat(saved.getValue().getCapUnits()).isEqualTo(1400L);
|
||||
verify(entitlementService).invalidate(36L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateCap_noCapTrue_clearsCapUnits() {
|
||||
User leader = userWithId(21L, UUID.randomUUID());
|
||||
Team team = teamWithId(34L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(leader));
|
||||
when(memberRepo.findPrimaryMembership(21L))
|
||||
.thenReturn(List.of(membership(team, leader, TeamRole.LEADER)));
|
||||
WalletPolicy existing = new WalletPolicy();
|
||||
existing.setTeamId(34L);
|
||||
existing.setCapUnits(1000L);
|
||||
existing.setCapSourceMoney(1000L);
|
||||
when(policyRepo.findByTeamId(34L)).thenReturn(Optional.of(existing));
|
||||
|
||||
ResponseEntity<Void> resp =
|
||||
controller.updateCap(
|
||||
new UpdateCapRequest(0, true), jwtAuth(leader.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
|
||||
ArgumentCaptor<WalletPolicy> saved = ArgumentCaptor.forClass(WalletPolicy.class);
|
||||
verify(policyRepo).save(saved.capture());
|
||||
assertThat(saved.getValue().getCapUnits()).isNull();
|
||||
assertThat(saved.getValue().getCapSourceMoney()).isNull();
|
||||
verify(entitlementService).invalidate(34L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateCap_memberIsForbidden() {
|
||||
User member = userWithId(22L, UUID.randomUUID());
|
||||
Team team = teamWithId(35L);
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(member));
|
||||
when(memberRepo.findPrimaryMembership(22L))
|
||||
.thenReturn(List.of(membership(team, member, TeamRole.MEMBER)));
|
||||
|
||||
ResponseEntity<Void> resp =
|
||||
controller.updateCap(
|
||||
new UpdateCapRequest(50, false), jwtAuth(member.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verify(policyRepo, never()).save(any());
|
||||
verify(entitlementService, never()).invalidate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateCap_noTeam_isForbidden() {
|
||||
User user = userWithId(23L, UUID.randomUUID());
|
||||
when(userRepository.findBySupabaseId(any())).thenReturn(Optional.of(user));
|
||||
when(memberRepo.findPrimaryMembership(23L)).thenReturn(List.of());
|
||||
|
||||
ResponseEntity<Void> resp =
|
||||
controller.updateCap(
|
||||
new UpdateCapRequest(10, false), jwtAuth(user.getSupabaseId()));
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verify(policyRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateCap_anonymousIs401() {
|
||||
Authentication anon =
|
||||
new AnonymousAuthenticationToken(
|
||||
"k",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS")));
|
||||
|
||||
ResponseEntity<Void> resp = controller.updateCap(new UpdateCapRequest(10, false), anon);
|
||||
|
||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
verifyNoInteractions(policyRepo, entitlementService);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// -----------------------------------------------------------------------------------------
|
||||
|
||||
private static User userWithId(Long id, UUID supabaseId) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setSupabaseId(supabaseId);
|
||||
return u;
|
||||
}
|
||||
|
||||
private static Team teamWithId(Long id) {
|
||||
Team t = new Team();
|
||||
t.setId(id);
|
||||
t.setName("t-" + id);
|
||||
return t;
|
||||
}
|
||||
|
||||
private static TeamMembership membership(Team team, User user, TeamRole role) {
|
||||
TeamMembership m = new TeamMembership();
|
||||
m.setTeam(team);
|
||||
m.setUser(user);
|
||||
m.setRole(role);
|
||||
return m;
|
||||
}
|
||||
|
||||
private static EntitlementSnapshot snapshot(long spend, Long cap) {
|
||||
LocalDateTime start = LocalDate.now().withDayOfMonth(1).atStartOfDay();
|
||||
LocalDateTime end = start.plusMonths(1);
|
||||
return new EntitlementSnapshot(
|
||||
EntitlementState.FULL,
|
||||
FeatureSet.FULL,
|
||||
List.of(
|
||||
FeatureGate.OFFSITE_PROCESSING,
|
||||
FeatureGate.AUTOMATION,
|
||||
FeatureGate.AI_SUPPORT,
|
||||
FeatureGate.CLIENT_SIDE),
|
||||
spend,
|
||||
cap,
|
||||
start,
|
||||
end,
|
||||
false);
|
||||
}
|
||||
|
||||
private static Authentication jwtAuth(UUID supabaseId) {
|
||||
Jwt jwt =
|
||||
Jwt.withTokenValue("token")
|
||||
.header("alg", "RS256")
|
||||
.claim("sub", supabaseId.toString())
|
||||
.claim("email", "[email protected]")
|
||||
.build();
|
||||
return new EnhancedJwtAuthenticationToken(
|
||||
jwt, List.of(), "[email protected]", supabaseId.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package stirling.software.saas.payg.cap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.saas.payg.cap.CapEvaluator.Evaluation;
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
|
||||
class CapEvaluatorTest {
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// evaluate(): single-axis cap evaluation
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void nullCap_returnsFullStateAndFullGates() {
|
||||
Evaluation e = CapEvaluator.evaluate(1_000_000L, null, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
|
||||
assertThat(e.enabledGates())
|
||||
.containsExactlyInAnyOrder(
|
||||
FeatureGate.OFFSITE_PROCESSING,
|
||||
FeatureGate.AUTOMATION,
|
||||
FeatureGate.AI_SUPPORT,
|
||||
FeatureGate.CLIENT_SIDE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroCap_treatedAsUnlimitedForSafety() {
|
||||
// Defensive: a zero cap would divide-by-zero. The guard treats it as null (FULL).
|
||||
Evaluation e = CapEvaluator.evaluate(50L, 0L, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void wellBelowWarn_returnsFull() {
|
||||
Evaluation e = CapEvaluator.evaluate(10L, 100L, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactlyAtWarnThreshold_returnsWarned() {
|
||||
// 80% of 100 = 80; pct = 80 / 100 * 100 = 80 → ≥ warnAtPct=80 → WARNED
|
||||
Evaluation e = CapEvaluator.evaluate(80L, 100L, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.WARNED);
|
||||
// Warn band keeps the FULL feature set — only flags the FE to show a banner.
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
|
||||
assertThat(e.enabledGates()).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void betweenWarnAndDegrade_returnsWarned() {
|
||||
Evaluation e = CapEvaluator.evaluate(95L, 100L, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.WARNED);
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactlyAtDegradeThreshold_returnsDegradedWithConfiguredSet() {
|
||||
Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
|
||||
// MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; only
|
||||
// AUTOMATION + AI_SUPPORT are blocked.
|
||||
assertThat(e.enabledGates())
|
||||
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overDegradeThreshold_returnsDegradedAndCannotEscalate() {
|
||||
Evaluation e = CapEvaluator.evaluate(500L, 100L, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void degradedFeatureSetClientOnly_dropsEverythingButClientSide() {
|
||||
Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, FeatureSet.CLIENT_ONLY);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.CLIENT_ONLY);
|
||||
assertThat(e.enabledGates()).containsExactly(FeatureGate.CLIENT_SIDE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullDegradedFeatureSet_fallsBackToMinimal() {
|
||||
Evaluation e = CapEvaluator.evaluate(100L, 100L, 80, 100, null);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.DEGRADED);
|
||||
assertThat(e.featureSet()).isEqualTo(FeatureSet.MINIMAL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void misconfiguredThresholds_treatedAsNoCap() {
|
||||
// warn > degrade is malformed; the evaluator returns FULL rather than risk wrong
|
||||
// degradation. The admin write path is supposed to validate this.
|
||||
Evaluation e = CapEvaluator.evaluate(100L, 100L, 110, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
|
||||
|
||||
// negative warn → bad config → FULL
|
||||
Evaluation neg = CapEvaluator.evaluate(100L, 100L, -10, 100, FeatureSet.MINIMAL);
|
||||
assertThat(neg.state()).isEqualTo(EntitlementState.FULL);
|
||||
|
||||
// zero degrade → bad config → FULL (would otherwise degrade on any spend)
|
||||
Evaluation zero = CapEvaluator.evaluate(0L, 100L, 80, 0, FeatureSet.MINIMAL);
|
||||
assertThat(zero.state()).isEqualTo(EntitlementState.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroSpend_returnsFullEvenIfCapTiny() {
|
||||
Evaluation e = CapEvaluator.evaluate(0L, 1L, 80, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void warnAtZeroPct_warnsImmediately() {
|
||||
// Edge case: warn-at-0% means any spend → WARNED. Allowed even if quirky.
|
||||
Evaluation e = CapEvaluator.evaluate(1L, 100L, 0, 100, FeatureSet.MINIMAL);
|
||||
assertThat(e.state()).isEqualTo(EntitlementState.WARNED);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// gatesFor(): mapping FeatureSet → declared gates
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void gatesFor_full_listsAllFour() {
|
||||
assertThat(CapEvaluator.gatesFor(FeatureSet.FULL))
|
||||
.containsExactlyInAnyOrder(
|
||||
FeatureGate.OFFSITE_PROCESSING,
|
||||
FeatureGate.AUTOMATION,
|
||||
FeatureGate.AI_SUPPORT,
|
||||
FeatureGate.CLIENT_SIDE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gatesFor_minimal_keepsOffsiteAndClientSide() {
|
||||
// MINIMAL keeps manual server tools (OFFSITE_PROCESSING) + client-side; AUTOMATION +
|
||||
// AI_SUPPORT are the only gates dropped on degrade.
|
||||
assertThat(CapEvaluator.gatesFor(FeatureSet.MINIMAL))
|
||||
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
|
||||
}
|
||||
|
||||
@Test
|
||||
void gatesFor_null_returnsEmpty() {
|
||||
assertThat(CapEvaluator.gatesFor(null)).isEmpty();
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+537
-11
@@ -17,14 +17,18 @@ import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.saas.payg.docs.DocumentClassifier;
|
||||
@@ -33,15 +37,24 @@ import stirling.software.saas.payg.job.JobContext;
|
||||
import stirling.software.saas.payg.job.JobService;
|
||||
import stirling.software.saas.payg.job.JoinOrOpenResult;
|
||||
import stirling.software.saas.payg.job.ProcessingJob;
|
||||
import stirling.software.saas.payg.meter.PaygMeterReportingService;
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.model.JobSource;
|
||||
import stirling.software.saas.payg.model.JobStatus;
|
||||
import stirling.software.saas.payg.model.LedgerBucket;
|
||||
import stirling.software.saas.payg.model.LedgerEntryType;
|
||||
import stirling.software.saas.payg.model.ProcessType;
|
||||
import stirling.software.saas.payg.model.ReferenceType;
|
||||
import stirling.software.saas.payg.model.ShadowChargeStatus;
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
import stirling.software.saas.payg.policy.PricingPolicy;
|
||||
import stirling.software.saas.payg.policy.PricingPolicyService;
|
||||
import stirling.software.saas.payg.repository.PaygShadowChargeRepository;
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
import stirling.software.saas.payg.repository.ProcessingJobRepository;
|
||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
||||
import stirling.software.saas.payg.shadow.PaygShadowCharge;
|
||||
import stirling.software.saas.payg.wallet.WalletLedgerEntry;
|
||||
|
||||
/**
|
||||
* Exercises {@link JobChargeService} as an orchestrator: policy lookup, step-limit resolution,
|
||||
@@ -54,6 +67,9 @@ class JobChargeServiceTest {
|
||||
private DocumentClassifier classifier;
|
||||
private PaygShadowChargeRepository shadowRepo;
|
||||
private ProcessingJobRepository jobRepo;
|
||||
private PaygTeamExtensionsRepository teamExtRepo;
|
||||
private PaygMeterReportingService meterReporter;
|
||||
private WalletLedgerRepository ledgerRepo;
|
||||
private JobChargeService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -63,7 +79,31 @@ class JobChargeServiceTest {
|
||||
classifier = Mockito.mock(DocumentClassifier.class);
|
||||
shadowRepo = Mockito.mock(PaygShadowChargeRepository.class);
|
||||
jobRepo = Mockito.mock(ProcessingJobRepository.class);
|
||||
service = new JobChargeService(jobService, policyService, classifier, shadowRepo, jobRepo);
|
||||
teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
|
||||
meterReporter = Mockito.mock(PaygMeterReportingService.class);
|
||||
ledgerRepo = Mockito.mock(WalletLedgerRepository.class);
|
||||
// findByIdForUpdate defaults to Optional.empty() (Mockito) → no free grant consumed unless
|
||||
// a test stubs the sidecar row. The free split is decided at openProcess time now, not at
|
||||
// close, so the meter tests just set free_units_consumed on the shadow row directly.
|
||||
service =
|
||||
new JobChargeService(
|
||||
jobService,
|
||||
policyService,
|
||||
classifier,
|
||||
shadowRepo,
|
||||
jobRepo,
|
||||
teamExtRepo,
|
||||
meterReporter,
|
||||
ledgerRepo);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// Defensive: a previous test could have left a fake synchronization registered. Clearing
|
||||
// ensures isolation when tests run in any order.
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -80,7 +120,12 @@ class JobChargeServiceTest {
|
||||
|
||||
ChargeOutcome out =
|
||||
service.openProcess(
|
||||
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.WEB,
|
||||
ProcessType.SINGLE_TOOL,
|
||||
BillingCategory.API),
|
||||
List.of(in));
|
||||
|
||||
assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.JOINED);
|
||||
@@ -89,6 +134,7 @@ class JobChargeServiceTest {
|
||||
verify(classifier, never()).classify(any(MultipartFile.class), any());
|
||||
verify(classifier, never()).classify(anyList(), any());
|
||||
verify(shadowRepo, never()).save(any());
|
||||
verify(ledgerRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -106,7 +152,12 @@ class JobChargeServiceTest {
|
||||
|
||||
ChargeOutcome out =
|
||||
service.openProcess(
|
||||
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.WEB,
|
||||
ProcessType.SINGLE_TOOL,
|
||||
BillingCategory.API),
|
||||
List.of(in));
|
||||
|
||||
assertThat(out.disposition()).isEqualTo(ChargeOutcome.Disposition.OPENED);
|
||||
@@ -126,9 +177,82 @@ class JobChargeServiceTest {
|
||||
// Legacy comparison not wired yet — zeroed until CreditService is wired in the follow-up.
|
||||
assertThat(row.getLegacyCreditsCharged()).isZero();
|
||||
assertThat(row.getDiffPct()).isZero();
|
||||
// PAYG analytics axis: billing_category + job_source are copied from the context so the
|
||||
// row stays self-describing after processing_job rows are pruned.
|
||||
assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.API);
|
||||
assertThat(row.getJobSource()).isEqualTo(JobSource.WEB);
|
||||
|
||||
// Job entity carries the classified docUnits so close-time receipts can render correctly.
|
||||
assertThat(newJob.getDocUnits()).isEqualTo(4);
|
||||
|
||||
// Live ledger DEBIT mirrors the shadow row: same units, stored NEGATIVE per the
|
||||
// wallet_ledger sign convention, tied back to the job via reference.
|
||||
ArgumentCaptor<WalletLedgerEntry> ledgerCaptor =
|
||||
ArgumentCaptor.forClass(WalletLedgerEntry.class);
|
||||
verify(ledgerRepo).save(ledgerCaptor.capture());
|
||||
WalletLedgerEntry debit = ledgerCaptor.getValue();
|
||||
assertThat(debit.getTeamId()).isEqualTo(100L);
|
||||
assertThat(debit.getActorUserId()).isEqualTo(42L);
|
||||
assertThat(debit.getEntryType()).isEqualTo(LedgerEntryType.DEBIT);
|
||||
assertThat(debit.getBucket()).isEqualTo(LedgerBucket.CYCLE);
|
||||
assertThat(debit.getAmountUnits()).isEqualTo(-4);
|
||||
assertThat(debit.getReferenceType()).isEqualTo(ReferenceType.JOB);
|
||||
assertThat(debit.getReferenceId()).isEqualTo(newJob.getId().toString());
|
||||
assertThat(debit.getPolicyId()).isEqualTo(policy.getId());
|
||||
assertThat(debit.getBillingCategory()).isEqualTo(BillingCategory.API);
|
||||
}
|
||||
|
||||
@Test
|
||||
void openProcess_bypassedCategory_writesShadowRowButNoLedgerDebit(@TempDir Path tmp)
|
||||
throws IOException {
|
||||
// Manual UI work is never billed: the shadow row still lands (comparison audit trail)
|
||||
// but the live wallet_ledger must stay untouched.
|
||||
PricingPolicy policy = stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10));
|
||||
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
|
||||
ProcessingJob newJob = openJob(UUID.randomUUID());
|
||||
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
|
||||
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
|
||||
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
|
||||
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
|
||||
|
||||
service.openProcess(
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.WEB,
|
||||
ProcessType.SINGLE_TOOL,
|
||||
BillingCategory.BYPASSED),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
verify(shadowRepo).save(any(PaygShadowCharge.class));
|
||||
verify(ledgerRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void openProcess_openedAutomationContext_writesShadowRowWithAutomationCategory(
|
||||
@TempDir Path tmp) throws IOException {
|
||||
PricingPolicy policy =
|
||||
stubPolicy(/*minCharge*/ 1, Map.of(JobSource.WEB, 10, JobSource.PIPELINE, 20));
|
||||
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
|
||||
ProcessingJob newJob = openJob(UUID.randomUUID());
|
||||
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
|
||||
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
|
||||
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
|
||||
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
|
||||
|
||||
service.openProcess(
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.PIPELINE,
|
||||
ProcessType.AUTOMATION,
|
||||
BillingCategory.AUTOMATION),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
|
||||
verify(shadowRepo).save(captor.capture());
|
||||
assertThat(captor.getValue().getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION);
|
||||
assertThat(captor.getValue().getJobSource()).isEqualTo(JobSource.PIPELINE);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -146,7 +270,12 @@ class JobChargeServiceTest {
|
||||
|
||||
ChargeOutcome out =
|
||||
service.openProcess(
|
||||
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.AUTOMATION),
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.WEB,
|
||||
ProcessType.AUTOMATION,
|
||||
BillingCategory.AUTOMATION),
|
||||
List.of(a, b));
|
||||
|
||||
assertThat(out.units()).isEqualTo(7);
|
||||
@@ -167,12 +296,107 @@ class JobChargeServiceTest {
|
||||
|
||||
ChargeOutcome out =
|
||||
service.openProcess(
|
||||
new ChargeContext(42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.WEB,
|
||||
ProcessType.SINGLE_TOOL,
|
||||
BillingCategory.API),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
assertThat(out.units()).isEqualTo(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void openProcess_drawsFreeGrant_storesSplitAndDecrementsCounter(@TempDir Path tmp)
|
||||
throws IOException {
|
||||
// Team has 10 free units left; a 4-unit job draws all 4 from the grant. The shadow row
|
||||
// records free_units_consumed = 4 (so nothing meters) and the counter drops to 6.
|
||||
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
|
||||
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
|
||||
ProcessingJob newJob = openJob(UUID.randomUUID());
|
||||
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
|
||||
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
|
||||
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
|
||||
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 4));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setFreeUnitsRemaining(10L);
|
||||
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
|
||||
|
||||
service.openProcess(
|
||||
new ChargeContext(
|
||||
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
|
||||
verify(shadowRepo).save(captor.capture());
|
||||
assertThat(captor.getValue().getPaygUnits()).isEqualTo(4);
|
||||
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(4);
|
||||
// Counter decremented in-place and persisted.
|
||||
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(6L);
|
||||
verify(teamExtRepo).save(ext);
|
||||
}
|
||||
|
||||
@Test
|
||||
void openProcess_grantStraddle_drawsRemainderFreeAndBillsTheRest(@TempDir Path tmp)
|
||||
throws IOException {
|
||||
// Only 3 free units left; a 10-unit job takes the 3 (counter → 0) and the other 7 bill.
|
||||
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
|
||||
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
|
||||
ProcessingJob newJob = openJob(UUID.randomUUID());
|
||||
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
|
||||
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
|
||||
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
|
||||
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 10));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setFreeUnitsRemaining(3L);
|
||||
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
|
||||
|
||||
service.openProcess(
|
||||
new ChargeContext(
|
||||
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
|
||||
verify(shadowRepo).save(captor.capture());
|
||||
assertThat(captor.getValue().getPaygUnits()).isEqualTo(10);
|
||||
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(3);
|
||||
assertThat(ext.getFreeUnitsRemaining()).isZero();
|
||||
verify(teamExtRepo).save(ext);
|
||||
}
|
||||
|
||||
@Test
|
||||
void openProcess_exhaustedGrant_storesZeroFreeAndLeavesCounterUntouched(@TempDir Path tmp)
|
||||
throws IOException {
|
||||
// Grant already at 0 → nothing free, full units bill, counter not re-saved.
|
||||
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
|
||||
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
|
||||
ProcessingJob newJob = openJob(UUID.randomUUID());
|
||||
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
|
||||
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
|
||||
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
|
||||
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 5));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setFreeUnitsRemaining(0L);
|
||||
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
|
||||
|
||||
service.openProcess(
|
||||
new ChargeContext(
|
||||
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
|
||||
verify(shadowRepo).save(captor.capture());
|
||||
assertThat(captor.getValue().getFreeUnitsConsumed()).isZero();
|
||||
verify(teamExtRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void openProcess_resolvesStepLimitFromPolicy_perJobSource(@TempDir Path tmp)
|
||||
throws IOException {
|
||||
@@ -187,7 +411,12 @@ class JobChargeServiceTest {
|
||||
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
|
||||
|
||||
service.openProcess(
|
||||
new ChargeContext(42L, 100L, JobSource.PIPELINE, ProcessType.AUTOMATION),
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.PIPELINE,
|
||||
ProcessType.AUTOMATION,
|
||||
BillingCategory.AUTOMATION),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
ArgumentCaptor<JobContext> ctxCaptor = ArgumentCaptor.forClass(JobContext.class);
|
||||
@@ -211,7 +440,12 @@ class JobChargeServiceTest {
|
||||
.thenReturn(new DocumentMetrics(1, 100L, "application/pdf", 1));
|
||||
|
||||
service.openProcess(
|
||||
new ChargeContext(42L, 100L, JobSource.DESKTOP_APP, ProcessType.SINGLE_TOOL),
|
||||
new ChargeContext(
|
||||
42L,
|
||||
100L,
|
||||
JobSource.DESKTOP_APP,
|
||||
ProcessType.SINGLE_TOOL,
|
||||
BillingCategory.API),
|
||||
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
|
||||
|
||||
ArgumentCaptor<JobContext> ctxCaptor = ArgumentCaptor.forClass(JobContext.class);
|
||||
@@ -225,7 +459,11 @@ class JobChargeServiceTest {
|
||||
() ->
|
||||
service.openProcess(
|
||||
new ChargeContext(
|
||||
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL),
|
||||
42L,
|
||||
100L,
|
||||
JobSource.WEB,
|
||||
ProcessType.SINGLE_TOOL,
|
||||
BillingCategory.API),
|
||||
List.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("inputs must not be empty");
|
||||
@@ -234,9 +472,8 @@ class JobChargeServiceTest {
|
||||
@Test
|
||||
void markFirstStepFailed_flipsShadowRowAndClosesProcess() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
PaygShadowCharge row = new PaygShadowCharge();
|
||||
row.setJobId(jobId);
|
||||
row.setStatus(ShadowChargeStatus.CHARGED);
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
|
||||
row.setPolicyId(7L);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(java.util.Optional.of(row));
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobRepo.findById(jobId)).thenReturn(java.util.Optional.of(job));
|
||||
@@ -250,6 +487,37 @@ class JobChargeServiceTest {
|
||||
assertThat(job.getClosedAt()).isNotNull();
|
||||
verify(shadowRepo).save(row);
|
||||
verify(jobRepo).save(job);
|
||||
|
||||
// Compensating REFUND entry: positive amount mirroring the openProcess debit, same JOB
|
||||
// reference so the pair nets to zero for the period.
|
||||
ArgumentCaptor<WalletLedgerEntry> ledgerCaptor =
|
||||
ArgumentCaptor.forClass(WalletLedgerEntry.class);
|
||||
verify(ledgerRepo).save(ledgerCaptor.capture());
|
||||
WalletLedgerEntry refund = ledgerCaptor.getValue();
|
||||
assertThat(refund.getTeamId()).isEqualTo(100L);
|
||||
assertThat(refund.getEntryType()).isEqualTo(LedgerEntryType.REFUND);
|
||||
assertThat(refund.getBucket()).isEqualTo(LedgerBucket.CYCLE);
|
||||
assertThat(refund.getAmountUnits()).isEqualTo(4);
|
||||
assertThat(refund.getReferenceType()).isEqualTo(ReferenceType.JOB);
|
||||
assertThat(refund.getReferenceId()).isEqualTo(jobId.toString());
|
||||
assertThat(refund.getPolicyId()).isEqualTo(7L);
|
||||
assertThat(refund.getBillingCategory()).isEqualTo(BillingCategory.API);
|
||||
// This row consumed no free units, so the grant counter is left alone.
|
||||
verify(teamExtRepo, never()).restoreFreeUnits(eq(100L), Mockito.anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void markFirstStepFailed_withFreeConsumed_restoresGrantToCounter() {
|
||||
// A first-step failure is pre-meter: nothing billed to Stripe, but the grant moved at
|
||||
// charge time. The refund must hand exactly those free units back to the team's counter.
|
||||
UUID jobId = UUID.randomUUID();
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId)));
|
||||
|
||||
service.markFirstStepFailed(jobId, "first-step-5xx:503");
|
||||
|
||||
verify(teamExtRepo).restoreFreeUnits(100L, 3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -267,6 +535,8 @@ class JobChargeServiceTest {
|
||||
|
||||
verify(shadowRepo, never()).save(any());
|
||||
verify(jobRepo, never()).save(any());
|
||||
// No double-credit: the REFUND ledger entry only accompanies the CHARGED→REFUNDED flip.
|
||||
verify(ledgerRepo, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -330,6 +600,262 @@ class JobChargeServiceTest {
|
||||
verify(jobRepo, never()).save(any());
|
||||
}
|
||||
|
||||
// --- close() — meter reporting in afterCommit -----------------------------------------------
|
||||
|
||||
@Test
|
||||
void close_subscribedTeam_postsMeterEventAfterCommit() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setStripeCustomerId("cus_subscribed");
|
||||
ext.setPaygSubscriptionId("sub_test");
|
||||
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
|
||||
// Row consumed no free units (free_units_consumed = 0) → all 4 are paid and meter.
|
||||
|
||||
withTransactionSynchronization(
|
||||
() -> {
|
||||
service.close(jobId);
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
});
|
||||
|
||||
// afterCommit ran on tearDown of withTransactionSynchronization → meter posted now.
|
||||
verify(meterReporter)
|
||||
.recordUsage(
|
||||
100L,
|
||||
"cus_subscribed",
|
||||
4,
|
||||
BillingCategory.API,
|
||||
"process:" + jobId + ":close",
|
||||
jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_fullyFreeJob_doesNotPostMeterEvent() {
|
||||
// The free-vs-paid split is fixed at charge time. A job whose 4 units all came from the
|
||||
// one-time grant (free_units_consumed = 4) has nothing left to meter; the ledger DEBIT
|
||||
// alone records the usage.
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, 4, BillingCategory.API);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setStripeCustomerId("cus_subscribed");
|
||||
ext.setPaygSubscriptionId("sub_test");
|
||||
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
|
||||
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_partiallyFreeJob_metersOnlyThePaidPortion() {
|
||||
// 20-unit job that drew 10 from the remaining grant at charge time (free_units_consumed =
|
||||
// 10) → 10 paid units meter to Stripe.
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 20, 10, BillingCategory.AUTOMATION);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setStripeCustomerId("cus_subscribed");
|
||||
ext.setPaygSubscriptionId("sub_test");
|
||||
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
|
||||
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
|
||||
verify(meterReporter)
|
||||
.recordUsage(
|
||||
100L,
|
||||
"cus_subscribed",
|
||||
10,
|
||||
BillingCategory.AUTOMATION,
|
||||
"process:" + jobId + ":close",
|
||||
jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_freeTierTeam_doesNotPostMeterEvent() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
|
||||
// No stripe_customer_id → treated as free-tier on this branch (pre-#6532).
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setStripeCustomerId(null);
|
||||
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
|
||||
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_noTeamExtensionsRow_doesNotPostMeterEvent() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
when(teamExtRepo.findById(100L)).thenReturn(Optional.empty());
|
||||
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_refundedShadowRow_doesNotPostMeterEvent() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.API);
|
||||
row.setStatus(ShadowChargeStatus.REFUNDED);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
Mockito.verifyNoInteractions(teamExtRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_noShadowRow_doesNotPostMeterEvent() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.empty());
|
||||
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
Mockito.verifyNoInteractions(teamExtRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_bypassedCategoryOnShadowRow_doesNotPostMeterEvent() {
|
||||
// Defensive: BYPASSED rows shouldn't normally exist (the interceptor short-circuits
|
||||
// before openProcess), but if one slips through we must not meter it.
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.BYPASSED);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
Mockito.verifyNoInteractions(teamExtRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_meterReporterThrowsRuntimeException_doesNotPropagate() {
|
||||
// PaygMeterReportingService is documented to swallow; defence-in-depth in
|
||||
// JobChargeService catches a misbehaving impl so the afterCommit hook can't poison the
|
||||
// close flow.
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 4, BillingCategory.AUTOMATION);
|
||||
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
|
||||
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(100L);
|
||||
ext.setStripeCustomerId("cus_subscribed");
|
||||
ext.setPaygSubscriptionId("sub_test");
|
||||
when(teamExtRepo.findById(100L)).thenReturn(Optional.of(ext));
|
||||
|
||||
Mockito.doThrow(new RuntimeException("simulated meter failure"))
|
||||
.when(meterReporter)
|
||||
.recordUsage(
|
||||
Mockito.anyLong(),
|
||||
Mockito.anyString(),
|
||||
Mockito.anyInt(),
|
||||
Mockito.any(BillingCategory.class),
|
||||
Mockito.anyString(),
|
||||
Mockito.any(UUID.class));
|
||||
|
||||
// Should not throw — afterCommit's defence-in-depth wraps the call.
|
||||
withTransactionSynchronization(() -> service.close(jobId));
|
||||
verify(meterReporter)
|
||||
.recordUsage(
|
||||
100L,
|
||||
"cus_subscribed",
|
||||
4,
|
||||
BillingCategory.AUTOMATION,
|
||||
"process:" + jobId + ":close",
|
||||
jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void close_noActiveTransactionSync_skipsMeterPostButStillClosesJob() {
|
||||
// Direct call without an outer @Transactional → no sync to register against. close()
|
||||
// must still close the job; the meter post is implicitly deferred to whatever async path
|
||||
// eventually wraps the call (or is never made, which is fine for ledger-only flows).
|
||||
UUID jobId = UUID.randomUUID();
|
||||
ProcessingJob job = openJob(jobId);
|
||||
when(jobService.close(jobId)).thenReturn(job);
|
||||
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
|
||||
service.close(jobId);
|
||||
|
||||
Mockito.verifyNoInteractions(meterReporter);
|
||||
verify(jobService).close(jobId);
|
||||
}
|
||||
|
||||
private static void withTransactionSynchronization(Runnable body) {
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
body.run();
|
||||
// Drain registered synchronizations to simulate a successful commit.
|
||||
for (TransactionSynchronization sync :
|
||||
TransactionSynchronizationManager.getSynchronizations()) {
|
||||
sync.afterCommit();
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private static PaygShadowCharge chargedShadowRow(
|
||||
UUID jobId, Long teamId, int units, BillingCategory category) {
|
||||
return chargedShadowRow(jobId, teamId, units, 0, category);
|
||||
}
|
||||
|
||||
private static PaygShadowCharge chargedShadowRow(
|
||||
UUID jobId, Long teamId, int units, int freeUnitsConsumed, BillingCategory category) {
|
||||
PaygShadowCharge row = new PaygShadowCharge();
|
||||
row.setJobId(jobId);
|
||||
row.setTeamId(teamId);
|
||||
row.setPaygUnits(units);
|
||||
row.setFreeUnitsConsumed(freeUnitsConsumed);
|
||||
row.setStatus(ShadowChargeStatus.CHARGED);
|
||||
row.setBillingCategory(category);
|
||||
return row;
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------------------------
|
||||
|
||||
private static PricingPolicy stubPolicy(int minCharge, Map<JobSource, Integer> stepLimits) {
|
||||
|
||||
+568
@@ -0,0 +1,568 @@
|
||||
package stirling.software.saas.payg.entitlement;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.jwt.Jwt;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.saas.payg.cap.RequiresFeature;
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
|
||||
|
||||
/**
|
||||
* Pure-Mockito tests for {@link EntitlementGuard}. Covers the four decision-matrix cells: anonymous
|
||||
* billable → 401, anonymous manual → pass, authenticated FULL → pass, authenticated DEGRADED for a
|
||||
* billable route → 402.
|
||||
*/
|
||||
class EntitlementGuardTest {
|
||||
|
||||
private EntitlementService entitlementService;
|
||||
private UserRepository userRepository;
|
||||
private MeterRegistry meterRegistry;
|
||||
private EntitlementGuard guard;
|
||||
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
entitlementService = Mockito.mock(EntitlementService.class);
|
||||
userRepository = Mockito.mock(UserRepository.class);
|
||||
meterRegistry = new SimpleMeterRegistry();
|
||||
guard = new EntitlementGuard(entitlementService, userRepository, meterRegistry);
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Scope: non-AutoJobPostMapping routes skip the guard entirely
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void nonHandlerMethod_passesThrough() throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, "someRawHandler");
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeWithNeitherAnnotation_isSkipped() throws Exception {
|
||||
HandlerMethod hm = handlerFor("plainEndpoint");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
Mockito.verifyNoInteractions(entitlementService, userRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeWithRequiresFeatureButNoAutoJobPostMapping_isInScope() throws Exception {
|
||||
// Regression: @RequiresFeature alone (e.g. JSON-bodied AI controllers) must be in-scope.
|
||||
// Previously the guard short-circuited unless @AutoJobPostMapping was present, which meant
|
||||
// a team without AI entitlement could hit /api/v1/ai/* freely.
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("aiOnlyNoAutoJobPostMapping");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(402);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
|
||||
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT");
|
||||
verify(entitlementService).getSnapshot(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeWithClassLevelRequiresFeatureOnly_isInScope() throws Exception {
|
||||
// Mirrors AiCreateController shape: @RequiresFeature lives on the @RestController class,
|
||||
// not the method. Must still be picked up by the guard.
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new AnonymousAuthenticationToken(
|
||||
"key",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
HandlerMethod hm = handlerForClassLevel("classLevelAiMethod");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED");
|
||||
assertThat(body.get("category").asText()).isEqualTo("AI");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Anonymous user
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void anonymousUser_billableRoute_returns401SignupRequired() throws Exception {
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new AnonymousAuthenticationToken(
|
||||
"key",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
HandlerMethod hm = handlerFor("automationOnly");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(401);
|
||||
assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("SIGNUP_REQUIRED");
|
||||
assertThat(body.get("category").asText()).isEqualTo("AUTOMATION");
|
||||
Mockito.verifyNoInteractions(entitlementService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousUser_aiRoute_returns401WithAiCategory() throws Exception {
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new AnonymousAuthenticationToken(
|
||||
"key",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
HandlerMethod hm = handlerFor("aiOnly");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("category").asText()).isEqualTo("AI");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anonymousUser_manualTool_passesThroughUnbilled() throws Exception {
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new AnonymousAuthenticationToken(
|
||||
"key",
|
||||
"anonymousUser",
|
||||
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
|
||||
|
||||
HandlerMethod hm = handlerFor("manualTool");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
Mockito.verifyNoInteractions(entitlementService);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Authenticated user — FULL vs DEGRADED
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void authenticatedUser_fullState_passesThrough() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("automationOnly");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedUser_degradedAndBillable_returns402() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("automationOnly");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(402);
|
||||
assertThat(res.getContentType()).startsWith(MediaType.APPLICATION_JSON_VALUE);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("FEATURE_DEGRADED");
|
||||
assertThat(body.get("state").asText()).isEqualTo("DEGRADED");
|
||||
assertThat(body.get("capUnits").asLong()).isEqualTo(500L);
|
||||
assertThat(body.get("spendUnits").asLong()).isEqualTo(500L);
|
||||
assertThat(body.get("missingGates").isArray()).isTrue();
|
||||
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AUTOMATION");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedUser_degradedButManualTool_passesThrough() throws Exception {
|
||||
// KEY assertion: DEGRADED+MINIMAL must still allow manual server tools (OFFSITE_PROCESSING)
|
||||
// — that's the whole point of moving OFFSITE into MINIMAL.
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("manualTool");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedUser_aiRouteDegraded_returns402() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("aiOnly");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(402);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("missingGates").get(0).asText()).isEqualTo("AI_SUPPORT");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// API-key calls: billable, hard-stop when degraded (allowance/cap reached)
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void apiKeyCall_degradedFreeTeam_returns402AndAdvisesSubscribe() throws Exception {
|
||||
// A plain server tool (OFFSITE_PROCESSING) reached via API key: the gate survives DEGRADED,
|
||||
// so the gate loop would wave it through — but API usage is billable and must hard-stop
|
||||
// once the free allowance is spent.
|
||||
SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false));
|
||||
|
||||
HandlerMethod hm = handlerFor("manualTool"); // OFFSITE default gate
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(402);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED");
|
||||
assertThat(body.get("subscribed").asBoolean()).isFalse();
|
||||
assertThat(body.get("message").asText()).contains("Subscribe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyCall_degradedSubscribedTeam_returns402AndAdvisesCap() throws Exception {
|
||||
SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(true));
|
||||
|
||||
HandlerMethod hm = handlerFor("manualTool");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isFalse();
|
||||
assertThat(res.getStatus()).isEqualTo(402);
|
||||
JsonNode body = json.readTree(res.getContentAsByteArray());
|
||||
assertThat(body.get("error").asText()).isEqualTo("PAYG_LIMIT_REACHED");
|
||||
assertThat(body.get("subscribed").asBoolean()).isTrue();
|
||||
assertThat(body.get("message").asText()).contains("cap");
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyCall_withinAllowance_passes() throws Exception {
|
||||
// Not degraded → API usage under the free allowance proceeds normally.
|
||||
SecurityContextHolder.getContext().setAuthentication(apiKeyAuth(42L));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(fullSnapshot());
|
||||
|
||||
HandlerMethod hm = handlerFor("manualTool");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jwtWebManualTool_degraded_stillPasses() throws Exception {
|
||||
// The "allow JWT web tool usage" guarantee: a web (JWT) user running an everyday server
|
||||
// tool (OFFSITE_PROCESSING) is NOT blocked when the team is over allowance — only billable
|
||||
// API/AI/automation calls hard-stop. (Web manual calls are BillingCategory.BYPASSED.)
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L)).thenReturn(degradedSnapshot(false));
|
||||
|
||||
HandlerMethod hm = handlerFor("manualTool"); // OFFSITE — survives DEGRADED
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Fail-open
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void snapshotLookupThrows_failsOpenAndPasses() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
when(userRepository.findBySupabaseId(supabaseId))
|
||||
.thenReturn(Optional.of(userWithTeam(7L, 42L)));
|
||||
when(entitlementService.getSnapshot(42L))
|
||||
.thenThrow(new RuntimeException("transient DB outage"));
|
||||
|
||||
HandlerMethod hm = handlerFor("automationOnly");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
assertThat(res.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTeam_passesThrough() throws Exception {
|
||||
UUID supabaseId = UUID.randomUUID();
|
||||
SecurityContextHolder.getContext().setAuthentication(jwtAuth(supabaseId));
|
||||
User u = new User();
|
||||
u.setId(7L);
|
||||
u.setTeam(null);
|
||||
when(userRepository.findBySupabaseId(supabaseId)).thenReturn(Optional.of(u));
|
||||
|
||||
HandlerMethod hm = handlerFor("automationOnly");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
boolean proceed = guard.preHandle(req, res, hm);
|
||||
|
||||
assertThat(proceed).isTrue();
|
||||
verify(entitlementService, never()).getSnapshot(any());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// resolveRequiredGates fallback
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void resolveRequiredGates_noAnnotation_defaultsToOffsiteProcessing() throws Exception {
|
||||
HandlerMethod hm = handlerFor("manualTool");
|
||||
FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm);
|
||||
assertThat(gates).containsExactly(FeatureGate.OFFSITE_PROCESSING);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveRequiredGates_withAnnotation_usesAnnotationValue() throws Exception {
|
||||
HandlerMethod hm = handlerFor("automationOnly");
|
||||
FeatureGate[] gates = EntitlementGuard.resolveRequiredGates(hm);
|
||||
assertThat(gates).containsExactly(FeatureGate.AUTOMATION);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// Helpers / fixture controller
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
||||
private static EntitlementSnapshot fullSnapshot() {
|
||||
return new EntitlementSnapshot(
|
||||
EntitlementState.FULL,
|
||||
FeatureSet.FULL,
|
||||
List.of(
|
||||
FeatureGate.OFFSITE_PROCESSING,
|
||||
FeatureGate.AUTOMATION,
|
||||
FeatureGate.AI_SUPPORT,
|
||||
FeatureGate.CLIENT_SIDE),
|
||||
0L,
|
||||
500L,
|
||||
LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 7, 1, 0, 0),
|
||||
false);
|
||||
}
|
||||
|
||||
private static EntitlementSnapshot degradedSnapshot() {
|
||||
return degradedSnapshot(false);
|
||||
}
|
||||
|
||||
private static EntitlementSnapshot degradedSnapshot(boolean subscribed) {
|
||||
return new EntitlementSnapshot(
|
||||
EntitlementState.DEGRADED,
|
||||
FeatureSet.MINIMAL,
|
||||
List.of(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE),
|
||||
500L,
|
||||
500L,
|
||||
LocalDateTime.of(2026, 6, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 7, 1, 0, 0),
|
||||
subscribed);
|
||||
}
|
||||
|
||||
private static ApiKeyAuthenticationToken apiKeyAuth(long teamId) {
|
||||
User u = userWithTeam(99L, teamId);
|
||||
return new ApiKeyAuthenticationToken(
|
||||
u, "sk-test", List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
}
|
||||
|
||||
private static User userWithTeam(long userId, long teamId) {
|
||||
User u = new User();
|
||||
u.setId(userId);
|
||||
Team t = new Team();
|
||||
t.setId(teamId);
|
||||
u.setTeam(t);
|
||||
return u;
|
||||
}
|
||||
|
||||
private static EnhancedJwtAuthenticationToken jwtAuth(UUID supabaseId) {
|
||||
Map<String, Object> headers = new HashMap<>();
|
||||
headers.put("alg", "RS256");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", supabaseId.toString());
|
||||
claims.put("email", "[email protected]");
|
||||
Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims);
|
||||
return new EnhancedJwtAuthenticationToken(
|
||||
jwt,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_USER")),
|
||||
"[email protected]",
|
||||
supabaseId.toString());
|
||||
}
|
||||
|
||||
private static HandlerMethod handlerFor(String methodName) throws NoSuchMethodException {
|
||||
Method m = TestController.class.getDeclaredMethod(methodName);
|
||||
return new HandlerMethod(new TestController(), m);
|
||||
}
|
||||
|
||||
private static HandlerMethod handlerForClassLevel(String methodName)
|
||||
throws NoSuchMethodException {
|
||||
Method m = ClassLevelAiController.class.getDeclaredMethod(methodName);
|
||||
return new HandlerMethod(new ClassLevelAiController(), m);
|
||||
}
|
||||
|
||||
/** Fixture mounting route shapes the guard's resolver needs to discriminate. */
|
||||
static class TestController {
|
||||
|
||||
@AutoJobPostMapping("/manual")
|
||||
public String manualTool() {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
@AutoJobPostMapping("/automation")
|
||||
@RequiresFeature(FeatureGate.AUTOMATION)
|
||||
public String automationOnly() {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
@AutoJobPostMapping("/ai")
|
||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
||||
public String aiOnly() {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
/** Endpoint without any annotation — guard must skip. */
|
||||
public String plainEndpoint() {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
/** AI-controller shape: @RequiresFeature with NO @AutoJobPostMapping (JSON body). */
|
||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
||||
public String aiOnlyNoAutoJobPostMapping() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors {@code AiCreateController} layout: @RequiresFeature on the class, plain methods. The
|
||||
* guard must pick up the class-level annotation.
|
||||
*/
|
||||
@RequiresFeature(FeatureGate.AI_SUPPORT)
|
||||
static class ClassLevelAiController {
|
||||
public String classLevelAiMethod() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
package stirling.software.saas.payg.entitlement;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.saas.payg.billing.TeamBillingContext;
|
||||
import stirling.software.saas.payg.billing.TeamBillingService;
|
||||
import stirling.software.saas.payg.model.EntitlementState;
|
||||
import stirling.software.saas.payg.model.FeatureGate;
|
||||
import stirling.software.saas.payg.model.FeatureSet;
|
||||
import stirling.software.saas.payg.repository.WalletLedgerRepository;
|
||||
import stirling.software.saas.payg.repository.WalletPolicyRepository;
|
||||
import stirling.software.saas.payg.wallet.WalletPolicy;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link EntitlementService}. Two branches (design 2026-06-11 — the free allowance
|
||||
* is a one-time lifetime grant):
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Unsubscribed</b> — gated by the grant. Cap = grant size, spend = {@code grant −
|
||||
* remaining}, both read straight from the billing context (no ledger query). Exhausted grant
|
||||
* (remaining ≤ 0) → DEGRADED.
|
||||
* <li><b>Subscribed</b> — gated by the monthly money-derived doc cap. Spend = this period's net
|
||||
* billable units ({@link WalletLedgerRepository#sumPeriodNetBillable} negated, refunds
|
||||
* netted).
|
||||
* </ul>
|
||||
*
|
||||
* Also covers cache hit/miss + the invalidate cascade.
|
||||
*/
|
||||
class EntitlementServiceTest {
|
||||
|
||||
private static final LocalDateTime PERIOD_START = LocalDateTime.of(2026, 6, 9, 0, 0);
|
||||
private static final LocalDateTime PERIOD_END = LocalDateTime.of(2026, 7, 9, 0, 0);
|
||||
|
||||
private TeamBillingService billingService;
|
||||
private WalletPolicyRepository walletPolicyRepo;
|
||||
private WalletLedgerRepository ledgerRepo;
|
||||
private EntitlementService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
billingService = Mockito.mock(TeamBillingService.class);
|
||||
walletPolicyRepo = Mockito.mock(WalletPolicyRepository.class);
|
||||
ledgerRepo = Mockito.mock(WalletLedgerRepository.class);
|
||||
service = new EntitlementService(billingService, walletPolicyRepo, ledgerRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSnapshot_nullTeamId_throws() {
|
||||
assertThatThrownBy(() -> service.getSnapshot(null))
|
||||
.isInstanceOf(NullPointerException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void freeTeam_capIsTheGrantAndSpendIsUsedFromCounter() {
|
||||
// Unsubscribed: cap = grant size, spend = grant − remaining — no ledger read.
|
||||
stubBilling(42L, freeContext(500L, 400L));
|
||||
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
assertThat(snap.periodCapUnits()).isEqualTo(500L);
|
||||
assertThat(snap.periodSpendUnits()).isEqualTo(100L);
|
||||
// 100/500 = 20% — well below warn → FULL
|
||||
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
|
||||
assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL);
|
||||
// The grant gate doesn't touch the ledger at all.
|
||||
Mockito.verifyNoInteractions(ledgerRepo);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribedTeam_capIsTheMoneyDerivedDocCap() {
|
||||
stubBilling(42L, subscribedContext(2000L));
|
||||
when(walletPolicyRepo.findByTeamId(42L))
|
||||
.thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL)));
|
||||
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-500L);
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
assertThat(snap.periodCapUnits()).isEqualTo(2000L);
|
||||
assertThat(snap.periodSpendUnits()).isEqualTo(500L);
|
||||
// 500/2000 = 25% — FULL
|
||||
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribedTeam_refundsNetAgainstSpend() {
|
||||
// Net billable = debits − refunds. A −300 net (e.g. 500 debited, 200 refunded) → 300 spend.
|
||||
stubBilling(42L, subscribedContext(2000L));
|
||||
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
|
||||
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-300L);
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
assertThat(snap.periodSpendUnits()).isEqualTo(300L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void spendWindow_comesFromBillingContextNotCalendarMonth() {
|
||||
stubBilling(42L, subscribedContext(2000L));
|
||||
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
|
||||
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L);
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
// The subscription-anchored window flows through to both the snapshot and the SUM query.
|
||||
assertThat(snap.periodStart()).isEqualTo(PERIOD_START);
|
||||
assertThat(snap.periodEnd()).isEqualTo(PERIOD_END);
|
||||
verify(ledgerRepo).sumPeriodNetBillable(eq(42L), eq(PERIOD_START), eq(PERIOD_END));
|
||||
}
|
||||
|
||||
@Test
|
||||
void exhaustedGrant_returnsDegradedWithMinimalGates() {
|
||||
// Grant fully consumed (remaining 0) → billable categories hard-stop for an unsubscribed
|
||||
// team. The displayed cap stays the grant size; spend reads as the full grant.
|
||||
stubBilling(42L, freeContext(100L, 0L));
|
||||
when(walletPolicyRepo.findByTeamId(42L))
|
||||
.thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL)));
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
assertThat(snap.state()).isEqualTo(EntitlementState.DEGRADED);
|
||||
assertThat(snap.featureSet()).isEqualTo(FeatureSet.MINIMAL);
|
||||
assertThat(snap.periodCapUnits()).isEqualTo(100L);
|
||||
assertThat(snap.periodSpendUnits()).isEqualTo(100L);
|
||||
// MINIMAL now keeps OFFSITE_PROCESSING + CLIENT_SIDE (manual tools); AUTOMATION + AI gone.
|
||||
assertThat(snap.enabledGates())
|
||||
.containsExactlyInAnyOrder(FeatureGate.OFFSITE_PROCESSING, FeatureGate.CLIENT_SIDE);
|
||||
assertThat(snap.enabledGates())
|
||||
.doesNotContain(FeatureGate.AUTOMATION, FeatureGate.AI_SUPPORT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantInWarnBand_returnsWarnedButFullFeatureSet() {
|
||||
// grant 100, remaining 15 → used 85 = 85% (between warn 80 and degrade 100).
|
||||
stubBilling(42L, freeContext(100L, 15L));
|
||||
when(walletPolicyRepo.findByTeamId(42L))
|
||||
.thenReturn(Optional.of(walletPolicyThresholds(FeatureSet.MINIMAL)));
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
assertThat(snap.state()).isEqualTo(EntitlementState.WARNED);
|
||||
assertThat(snap.featureSet()).isEqualTo(FeatureSet.FULL);
|
||||
assertThat(snap.enabledGates()).hasSize(4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uncappedSubscribedTeam_nullCapNeverDegrades() {
|
||||
stubBilling(42L, subscribedContext(null));
|
||||
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
|
||||
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(-1_000_000L);
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
assertThat(snap.periodCapUnits()).isNull();
|
||||
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void positiveNetBillable_treatedAsZeroSpend() {
|
||||
// Subscribed defensive: if refunds exceed debits (positive net), spend clamps to zero.
|
||||
stubBilling(42L, subscribedContext(100L));
|
||||
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
|
||||
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(50L);
|
||||
|
||||
EntitlementSnapshot snap = service.getSnapshot(42L);
|
||||
|
||||
assertThat(snap.periodSpendUnits()).isZero();
|
||||
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cacheHit_secondCallSkipsLedgerLookup() {
|
||||
stubBilling(42L, subscribedContext(500L));
|
||||
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
|
||||
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L);
|
||||
|
||||
service.getSnapshot(42L);
|
||||
service.getSnapshot(42L);
|
||||
service.getSnapshot(42L);
|
||||
|
||||
// Only one underlying ledger SUM despite 3 calls — second + third hit the cache.
|
||||
verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any());
|
||||
assertThat(service.cacheSize()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidate_dropsCacheAndCascadesToBillingService() {
|
||||
stubBilling(42L, subscribedContext(500L));
|
||||
when(walletPolicyRepo.findByTeamId(42L)).thenReturn(Optional.empty());
|
||||
when(ledgerRepo.sumPeriodNetBillable(eq(42L), any(), any())).thenReturn(0L);
|
||||
|
||||
service.getSnapshot(42L);
|
||||
service.invalidate(42L);
|
||||
service.getSnapshot(42L);
|
||||
|
||||
verify(ledgerRepo, times(2)).sumPeriodNetBillable(eq(42L), any(), any());
|
||||
// Window/cap facts must recompute together with the spend.
|
||||
verify(billingService).invalidate(42L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidate_otherTeamLeavesEntryAlone() {
|
||||
when(billingService.forTeam(any())).thenReturn(subscribedContext(500L));
|
||||
when(walletPolicyRepo.findByTeamId(any())).thenReturn(Optional.empty());
|
||||
when(ledgerRepo.sumPeriodNetBillable(any(), any(), any())).thenReturn(0L);
|
||||
|
||||
service.getSnapshot(42L);
|
||||
service.invalidate(99L);
|
||||
service.getSnapshot(42L);
|
||||
|
||||
// Only one fetch for team 42 — 99 invalidate didn't touch its entry.
|
||||
verify(ledgerRepo, times(1)).sumPeriodNetBillable(eq(42L), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void currentMonthWindow_isStartOfMonthInclusiveToStartOfNextMonthExclusive() {
|
||||
LocalDateTime mid = LocalDateTime.of(2026, 6, 15, 14, 30);
|
||||
LocalDateTime[] w = EntitlementService.currentMonthWindow(mid);
|
||||
assertThat(w[0]).isEqualTo(LocalDateTime.of(2026, 6, 1, 0, 0));
|
||||
assertThat(w[1]).isEqualTo(LocalDateTime.of(2026, 7, 1, 0, 0));
|
||||
}
|
||||
|
||||
private void stubBilling(Long teamId, TeamBillingContext ctx) {
|
||||
when(billingService.forTeam(teamId)).thenReturn(ctx);
|
||||
}
|
||||
|
||||
/** Unsubscribed team: gated by the one-time grant (size + remaining); no monthly cap. */
|
||||
private static TeamBillingContext freeContext(long grant, long remaining) {
|
||||
return new TeamBillingContext(
|
||||
false, null, PERIOD_START, PERIOD_END, grant, remaining, null, null, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribed team: monthly money-derived paid-doc cap (null = uncapped); grant treated as
|
||||
* exhausted (remaining 0 — doesn't gate a paying team).
|
||||
*/
|
||||
private static TeamBillingContext subscribedContext(Long monthlyCapDocUnits) {
|
||||
return new TeamBillingContext(
|
||||
true,
|
||||
"sub_test",
|
||||
PERIOD_START,
|
||||
PERIOD_END,
|
||||
500L,
|
||||
0L,
|
||||
java.math.BigDecimal.valueOf(2),
|
||||
"usd",
|
||||
monthlyCapDocUnits == null ? null : monthlyCapDocUnits * 2,
|
||||
monthlyCapDocUnits);
|
||||
}
|
||||
|
||||
private static WalletPolicy walletPolicyThresholds(FeatureSet degradedSet) {
|
||||
WalletPolicy p = new WalletPolicy();
|
||||
p.setDegradedFeatureSet(degradedSet);
|
||||
p.setWarnAtPct(80);
|
||||
p.setDegradeAtPct(100);
|
||||
return p;
|
||||
}
|
||||
}
|
||||
+291
-11
@@ -130,7 +130,8 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void preHandle_openedDisposition_stashesJobIdAndDisposition() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
// API-key auth → BillingCategory.API → billable path engaged.
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 4, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -153,7 +154,8 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void preHandle_chargeServiceThrows_failsOpenAndIncrementsErrorCounter() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
// API-key auth → API category → reaches openProcess so the throw can be observed.
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
when(chargeService.openProcess(any(), anyList())).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
@@ -170,7 +172,7 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void afterCompletion_2xx_appendsStepAndRecordsOutputs() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -203,11 +205,33 @@ class PaygChargeInterceptorTest {
|
||||
verify(jobService).recordOutput(eq(jobId), any());
|
||||
verify(chargeService, never()).markFirstStepFailed(any(), any());
|
||||
verify(chargeService, never()).decrementStepCount(any());
|
||||
// Success on an OPENED process is the primary meter trigger — fires now, not at close.
|
||||
verify(chargeService).meterJobUsage(jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCompletion_2xx_joined_doesNotMeter() throws Exception {
|
||||
// A JOINED follow-up step (chained tool on the same document) added no units when it
|
||||
// joined — it must not re-meter; the OPENED step already did.
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED));
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
res.setStatus(200);
|
||||
|
||||
interceptor.preHandle(req, res, handlerMethodForFakeController());
|
||||
interceptor.afterCompletion(req, res, handlerMethodForFakeController(), null);
|
||||
|
||||
verify(chargeService, never()).meterJobUsage(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCompletion_5xx_opened_callsMarkFirstStepFailed() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -226,11 +250,13 @@ class PaygChargeInterceptorTest {
|
||||
verify(jobService)
|
||||
.appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("503"));
|
||||
assertThat(meterRegistry.counter("payg.filter.refunds").count()).isEqualTo(1.0);
|
||||
// First-step failure refunds — never meter it.
|
||||
verify(chargeService, never()).meterJobUsage(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCompletion_5xx_joined_callsDecrementStepCount() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 0, ChargeOutcome.Disposition.JOINED));
|
||||
@@ -249,7 +275,7 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void afterCompletion_4xx_appendsFailedStepNoRefundNoOutputs() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -267,6 +293,8 @@ class PaygChargeInterceptorTest {
|
||||
verify(jobService, never()).recordOutput(any(), any());
|
||||
verify(jobService)
|
||||
.appendStep(eq(jobId), any(), eq(JobStepStatus.FAILED), any(), any(), eq("422"));
|
||||
// 4xx is a full charge (customer paid for the attempt), so it still meters.
|
||||
verify(chargeService).meterJobUsage(jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -293,7 +321,7 @@ class PaygChargeInterceptorTest {
|
||||
@Test
|
||||
void afterCompletion_maxBytesExceeded_skipsOutputRecording() throws Exception {
|
||||
properties.getResponse().setMaxBytes(2L);
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -317,7 +345,10 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void preHandle_desktopClientHeader_setsJobSourceDesktopApp() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
// API-key (billable) so the call reaches openProcess; the desktop header still wins the
|
||||
// source mapping (checked before the API-key branch in determineSource). A manual JWT call
|
||||
// is BYPASSED and never opens a process, so source wouldn't be recorded.
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -338,7 +369,9 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void preHandle_toolId_prefersBestMatchingPattern() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
// API-key (billable) so doPreHandle runs and records tool_id; a manual JWT call is
|
||||
// BYPASSED before that point, so no tool_id is stored.
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -359,7 +392,9 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void preHandle_toolId_truncatesAndCountsWhenLongerThan128() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
// API-key (billable) so doPreHandle runs and records tool_id (a manual JWT call is
|
||||
// BYPASSED).
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -380,7 +415,7 @@ class PaygChargeInterceptorTest {
|
||||
|
||||
@Test
|
||||
void preHandle_pipelineHeader_setsJobSourcePipeline() throws Exception {
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
@@ -399,6 +434,184 @@ class PaygChargeInterceptorTest {
|
||||
.isEqualTo(stirling.software.saas.payg.model.JobSource.PIPELINE);
|
||||
}
|
||||
|
||||
// --- BillingCategory categorisation + bypass fast-path -------------------------------------
|
||||
|
||||
@Test
|
||||
void preHandle_manualToolJwt_isBypassedAndSkipsOpenProcess() throws Exception {
|
||||
// JWT-authenticated, plain @AutoJobPostMapping endpoint, no automation header → BYPASSED.
|
||||
// The interceptor must skip openProcess entirely (no temp files, no DB writes) and bump
|
||||
// the payg.filter.bypassed counter.
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
|
||||
boolean cont =
|
||||
interceptor.preHandle(
|
||||
req, new MockHttpServletResponse(), handlerMethodForFakeController());
|
||||
|
||||
assertThat(cont).isTrue();
|
||||
verify(chargeService, never()).openProcess(any(), anyList());
|
||||
assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_JOB_ID)).isNull();
|
||||
assertThat(req.getAttribute(PaygChargeInterceptor.ATTR_INPUT_TEMP_FILES)).isNull();
|
||||
assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_apiKeyAuth_setsBillingCategoryApi() throws Exception {
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.saas.payg.charge.ChargeContext.class);
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
|
||||
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForFakeController());
|
||||
|
||||
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
|
||||
assertThat(ctxCaptor.getValue().billingCategory())
|
||||
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.API);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_requiresFeatureAutomation_setsBillingCategoryAutomation() throws Exception {
|
||||
// JWT auth on a @RequiresFeature(AUTOMATION) endpoint → AUTOMATION category.
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.saas.payg.charge.ChargeContext.class);
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
|
||||
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAutomation());
|
||||
|
||||
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
|
||||
assertThat(ctxCaptor.getValue().billingCategory())
|
||||
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_requiresFeatureAiSupport_setsBillingCategoryAi() throws Exception {
|
||||
// JWT auth on a @RequiresFeature(AI_SUPPORT) endpoint → AI category.
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.saas.payg.charge.ChargeContext.class);
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
|
||||
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi());
|
||||
|
||||
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
|
||||
assertThat(ctxCaptor.getValue().billingCategory())
|
||||
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_requiresFeatureWithoutAutoJobPostMapping_reachesCategoryGate() throws Exception {
|
||||
// Regression: AI controllers carry @RequiresFeature but NO @AutoJobPostMapping. They must
|
||||
// still flow past the short-circuit gate so determineCategory runs (and so future
|
||||
// multipart-bearing @RequiresFeature routes bill correctly). API-key auth +
|
||||
// @RequiresFeature
|
||||
// — even without multipart inputs — should land in the BillingCategory.API branch via
|
||||
// determineCategory's auth check, then short-circuit inside doPreHandle because there are
|
||||
// no multipart parts.
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
// No file parts — emulates a JSON-bodied AI controller request that happens to be wrapped
|
||||
// as multipart. doPreHandle short-circuits with no openProcess call.
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", new byte[0]));
|
||||
|
||||
boolean cont =
|
||||
interceptor.preHandle(
|
||||
req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob());
|
||||
|
||||
assertThat(cont).isTrue();
|
||||
verify(chargeService, never()).openProcess(any(), anyList());
|
||||
// Importantly: not counted as BYPASSED — the AI category was determined correctly.
|
||||
assertThat(meterRegistry.counter("payg.filter.bypassed").count()).isEqualTo(0.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_aiEndpointWithoutAutoJobPostMapping_categoryIsAi() throws Exception {
|
||||
// With multipart parts present + @RequiresFeature(AI_SUPPORT) but no @AutoJobPostMapping:
|
||||
// the interceptor must run determineCategory and tag the ChargeContext as AI.
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.saas.payg.charge.ChargeContext.class);
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
|
||||
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAiNoAutoJob());
|
||||
|
||||
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
|
||||
assertThat(ctxCaptor.getValue().billingCategory())
|
||||
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_classLevelRequiresFeatureOnly_isInScope() throws Exception {
|
||||
// Mirrors AiCreateController shape: @RequiresFeature on the @RestController class.
|
||||
// The interceptor must resolve it via beanType lookup and not short-circuit as
|
||||
// "no annotation".
|
||||
authenticateWithApiKey(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.saas.payg.charge.ChargeContext.class);
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
|
||||
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForClassLevelAi());
|
||||
|
||||
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
|
||||
assertThat(ctxCaptor.getValue().billingCategory())
|
||||
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AI);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandle_aiEndpointWithAutomationHeader_automationWinsByPrecedence() throws Exception {
|
||||
// X-Stirling-Automation: true on an @RequiresFeature(AI_SUPPORT) endpoint → AUTOMATION
|
||||
// (header beats annotation by design — pipeline-driven AI counts as automation usage).
|
||||
authenticateWithUser(makeUser(7L, 42L));
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(chargeService.openProcess(any(), anyList()))
|
||||
.thenReturn(new ChargeOutcome(jobId, 1, ChargeOutcome.Disposition.OPENED));
|
||||
org.mockito.ArgumentCaptor<stirling.software.saas.payg.charge.ChargeContext> ctxCaptor =
|
||||
org.mockito.ArgumentCaptor.forClass(
|
||||
stirling.software.saas.payg.charge.ChargeContext.class);
|
||||
|
||||
MockMultipartHttpServletRequest req = newMultipart();
|
||||
req.addFile(new MockMultipartFile("file", "x.pdf", "application/pdf", "abc".getBytes()));
|
||||
req.addHeader("X-Stirling-Automation", "true");
|
||||
|
||||
interceptor.preHandle(req, new MockHttpServletResponse(), handlerMethodForAi());
|
||||
|
||||
verify(chargeService).openProcess(ctxCaptor.capture(), anyList());
|
||||
assertThat(ctxCaptor.getValue().billingCategory())
|
||||
.isEqualTo(stirling.software.saas.payg.model.BillingCategory.AUTOMATION);
|
||||
}
|
||||
|
||||
// --- helpers --------------------------------------------------------------------------------
|
||||
|
||||
private MockMultipartHttpServletRequest newMultipart() {
|
||||
@@ -459,11 +672,78 @@ class PaygChargeInterceptorTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static HandlerMethod handlerMethodForAutomation() {
|
||||
try {
|
||||
Method m = FakeController.class.getDeclaredMethod("handleAutomation");
|
||||
return new HandlerMethod(new FakeController(), m);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static HandlerMethod handlerMethodForAi() {
|
||||
try {
|
||||
Method m = FakeController.class.getDeclaredMethod("handleAi");
|
||||
return new HandlerMethod(new FakeController(), m);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static HandlerMethod handlerMethodForAiNoAutoJob() {
|
||||
try {
|
||||
Method m = FakeController.class.getDeclaredMethod("handleAiNoAutoJob");
|
||||
return new HandlerMethod(new FakeController(), m);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static HandlerMethod handlerMethodForClassLevelAi() {
|
||||
try {
|
||||
Method m = ClassLevelAiController.class.getDeclaredMethod("classLevelAi");
|
||||
return new HandlerMethod(new ClassLevelAiController(), m);
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void authenticateWithApiKey(User user) {
|
||||
stirling.software.proprietary.security.model.ApiKeyAuthenticationToken token =
|
||||
new stirling.software.proprietary.security.model.ApiKeyAuthenticationToken(
|
||||
user, "test-api-key", List.of(new SimpleGrantedAuthority("ROLE_API")));
|
||||
SecurityContextHolder.getContext().setAuthentication(token);
|
||||
}
|
||||
|
||||
static class FakeController {
|
||||
@AutoJobPostMapping(value = "/x", resourceWeight = 1)
|
||||
public void handleAuto() {}
|
||||
|
||||
public void handlePlain() {}
|
||||
|
||||
@AutoJobPostMapping(value = "/auto", resourceWeight = 1)
|
||||
@stirling.software.saas.payg.cap.RequiresFeature(
|
||||
stirling.software.saas.payg.model.FeatureGate.AUTOMATION)
|
||||
public void handleAutomation() {}
|
||||
|
||||
@AutoJobPostMapping(value = "/ai", resourceWeight = 1)
|
||||
@stirling.software.saas.payg.cap.RequiresFeature(
|
||||
stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT)
|
||||
public void handleAi() {}
|
||||
|
||||
/**
|
||||
* AI-controller shape: @RequiresFeature without @AutoJobPostMapping (JSON body / proxy).
|
||||
*/
|
||||
@stirling.software.saas.payg.cap.RequiresFeature(
|
||||
stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT)
|
||||
public void handleAiNoAutoJob() {}
|
||||
}
|
||||
|
||||
/** Mirrors AiCreateController layout: @RequiresFeature on the class, plain methods. */
|
||||
@stirling.software.saas.payg.cap.RequiresFeature(
|
||||
stirling.software.saas.payg.model.FeatureGate.AI_SUPPORT)
|
||||
static class ClassLevelAiController {
|
||||
public void classLevelAi() {}
|
||||
}
|
||||
|
||||
/** Placeholder so AutoCloseable resources flow in some helper methods. */
|
||||
|
||||
@@ -1,35 +1,72 @@
|
||||
package stirling.software.saas.payg.job;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.saas.payg.charge.JobChargeService;
|
||||
|
||||
/**
|
||||
* Smoke test for the scheduler wiring. The interesting close logic is exercised in {@link
|
||||
* JobServiceTest#closeStale_closesAllStaleJobs}; here we just confirm the scheduler bean delegates
|
||||
* to {@code JobService.closeStale} and tolerates an empty result without erroring.
|
||||
* Smoke test for the scheduler wiring. Confirms the scheduler closes each stale job through {@link
|
||||
* JobChargeService#close} (so the Stripe meter afterCommit hook fires per job), isolates per-job
|
||||
* failures, and tolerates an empty stale set without erroring. The close/meter logic itself is
|
||||
* exercised in {@code JobChargeServiceTest}.
|
||||
*/
|
||||
class StaleJobCloserTest {
|
||||
|
||||
@Test
|
||||
void closeStale_invokesJobService() {
|
||||
JobService jobService = Mockito.mock(JobService.class);
|
||||
when(jobService.closeStale()).thenReturn(3);
|
||||
|
||||
new StaleJobCloser(jobService).closeStale();
|
||||
|
||||
verify(jobService).closeStale();
|
||||
private static ProcessingJob job(UUID id) {
|
||||
ProcessingJob j = new ProcessingJob();
|
||||
j.setId(id);
|
||||
return j;
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeStale_zeroClosedDoesNotThrow() {
|
||||
void closeStale_closesEachStaleJobThroughChargeService() {
|
||||
JobService jobService = Mockito.mock(JobService.class);
|
||||
when(jobService.closeStale()).thenReturn(0);
|
||||
JobChargeService chargeService = Mockito.mock(JobChargeService.class);
|
||||
UUID a = UUID.randomUUID();
|
||||
UUID b = UUID.randomUUID();
|
||||
when(jobService.findStale()).thenReturn(List.of(job(a), job(b)));
|
||||
|
||||
new StaleJobCloser(jobService).closeStale();
|
||||
new StaleJobCloser(jobService, chargeService).closeStale();
|
||||
|
||||
verify(jobService).closeStale();
|
||||
// Routed through the charge service (meter hook), NOT the bulk closeStale flip.
|
||||
verify(chargeService).close(a);
|
||||
verify(chargeService).close(b);
|
||||
verify(jobService, never()).closeStale();
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeStale_oneJobFailing_doesNotStrandTheRest() {
|
||||
JobService jobService = Mockito.mock(JobService.class);
|
||||
JobChargeService chargeService = Mockito.mock(JobChargeService.class);
|
||||
UUID bad = UUID.randomUUID();
|
||||
UUID good = UUID.randomUUID();
|
||||
when(jobService.findStale()).thenReturn(List.of(job(bad), job(good)));
|
||||
when(chargeService.close(bad)).thenThrow(new RuntimeException("boom"));
|
||||
|
||||
// Must not propagate — the sweep continues to the next job.
|
||||
new StaleJobCloser(jobService, chargeService).closeStale();
|
||||
|
||||
verify(chargeService).close(bad);
|
||||
verify(chargeService).close(good);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeStale_emptyStaleSet_doesNotTouchChargeService() {
|
||||
JobService jobService = Mockito.mock(JobService.class);
|
||||
JobChargeService chargeService = Mockito.mock(JobChargeService.class);
|
||||
when(jobService.findStale()).thenReturn(List.of());
|
||||
|
||||
new StaleJobCloser(jobService, chargeService).closeStale();
|
||||
|
||||
verify(chargeService, never()).close(any());
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package stirling.software.saas.payg.meter;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import stirling.software.saas.payg.policy.PaygTeamExtensions;
|
||||
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
|
||||
import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link PaygMeterReconcileScheduler}: retries unposted events for still-subscribed
|
||||
* teams under the same idempotency key, skips teams that have since unsubscribed, and no-ops when
|
||||
* disabled.
|
||||
*/
|
||||
class PaygMeterReconcileSchedulerTest {
|
||||
|
||||
private PaygMeterEventLogRepository logRepo;
|
||||
private PaygTeamExtensionsRepository teamExtRepo;
|
||||
private PaygMeterReportingService meterReportingService;
|
||||
private MeterRegistry meterRegistry;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
logRepo = Mockito.mock(PaygMeterEventLogRepository.class);
|
||||
teamExtRepo = Mockito.mock(PaygTeamExtensionsRepository.class);
|
||||
meterReportingService = Mockito.mock(PaygMeterReportingService.class);
|
||||
meterRegistry = new SimpleMeterRegistry();
|
||||
when(logRepo.countStuck(any())).thenReturn(0L);
|
||||
}
|
||||
|
||||
private PaygMeterReconcileScheduler scheduler(boolean enabled) {
|
||||
return new PaygMeterReconcileScheduler(
|
||||
logRepo,
|
||||
teamExtRepo,
|
||||
meterReportingService,
|
||||
enabled,
|
||||
Duration.ofMinutes(5),
|
||||
100,
|
||||
meterRegistry);
|
||||
}
|
||||
|
||||
private static PaygMeterEventLog row(Long teamId, UUID jobId, String key, int units) {
|
||||
PaygMeterEventLog e = new PaygMeterEventLog();
|
||||
e.setTeamId(teamId);
|
||||
e.setJobId(jobId);
|
||||
e.setIdempotencyKey(key);
|
||||
e.setUnits(units);
|
||||
return e;
|
||||
}
|
||||
|
||||
private static PaygTeamExtensions ext(Long teamId, String customerId, String subscriptionId) {
|
||||
PaygTeamExtensions ext = new PaygTeamExtensions();
|
||||
ext.setTeamId(teamId);
|
||||
ext.setStripeCustomerId(customerId);
|
||||
ext.setPaygSubscriptionId(subscriptionId);
|
||||
return ext;
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcile_subscribedTeam_reMetersUnderSameKey() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(logRepo.findRetryable(any(), any(), any(Pageable.class)))
|
||||
.thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4)));
|
||||
when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", "sub_live")));
|
||||
|
||||
scheduler(true).reconcile();
|
||||
|
||||
verify(meterReportingService)
|
||||
.recordUsage(100L, "cus_live", 4, null, "process:" + jobId + ":close", jobId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcile_teamUnsubscribedSince_isSkipped() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(logRepo.findRetryable(any(), any(), any(Pageable.class)))
|
||||
.thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4)));
|
||||
// Customer still present but no live subscription → no longer billable.
|
||||
when(teamExtRepo.findAllById(any())).thenReturn(List.of(ext(100L, "cus_live", null)));
|
||||
|
||||
scheduler(true).reconcile();
|
||||
|
||||
verify(meterReportingService, never())
|
||||
.recordUsage(any(), any(), anyInt(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcile_missingTeamExtensions_isSkipped() {
|
||||
UUID jobId = UUID.randomUUID();
|
||||
when(logRepo.findRetryable(any(), any(), any(Pageable.class)))
|
||||
.thenReturn(List.of(row(100L, jobId, "process:" + jobId + ":close", 4)));
|
||||
when(teamExtRepo.findAllById(any())).thenReturn(List.of());
|
||||
|
||||
scheduler(true).reconcile();
|
||||
|
||||
verify(meterReportingService, never())
|
||||
.recordUsage(any(), any(), anyInt(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcile_disabled_isNoOp() {
|
||||
scheduler(false).reconcile();
|
||||
|
||||
verifyNoInteractions(logRepo, teamExtRepo, meterReportingService);
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package stirling.software.saas.payg.meter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.net.ConnectException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.MeterRegistry;
|
||||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
|
||||
|
||||
import stirling.software.saas.payg.model.BillingCategory;
|
||||
import stirling.software.saas.payg.repository.PaygMeterEventLogRepository;
|
||||
|
||||
/**
|
||||
* Covers the contract documented on {@link PaygMeterReportingService#recordUsage}: never throws,
|
||||
* skips when endpoint is blank, counts non-2xx and exceptions on {@code payg.meter.errors}, and
|
||||
* wraps every POST in a durable {@code payg_meter_event_log} row (pending → posted / failed).
|
||||
*/
|
||||
class PaygMeterReportingServiceTest {
|
||||
|
||||
private static final String ENDPOINT =
|
||||
"https://example.supabase.co/functions/v1/meter-payg-units";
|
||||
private static final String TOKEN = "test-service-role-token";
|
||||
private static final UUID JOB = UUID.fromString("00000000-0000-0000-0000-0000000000aa");
|
||||
|
||||
private RestTemplate restTemplate;
|
||||
private PaygMeterEventLogRepository eventLogRepository;
|
||||
private MeterRegistry meterRegistry;
|
||||
private Counter errorsCounter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
restTemplate = Mockito.mock(RestTemplate.class);
|
||||
eventLogRepository = Mockito.mock(PaygMeterEventLogRepository.class);
|
||||
meterRegistry = new SimpleMeterRegistry();
|
||||
errorsCounter = meterRegistry.counter("payg.meter.errors");
|
||||
}
|
||||
|
||||
private PaygMeterReportingService newService(String endpoint, String token) {
|
||||
return new PaygMeterReportingService(
|
||||
endpoint, token, restTemplate, eventLogRepository, meterRegistry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_happyPath_postsBodyLogsPendingThenPosted() {
|
||||
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
|
||||
.thenReturn(new ResponseEntity<>("{\"ok\":true}", HttpStatus.OK));
|
||||
|
||||
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
|
||||
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job1:close", JOB);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<HttpEntity<Map<String, Object>>> entityCaptor =
|
||||
ArgumentCaptor.forClass(HttpEntity.class);
|
||||
verify(restTemplate)
|
||||
.exchange(
|
||||
eq(ENDPOINT),
|
||||
eq(HttpMethod.POST),
|
||||
entityCaptor.capture(),
|
||||
eq(String.class));
|
||||
|
||||
HttpEntity<Map<String, Object>> sent = entityCaptor.getValue();
|
||||
Map<String, Object> body = sent.getBody();
|
||||
assertThat(body).isNotNull();
|
||||
// JSON number — the edge fn type-checks team_id and ignores strings.
|
||||
assertThat(body.get("team_id")).isEqualTo(100L);
|
||||
assertThat(body.get("stripe_customer_id")).isEqualTo("cus_abc");
|
||||
assertThat(body.get("units")).isEqualTo(5);
|
||||
assertThat(body.get("idempotency_key")).isEqualTo("process:job1:close");
|
||||
assertThat(body.get("metadata")).isEqualTo(Map.of("category", "API"));
|
||||
|
||||
HttpHeaders headers = sent.getHeaders();
|
||||
assertThat(headers.getFirst("Authorization")).isEqualTo("Bearer " + TOKEN);
|
||||
assertThat(headers.getContentType()).isNotNull();
|
||||
assertThat(headers.getContentType().toString()).startsWith("application/json");
|
||||
|
||||
// Durable audit: pending row written before the POST, stamped posted after success.
|
||||
verify(eventLogRepository).insertPending(100L, JOB, "process:job1:close", 5);
|
||||
verify(eventLogRepository).markPosted("process:job1:close");
|
||||
verify(eventLogRepository, never()).markFailed(any(), any(), any());
|
||||
assertThat(errorsCounter.count()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_5xxResponse_marksFailedIncrementsErrorCounterAndDoesNotThrow() {
|
||||
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
|
||||
.thenReturn(new ResponseEntity<>("oops", HttpStatus.INTERNAL_SERVER_ERROR));
|
||||
|
||||
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.recordUsage(
|
||||
100L,
|
||||
"cus_abc",
|
||||
3,
|
||||
BillingCategory.AUTOMATION,
|
||||
"process:job2:close",
|
||||
JOB))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
verify(eventLogRepository).insertPending(100L, JOB, "process:job2:close", 3);
|
||||
verify(eventLogRepository).markFailed(eq("process:job2:close"), eq("500"), any());
|
||||
verify(eventLogRepository, never()).markPosted(any());
|
||||
assertThat(errorsCounter.count()).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_connectionRefused_marksFailedIncrementsErrorCounterAndDoesNotThrow() {
|
||||
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
|
||||
.thenThrow(new ResourceAccessException("connect refused", new ConnectException()));
|
||||
|
||||
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.recordUsage(
|
||||
100L,
|
||||
"cus_abc",
|
||||
7,
|
||||
BillingCategory.AI,
|
||||
"process:job3:close",
|
||||
JOB))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
verify(eventLogRepository).insertPending(100L, JOB, "process:job3:close", 7);
|
||||
verify(eventLogRepository).markFailed(eq("process:job3:close"), eq("exception"), any());
|
||||
assertThat(errorsCounter.count()).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_runtimeException_swallowed() {
|
||||
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
|
||||
.thenThrow(new RuntimeException("boom"));
|
||||
|
||||
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.recordUsage(
|
||||
100L,
|
||||
"cus_abc",
|
||||
7,
|
||||
BillingCategory.AI,
|
||||
"process:job4:close",
|
||||
JOB))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
verify(eventLogRepository).markFailed(eq("process:job4:close"), eq("exception"), any());
|
||||
assertThat(errorsCounter.count()).isEqualTo(1.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_logPendingFailure_stillPostsAndDoesNotThrow() {
|
||||
// A DB hiccup writing the audit row must not stop us metering the customer.
|
||||
Mockito.doThrow(new RuntimeException("db down"))
|
||||
.when(eventLogRepository)
|
||||
.insertPending(any(), any(), any(), anyInt());
|
||||
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
|
||||
.thenReturn(new ResponseEntity<>("{}", HttpStatus.OK));
|
||||
|
||||
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
|
||||
assertThatCode(
|
||||
() ->
|
||||
service.recordUsage(
|
||||
100L,
|
||||
"cus_abc",
|
||||
2,
|
||||
BillingCategory.API,
|
||||
"process:job9:close",
|
||||
JOB))
|
||||
.doesNotThrowAnyException();
|
||||
|
||||
verify(restTemplate).exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_blankEndpoint_noopsAndDoesNotCallRestTemplateOrLog() {
|
||||
PaygMeterReportingService service = newService("", TOKEN);
|
||||
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job5:close", JOB);
|
||||
|
||||
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
|
||||
verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt());
|
||||
assertThat(errorsCounter.count()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_nullEndpoint_noopsAndDoesNotCallRestTemplate() {
|
||||
PaygMeterReportingService service = newService(null, TOKEN);
|
||||
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job6:close", JOB);
|
||||
|
||||
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
|
||||
verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt());
|
||||
assertThat(errorsCounter.count()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_zeroUnits_noopsAndDoesNotCallRestTemplateOrLog() {
|
||||
PaygMeterReportingService service = newService(ENDPOINT, TOKEN);
|
||||
service.recordUsage(100L, "cus_abc", 0, BillingCategory.API, "process:job7:close", JOB);
|
||||
|
||||
verify(restTemplate, never()).exchange(any(String.class), any(), any(), any(Class.class));
|
||||
verify(eventLogRepository, never()).insertPending(any(), any(), any(), anyInt());
|
||||
assertThat(errorsCounter.count()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordUsage_blankServiceRoleToken_postsWithoutAuthorizationHeader() {
|
||||
when(restTemplate.exchange(eq(ENDPOINT), eq(HttpMethod.POST), any(), eq(String.class)))
|
||||
.thenReturn(new ResponseEntity<>("{}", HttpStatus.OK));
|
||||
|
||||
PaygMeterReportingService service = newService(ENDPOINT, "");
|
||||
service.recordUsage(100L, "cus_abc", 5, BillingCategory.API, "process:job8:close", JOB);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<HttpEntity<Map<String, Object>>> entityCaptor =
|
||||
ArgumentCaptor.forClass(HttpEntity.class);
|
||||
verify(restTemplate, times(1))
|
||||
.exchange(
|
||||
eq(ENDPOINT),
|
||||
eq(HttpMethod.POST),
|
||||
entityCaptor.capture(),
|
||||
eq(String.class));
|
||||
assertThat(entityCaptor.getValue().getHeaders().getFirst("Authorization")).isNull();
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,16 @@ class PaygEntitiesSmokeTest {
|
||||
assertThat(entry.getAmountUnits()).isEqualTo(-4);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletLedgerEntry_billingCategoryRoundTrips() {
|
||||
WalletLedgerEntry entry = new WalletLedgerEntry();
|
||||
// Default (unset) is null — captured by both the legacy debit path and pre-V16 rows.
|
||||
assertThat(entry.getBillingCategory()).isNull();
|
||||
|
||||
entry.setBillingCategory(BillingCategory.AUTOMATION);
|
||||
assertThat(entry.getBillingCategory()).isEqualTo(BillingCategory.AUTOMATION);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walletPolicy_carriesSensibleDefaults() {
|
||||
WalletPolicy policy = new WalletPolicy();
|
||||
@@ -148,4 +158,29 @@ class PaygEntitiesSmokeTest {
|
||||
|
||||
assertThat(row.getDiffPct()).isNegative();
|
||||
}
|
||||
|
||||
@Test
|
||||
void paygShadowCharge_billingCategoryAndJobSourceRoundTrip() {
|
||||
PaygShadowCharge row = new PaygShadowCharge();
|
||||
assertThat(row.getBillingCategory()).isNull();
|
||||
assertThat(row.getJobSource()).isNull();
|
||||
|
||||
row.setBillingCategory(BillingCategory.AI);
|
||||
row.setJobSource(JobSource.API);
|
||||
|
||||
assertThat(row.getBillingCategory()).isEqualTo(BillingCategory.AI);
|
||||
assertThat(row.getJobSource()).isEqualTo(JobSource.API);
|
||||
}
|
||||
|
||||
@Test
|
||||
void billingCategory_listingOrderIsStable() {
|
||||
// No downstream relies on ordinal() today, but the comment in the enum claims BYPASSED is
|
||||
// declared first as the default sentinel — guard against an accidental reorder.
|
||||
assertThat(BillingCategory.values())
|
||||
.containsExactly(
|
||||
BillingCategory.BYPASSED,
|
||||
BillingCategory.API,
|
||||
BillingCategory.AI,
|
||||
BillingCategory.AUTOMATION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3090,6 +3090,18 @@ integration = "Integration Configuration"
|
||||
security = "Security Configuration"
|
||||
system = "System Configuration"
|
||||
|
||||
[config.payg]
|
||||
label = "Billing & usage"
|
||||
section = "Pay-as-you-go"
|
||||
|
||||
[connectionMode.status]
|
||||
localOffline = "Offline mode running"
|
||||
localOnline = "Offline mode running"
|
||||
saas = "Connected to Stirling Cloud"
|
||||
selfhostedChecking = "Connected to self-hosted server (checking...)"
|
||||
selfhostedOffline = "Self-hosted server unreachable"
|
||||
selfhostedOnline = "Connected to self-hosted server"
|
||||
|
||||
[convert]
|
||||
tags = "transform,change,convert,PDF to Word,PDF to Excel,PDF to image,Word to PDF,Excel to PDF,PowerPoint to PDF,HTML to PDF,export,import,file conversion,format change,save as"
|
||||
autoRotate = "Auto Rotate"
|
||||
@@ -5132,6 +5144,239 @@ title = "Page Ranges"
|
||||
bullet1 = "<strong>all</strong> → selects all pages"
|
||||
title = "Special Keywords"
|
||||
|
||||
[pageSelection.tooltip.syntax]
|
||||
text = "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported."
|
||||
title = "Syntax Basics"
|
||||
|
||||
[pageSelection.tooltip.syntax.bullets]
|
||||
keywords = "Keywords: odd, even"
|
||||
numbers = "Numbers/ranges: 5, 10-20"
|
||||
progressions = "Progressions: 3n, 4n+1"
|
||||
|
||||
[pageSelection.tooltip.tips]
|
||||
bullet1 = "Page numbers start from 1 (not 0)"
|
||||
bullet2 = "Spaces are automatically removed"
|
||||
bullet3 = "Invalid expressions are ignored"
|
||||
text = "Keep these guidelines in mind:"
|
||||
title = "Tips"
|
||||
|
||||
[payg]
|
||||
subtitle = "Pay-as-you-go — you only pay for what you process. Billing period {{start}} – {{end}}."
|
||||
|
||||
[payg.activity]
|
||||
subtitle = "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here."
|
||||
title = "Recent billable activity"
|
||||
units = "units"
|
||||
viewAll = "View all"
|
||||
|
||||
[payg.cap]
|
||||
amount = "Cap amount"
|
||||
currency = "Currency"
|
||||
custom = "Custom"
|
||||
degradeAt = "Limit spend at"
|
||||
degradeAtDesc = "Set below 100% to leave yourself headroom."
|
||||
docsEstimate = "≈ {{docs}} processed PDFs / month"
|
||||
docsRate = "at {{rate}} / PDF"
|
||||
noCapDesc = "Usage is billed without an upper limit. You can re-enable a cap at any time."
|
||||
noCapLabel = "No cap"
|
||||
perMonth = "/ month"
|
||||
preview = "{{money}} ≈ {{units}} PDFs per month"
|
||||
previewNote = "At current pricing. Final translation happens server-side on save."
|
||||
previewShort = "≈ {{units}} PDFs per month at current pricing."
|
||||
save = "Update cap"
|
||||
subtitle = "Set your maximum spend. We convert this to PDFs using the current price tier."
|
||||
title = "Monthly spending cap"
|
||||
warnAt = "Warn me at"
|
||||
warnAtDesc = "Notify when usage crosses this threshold."
|
||||
|
||||
[payg.checkout]
|
||||
connecting = "Connecting to Stripe…"
|
||||
errorTitle = "Stripe error"
|
||||
|
||||
[payg.checkout.error]
|
||||
startFailed = "Couldn't start checkout session"
|
||||
|
||||
[payg.checkout.mock]
|
||||
backend = "Backend is in mock mode — no real Stripe session was created."
|
||||
continue = "Continue with mock subscription"
|
||||
noKey = "VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured."
|
||||
title = "Stripe Embedded Checkout (mock mode)"
|
||||
|
||||
[payg.confirm]
|
||||
body = "Your team can now run automation, AI, and API operations beyond your 500 free PDFs."
|
||||
capValue = "{{symbol}}{{amount}} / month"
|
||||
noCap = "No cap"
|
||||
note = "You can change your cap, cancel, or open the Stripe customer portal any time from this page."
|
||||
summaryLabel = "Monthly ceiling"
|
||||
title = "Welcome to the Processor plan"
|
||||
|
||||
[payg.error]
|
||||
body = "We couldn't reach the billing service. Refresh the page to try again."
|
||||
title = "Couldn't load your plan"
|
||||
|
||||
[payg.exhausted]
|
||||
body = "You've used all 500 of your free PDFs. Upgrade to Processor to keep going."
|
||||
cta = "Go to billing"
|
||||
title = "You've used your free PDFs"
|
||||
|
||||
[payg.free.cta]
|
||||
benefit1Body = "chain tools, schedule runs, batch process"
|
||||
benefit1Title = "Automation pipelines"
|
||||
benefit2Body = "summarise, classify, redact, AI-OCR"
|
||||
benefit2Title = "AI tools"
|
||||
benefit3Body = "call any Stirling endpoint programmatically"
|
||||
benefit3Title = "API access"
|
||||
button = "Turn on Processor →"
|
||||
reassurance = "No minimum · Set a $0 cap to test · Cancel anytime"
|
||||
subtitle = "Keep going past your 500 free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control."
|
||||
title = "Turn on the Processor plan"
|
||||
|
||||
[payg.free.editor]
|
||||
eyebrow = "Editor plan · Always free"
|
||||
|
||||
[payg.free.explainer]
|
||||
alwaysFreeBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, no matter where you trigger them from."
|
||||
alwaysFreeForYouBody = "Manual tools — viewing, editing, merging, splitting, signing, watermarks, compression, conversion, manual OCR. Use them as much as you want, never counted."
|
||||
alwaysFreeForYouLabel = "Always free for you"
|
||||
alwaysFreeLabel = "Always free"
|
||||
countsBody = "Automation pipelines (chained tools, scheduled runs), AI tools (summaries, classification, AI-OCR), and API calls (programmatic access). Past your free PDFs you'll need Processor."
|
||||
countsLabel = "Counts toward your 500 free"
|
||||
sharedBody = "Automation pipelines, AI tools, and API calls share a single one-time allowance of 500 free PDFs across the whole team. Once it's used up, ask your owner to enable Processor."
|
||||
sharedLabel = "Shared with the team"
|
||||
|
||||
[payg.free.header]
|
||||
subtitle = "Editor plan — manual tools are always free. Pay only for automation, AI & API. Billing period {{period}}."
|
||||
|
||||
[payg.free.hero]
|
||||
capSuffix = "/ {{limit}} free PDFs"
|
||||
eyebrow = "Your free PDFs"
|
||||
metaCategories = "Automation · AI · API requests"
|
||||
neverResets = "One-time — never resets"
|
||||
|
||||
[payg.free.member]
|
||||
body = "Your team owner can enable the Processor plan and set a monthly ceiling. Until then, manual tools are free for you to use as much as you like — automation, AI, and API access share the team's one-time allowance of 500 free PDFs."
|
||||
ownerOnly = "Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like."
|
||||
title = "Want to process more than your 500 free PDFs?"
|
||||
|
||||
[payg.free.proc]
|
||||
eyebrow = "Processor plan · metered"
|
||||
|
||||
[payg.free.state]
|
||||
approachingLimit = "Approaching limit"
|
||||
limitReached = "Limit reached"
|
||||
plentyLeft = "Plenty left"
|
||||
|
||||
[payg.gates]
|
||||
ai = "AI tools (AI Create, suggestions, AI-OCR)"
|
||||
automation = "Automations & pipelines"
|
||||
client = "Browser-only tools (viewer, page editor, file management)"
|
||||
offsite = "Server tools (compress, OCR, convert, watermark…)"
|
||||
pauses = "pauses at cap"
|
||||
subtitle = "Your everyday tools keep working. Only AI and automation pause until the cap resets or is raised."
|
||||
title = "What happens when the cap is reached"
|
||||
|
||||
[payg.member]
|
||||
askLeader = "Only your team owner can change the cap."
|
||||
|
||||
[payg.members]
|
||||
docs = "PDFs"
|
||||
subtitle = "Billable PDFs each teammate has processed this period."
|
||||
title = "Team member usage"
|
||||
|
||||
[payg.role]
|
||||
leader = "Team owner"
|
||||
member = "Member"
|
||||
|
||||
[payg.signupRequired]
|
||||
body = "Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
|
||||
cancel = "Not now"
|
||||
cta = "Sign up free"
|
||||
subtext = "Creating an account is free and takes a few seconds. No credit card required."
|
||||
title = "Sign up to use {{category}}"
|
||||
|
||||
[payg.signupRequired.category]
|
||||
ai = "AI features"
|
||||
api = "this tool"
|
||||
automation = "automations"
|
||||
default = "this feature"
|
||||
|
||||
[payg.state]
|
||||
degraded = "Cap reached"
|
||||
full = "Healthy"
|
||||
warned = "Approaching cap"
|
||||
|
||||
[payg.stripe]
|
||||
open = "Open billing portal"
|
||||
subtitle = "Receipts, invoices, payment method, billing currency."
|
||||
title = "Manage billing in Stripe"
|
||||
|
||||
[payg.stripe.toast.unavailable]
|
||||
body = "Billing portal isn't available right now. Try again in a moment."
|
||||
title = "Billing portal unavailable"
|
||||
|
||||
[payg.upgrade]
|
||||
closeAria = "Close"
|
||||
|
||||
[payg.upgrade.button]
|
||||
back = "← Back"
|
||||
cancel = "Cancel"
|
||||
continue = "Continue →"
|
||||
finish = "Finish"
|
||||
|
||||
[payg.upgrade.cap]
|
||||
customPlaceholder = "Or enter your own amount ($0 keeps it free)"
|
||||
help = "We'll never charge above this. Set $0 if you want to keep everything free while testing."
|
||||
noCapHint = "You can still cancel anytime from the customer portal."
|
||||
noCapLabel = "No cap — I'll manage spend manually"
|
||||
perMonthSuffix = "/ month"
|
||||
presetsAria = "Monthly cap preset"
|
||||
title = "Set your monthly spend ceiling"
|
||||
usdNote = "Estimated in USD. You can adjust your cap any time after subscribing — in your own currency."
|
||||
|
||||
[payg.upgrade.checkout]
|
||||
capLabel = "Monthly ceiling:"
|
||||
capValue = "{{symbol}}{{amount}} / month"
|
||||
edit = "Edit"
|
||||
help = "Stripe handles your card details. Stirling never sees them."
|
||||
loading = "Loading checkout…"
|
||||
noCap = "No cap"
|
||||
title = "Add your payment method"
|
||||
|
||||
[payg.upgrade.footer]
|
||||
capHint = "You can change your cap any time later."
|
||||
checkoutHint = "Card details handled by Stripe — never touched by Stirling."
|
||||
confirmHint = "Your wallet will refresh automatically in a moment."
|
||||
|
||||
[payg.upgrade.help]
|
||||
aiBody = "summarise, classify, redact, AI-OCR"
|
||||
aiTitle = "AI tools"
|
||||
apiBody = "programmatic access to any Stirling endpoint"
|
||||
apiTitle = "API calls"
|
||||
automationBody = "chained tools or scheduled runs that don't need clicks"
|
||||
automationTitle = "Automation pipelines"
|
||||
footnote = "Manual tools — viewing, editing, merging, splitting, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click."
|
||||
title = "What we count toward billing"
|
||||
|
||||
[payg.upgrade.promise]
|
||||
body = "You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge."
|
||||
highlight = "Manual tools stay free, always."
|
||||
|
||||
[payg.upgrade.steps]
|
||||
cap = "Set monthly ceiling"
|
||||
payment = "Add payment method"
|
||||
|
||||
[payg.upgrade.title]
|
||||
confirm = "You're subscribed"
|
||||
default = "Upgrade to Processor plan"
|
||||
|
||||
[payg.usage]
|
||||
credit = "{{amount}} account credit"
|
||||
docs = "documents"
|
||||
resetsIn = "Resets in {{days}} days"
|
||||
resetsTomorrow = "Resets tomorrow"
|
||||
spent = "{{spend}} of {{cap}} used"
|
||||
thisPeriod = "This billing period"
|
||||
|
||||
[payment]
|
||||
autoClose = "This window will close automatically..."
|
||||
canCloseWindow = "You can now close this window."
|
||||
|
||||
@@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"help",
|
||||
"payg",
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface AppConfig {
|
||||
enableDesktopInstallSlide?: boolean;
|
||||
premiumEnabled?: boolean;
|
||||
premiumKey?: string;
|
||||
paygEnabled?: boolean;
|
||||
termsAndConditions?: string;
|
||||
privacyPolicy?: string;
|
||||
cookiePolicy?: string;
|
||||
|
||||
@@ -13,6 +13,7 @@ import AuthCallback from "@app/routes/AuthCallback";
|
||||
import ResetPassword from "@app/routes/ResetPassword";
|
||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||
import TrialExpiredBootstrap from "@app/components/TrialExpiredBootstrap";
|
||||
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
||||
|
||||
// Import global styles
|
||||
import "@app/styles/tailwind.css";
|
||||
@@ -36,6 +37,7 @@ export default function App() {
|
||||
<AppLayout>
|
||||
<OnboardingBootstrap />
|
||||
<TrialExpiredBootstrap />
|
||||
<SignupRequiredBootstrap />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
|
||||
@@ -18,11 +18,8 @@ import {
|
||||
CreditSummary,
|
||||
SubscriptionInfo,
|
||||
CreditCheckResult,
|
||||
ApiCredits,
|
||||
} from "@app/types/credits";
|
||||
import apiClient, {
|
||||
setGlobalCreditUpdateCallback,
|
||||
} from "@app/services/apiClient";
|
||||
import { setGlobalCreditUpdateCallback } from "@app/services/apiClient";
|
||||
import { synchronizeUserUpgrade } from "@app/services/userService";
|
||||
import {
|
||||
syncOAuthAvatar,
|
||||
@@ -145,72 +142,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [profilePictureMetadata, setProfilePictureMetadata] =
|
||||
useState<ProfilePictureMetadata | null>(null);
|
||||
|
||||
const fetchCredits = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
const currentSession = sessionToUse ?? session;
|
||||
|
||||
if (!currentSession?.user) {
|
||||
console.debug("[Auth Debug] No user session, skipping credit fetch");
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(
|
||||
"[Auth Debug] Fetching credits for user:",
|
||||
currentSession.user.id,
|
||||
);
|
||||
// Auto-fires on session init and TOKEN_REFRESHED; a backend 401 must
|
||||
// stay local rather than trigger the global login redirect.
|
||||
const response = await apiClient.get<ApiCredits>("/api/v1/credits", {
|
||||
suppressErrorToast: true,
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
const apiCredits = response.data;
|
||||
|
||||
// Map server payload to app CreditSummary
|
||||
const credits: CreditSummary = {
|
||||
currentCredits: apiCredits.totalAvailableCredits,
|
||||
maxCredits:
|
||||
apiCredits.weeklyCreditsAllocated + apiCredits.totalBoughtCredits,
|
||||
creditsUsed:
|
||||
apiCredits.weeklyCreditsAllocated -
|
||||
apiCredits.weeklyCreditsRemaining +
|
||||
(apiCredits.totalBoughtCredits - apiCredits.boughtCreditsRemaining),
|
||||
creditsRemaining: apiCredits.totalAvailableCredits,
|
||||
resetDate: apiCredits.weeklyResetDate,
|
||||
weeklyAllowance: apiCredits.weeklyCreditsAllocated,
|
||||
};
|
||||
|
||||
setCreditSummary(credits);
|
||||
setCreditBalance(credits.creditsRemaining);
|
||||
|
||||
const subscriptionInfo: SubscriptionInfo = {
|
||||
status: "active",
|
||||
tier: (credits.weeklyAllowance || 0) > 100 ? "premium" : "free",
|
||||
creditsPerWeek: credits.weeklyAllowance,
|
||||
maxCredits: credits.maxCredits,
|
||||
};
|
||||
setSubscription(subscriptionInfo);
|
||||
|
||||
console.debug("[Auth Debug] Credits fetched successfully:", credits);
|
||||
} catch (error: unknown) {
|
||||
console.debug("[Auth Debug] Failed to fetch credits:", error);
|
||||
// Don't set error state for credit fetching failures to avoid disrupting auth flow
|
||||
// Credits might not be available in all deployments
|
||||
setCreditBalance(null);
|
||||
setCreditSummary(null);
|
||||
setSubscription(null);
|
||||
}
|
||||
},
|
||||
[session],
|
||||
);
|
||||
// Legacy weekly-credits feed (GET /api/v1/credits) is dead. PAYG replaces it via
|
||||
// useWallet() reading /api/v1/payg/wallet. Symbols are kept as no-ops so existing
|
||||
// consumers of useAuth() that still destructure creditBalance / refreshCredits
|
||||
// compile cleanly; values just stay null forever and refreshCredits is a noop.
|
||||
// _ underscore on the param keeps the public signature stable for callers.
|
||||
const fetchCredits = useCallback(async (_sessionToUse?: Session | null) => {
|
||||
/* legacy credit fetch removed — see comment above */
|
||||
}, []);
|
||||
|
||||
const refreshCredits = useCallback(async () => {
|
||||
await fetchCredits();
|
||||
}, [fetchCredits]);
|
||||
/* legacy credit refresh removed — useWallet() replaces this */
|
||||
}, []);
|
||||
|
||||
const fetchProStatus = useCallback(
|
||||
async (sessionToUse?: Session | null) => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Modal, Stack, Button, Text } from "@mantine/core";
|
||||
import PersonAddIcon from "@mui/icons-material/PersonAdd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||
import type { PaygSignupRequiredDetail } from "@app/services/paygErrorInterceptor";
|
||||
|
||||
/**
|
||||
* Bootstrap that listens for {@code payg:signupRequired} (dispatched by
|
||||
* the {@code apiClient} response interceptor when an anonymous user hits
|
||||
* a billable endpoint and the server returns {@code 401 SIGNUP_REQUIRED})
|
||||
* and opens a Mantine modal explaining the free 500-op/month allowance
|
||||
* with a "Sign up free" CTA.
|
||||
*
|
||||
* <h2>Why an event bus instead of direct render</h2>
|
||||
* The {@code apiClient} module is created at app boot, outside the React
|
||||
* tree, and can't import JSX. We bridge with a {@code CustomEvent}: the
|
||||
* interceptor dispatches, this bootstrap (mounted near the app root)
|
||||
* listens and renders. Same pattern as {@code TrialExpiredBootstrap},
|
||||
* just driven by a request-side trigger rather than auth state.
|
||||
*
|
||||
* <h2>De-duping</h2>
|
||||
* If the user fires multiple billable requests in quick succession (e.g.
|
||||
* clicking a tool button twice), only one modal opens — the listener
|
||||
* ignores the event when the modal is already visible. The modal closes
|
||||
* on backdrop click or Escape; we don't gate it on a localStorage flag
|
||||
* because this is a deterministic "you need an account" UI, not a one-
|
||||
* time onboarding nudge.
|
||||
*/
|
||||
export default function SignupRequiredBootstrap() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent<PaygSignupRequiredDetail>).detail;
|
||||
// De-dupe: if the modal is already open, the existing copy wins.
|
||||
// The user is already being prompted; piling another open call on
|
||||
// top would just flicker the same content.
|
||||
setOpened((wasOpen) => {
|
||||
if (!wasOpen) {
|
||||
setCategory(detail?.category ?? null);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
window.addEventListener("payg:signupRequired", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"payg:signupRequired",
|
||||
handler as EventListener,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Map the server's gate categories to user-facing nouns. The server
|
||||
// returns the FeatureGate name (AI, AUTOMATION, API); the user has
|
||||
// no idea what those are in raw form, so we pretty-print here. The
|
||||
// fallback "this feature" keeps the modal sensible if the BE adds
|
||||
// a category we don't know about.
|
||||
const categoryNoun = useMemo(() => {
|
||||
switch ((category ?? "").toUpperCase()) {
|
||||
case "AI":
|
||||
return t("payg.signupRequired.category.ai", "AI features");
|
||||
case "AUTOMATION":
|
||||
return t("payg.signupRequired.category.automation", "automations");
|
||||
case "API":
|
||||
return t("payg.signupRequired.category.api", "this tool");
|
||||
default:
|
||||
return t("payg.signupRequired.category.default", "this feature");
|
||||
}
|
||||
}, [category, t]);
|
||||
|
||||
const handleSignUp = () => {
|
||||
window.location.href = withBasePath("/signup");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
withCloseButton
|
||||
centered
|
||||
size="md"
|
||||
radius="lg"
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
title={
|
||||
<Text fw={700} size="lg">
|
||||
{t("payg.signupRequired.title", "Sign up to use {{category}}", {
|
||||
category: categoryNoun,
|
||||
})}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
{t(
|
||||
"payg.signupRequired.body",
|
||||
"Stirling PDF gives every signed-up account 500 free operations — enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing.",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payg.signupRequired.subtext",
|
||||
"Creating an account is free and takes a few seconds. No credit card required.",
|
||||
)}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<Button variant="default" onClick={() => setOpened(false)}>
|
||||
{t("payg.signupRequired.cancel", "Not now")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<PersonAddIcon style={{ fontSize: 16 }} />}
|
||||
onClick={handleSignUp}
|
||||
>
|
||||
{t("payg.signupRequired.cta", "Sign up free")}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,24 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
window.removeEventListener("appConfig:notice", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// Full-screen overlays that live inside our React tree (e.g. the PAYG
|
||||
// UpgradeModal, portal'd to document.body) announce themselves here so we
|
||||
// can hide — not unmount — while they're up. Unmounting would kill the
|
||||
// overlay itself since it's our descendant; hiding keeps all section state
|
||||
// (active tab, scroll, wallet data) intact for when the overlay closes.
|
||||
const [overlayActive, setOverlayActive] = useState(false);
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as
|
||||
| { open?: boolean }
|
||||
| undefined;
|
||||
setOverlayActive(Boolean(detail?.open));
|
||||
};
|
||||
window.addEventListener("appConfig:overlay", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener("appConfig:overlay", handler as EventListener);
|
||||
}, []);
|
||||
|
||||
// When the modal opens to Plan, proactively refresh credits and log values
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
@@ -106,7 +124,9 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
|
||||
const openLogoutConfirm = useCallback(() => setConfirmOpen(true), []);
|
||||
|
||||
// Left navigation structure and icons
|
||||
// Left navigation structure and icons. The Plan tab now internally branches
|
||||
// free vs subscribed × leader vs member via useWallet(), so the modal no
|
||||
// longer plumbs paygEnabled / isLeader through to the nav builder.
|
||||
const configNavSections = useMemo(
|
||||
() =>
|
||||
createSaasConfigNavSections(Overview, openLogoutConfirm, {
|
||||
@@ -139,7 +159,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
size={isMobile ? "100%" : 1200}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
@@ -147,6 +167,17 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
// Hidden (not closed) while a child overlay like the PAYG UpgradeModal
|
||||
// is up — see the appConfig:overlay listener above. The focus trap and
|
||||
// escape/outside-close must release too: the trap would steal focus
|
||||
// from the Stripe card iframe, and Escape would close US underneath
|
||||
// the overlay — unmounting the checkout mid-payment.
|
||||
styles={{
|
||||
root: { display: overlayActive ? "none" : undefined },
|
||||
}}
|
||||
trapFocus={!overlayActive}
|
||||
closeOnEscape={!overlayActive}
|
||||
closeOnClickOutside={!overlayActive}
|
||||
>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
|
||||
@@ -42,10 +42,7 @@ export function ManageBillingButton({
|
||||
url: string;
|
||||
error?: string;
|
||||
}>("manage-billing", {
|
||||
body: {
|
||||
name: "Functions",
|
||||
return_url: returnUrl,
|
||||
},
|
||||
body: { return_url: returnUrl },
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || "error" in data)
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
/* Pay-as-you-go settings screen — visual polish.
|
||||
Uses the app's semantic theme tokens (theme.css) so it tracks light/dark. */
|
||||
|
||||
.payg {
|
||||
--payg-accent: #0a8bff;
|
||||
--payg-accent-soft: rgba(10, 139, 255, 0.1);
|
||||
--payg-radius: 14px;
|
||||
--payg-gap: 20px;
|
||||
/* Cards sit ON the modal content bg; in light that's white-on-white so we
|
||||
lean on border + shadow. Tokens are overridden per scheme below. */
|
||||
--payg-card-bg: var(--bg-surface);
|
||||
--payg-card-border: var(--border-default);
|
||||
--payg-inset-bg: var(--bg-raised);
|
||||
--payg-divider: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Dark mode: the modal content bg is #2a2f36 and so is --bg-surface, so plain
|
||||
cards vanish. Lift cards a shade above the modal and strengthen borders. */
|
||||
[data-mantine-color-scheme="dark"] .payg {
|
||||
--payg-card-bg: #313842;
|
||||
--payg-card-border: #3d444e;
|
||||
--payg-inset-bg: #272c33;
|
||||
--payg-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--payg-divider: #3d444e;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────── */
|
||||
.payg-header__title {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-header__subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-role-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payg-role-pill[data-leader="true"] {
|
||||
background: var(--payg-accent-soft);
|
||||
color: var(--payg-accent);
|
||||
border: 1px solid rgba(10, 139, 255, 0.25);
|
||||
}
|
||||
.payg-role-pill[data-leader="false"] {
|
||||
background: var(--bg-muted);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* ── Plan header: free-vs-metered split (shared by free + subscribed) ──── */
|
||||
.payg-planhead {
|
||||
padding: 14px 20px;
|
||||
border-radius: var(--payg-radius);
|
||||
background: var(--payg-card-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
}
|
||||
.payg-planhead__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.payg-planhead__eyebrow {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-planhead__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
}
|
||||
.payg-planhead__col {
|
||||
padding-right: 22px;
|
||||
}
|
||||
.payg-planhead__col--meter {
|
||||
padding-right: 0;
|
||||
padding-left: 22px;
|
||||
border-left: 1px solid var(--payg-divider);
|
||||
}
|
||||
.payg-planhead__lbl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.payg-planhead__lbl--free {
|
||||
color: #10b981;
|
||||
}
|
||||
.payg-planhead__lbl--meter {
|
||||
color: var(--payg-accent);
|
||||
}
|
||||
.payg-planhead__lbl-icon {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
.payg-planhead__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.payg-planhead__body {
|
||||
margin: 5px 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.payg-planhead__split {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.payg-planhead__col {
|
||||
padding-right: 0;
|
||||
}
|
||||
.payg-planhead__col--meter {
|
||||
padding-left: 0;
|
||||
padding-top: 16px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--payg-divider);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Generic card ───────────────────────────────────────────────────── */
|
||||
.payg-card {
|
||||
background: var(--payg-card-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
border-radius: var(--payg-radius);
|
||||
padding: 16px 20px;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.payg-card__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-card__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Hero usage panel ───────────────────────────────────────────────── */
|
||||
.payg-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--payg-radius);
|
||||
padding: 18px 22px;
|
||||
border: 1px solid var(--payg-card-border);
|
||||
background: var(--payg-card-bg);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
.payg-hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.payg-hero[data-state="FULL"]::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(10, 139, 255, 0.12) 0%,
|
||||
rgba(10, 139, 255, 0) 55%
|
||||
);
|
||||
}
|
||||
.payg-hero[data-state="WARNED"]::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(234, 179, 8, 0.16) 0%,
|
||||
rgba(234, 179, 8, 0) 55%
|
||||
);
|
||||
}
|
||||
.payg-hero[data-state="DEGRADED"]::before {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(239, 68, 68, 0.16) 0%,
|
||||
rgba(239, 68, 68, 0) 55%
|
||||
);
|
||||
}
|
||||
.payg-hero__inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.payg-hero__eyebrow {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-hero__figure {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.payg-hero__spend {
|
||||
font-size: 2.35rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.payg-hero__cap {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.payg-hero__meta {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 18px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.payg-hero__meta-dot {
|
||||
color: var(--border-strong);
|
||||
}
|
||||
.payg-hero__credit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-green-700);
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .payg-hero__credit {
|
||||
color: #4ade80;
|
||||
background: rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
|
||||
/* Make Mantine default-variant buttons read clearly on the lifted cards in
|
||||
both themes (their stock border can wash out against --payg-card-bg). */
|
||||
.payg .mantine-Button-root[data-variant="default"] {
|
||||
border-color: var(--payg-card-border);
|
||||
}
|
||||
|
||||
/* Dark mode: the stock default button is a flat slab that disappears into the
|
||||
card. Lift it with a soft top-down gradient + brighter border, and warm the
|
||||
hover so the buttons feel tactile against --payg-card-bg (#313842). Driven
|
||||
through Mantine's --button-* vars. The same rule covers disabled buttons
|
||||
(Cancel/Update cap before the form is dirty) so they read identically to the
|
||||
always-enabled ones — Mantine only fades them via opacity. */
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.payg
|
||||
.mantine-Button-root[data-variant="default"] {
|
||||
--button-bg: linear-gradient(180deg, #3d4651 0%, #353d48 100%);
|
||||
--button-hover: linear-gradient(180deg, #475160 0%, #3d4654 100%);
|
||||
--button-bd: 1px solid #4b5563;
|
||||
--button-color: var(--mantine-color-white);
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
/* Mantine recolours disabled buttons to a dim grey via a hard-coded color/bg
|
||||
on the disabled selector, overriding --button-color. Re-assert white + the
|
||||
gradient so disabled buttons match the rest. */
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.payg
|
||||
.mantine-Button-root[data-variant="default"]:disabled,
|
||||
[data-mantine-color-scheme="dark"]
|
||||
.payg
|
||||
.mantine-Button-root[data-variant="default"][data-disabled] {
|
||||
background: var(--button-bg);
|
||||
color: var(--mantine-color-white);
|
||||
}
|
||||
|
||||
/* Status chip */
|
||||
.payg-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 13px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payg-status__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.payg-status[data-state="FULL"] {
|
||||
background: var(--color-green-100);
|
||||
color: var(--color-green-700);
|
||||
}
|
||||
.payg-status[data-state="FULL"] .payg-status__dot {
|
||||
background: var(--color-green-500);
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
.payg-status[data-state="WARNED"] {
|
||||
background: var(--color-yellow-100);
|
||||
color: var(--color-yellow-700);
|
||||
}
|
||||
.payg-status[data-state="WARNED"] .payg-status__dot {
|
||||
background: var(--color-yellow-500);
|
||||
box-shadow: 0 0 0 3px rgba(234, 179, 8, 0.2);
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] {
|
||||
background: var(--color-red-100);
|
||||
color: var(--color-red-700);
|
||||
}
|
||||
.payg-status[data-state="DEGRADED"] .payg-status__dot {
|
||||
background: var(--color-red-500);
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Segmented usage bar */
|
||||
.payg-bar {
|
||||
margin-top: 18px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-muted);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.payg-bar__fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.payg-bar__fill[data-state="FULL"] {
|
||||
background: linear-gradient(90deg, #0a8bff, #38bdf8);
|
||||
}
|
||||
.payg-bar__fill[data-state="WARNED"] {
|
||||
background: linear-gradient(90deg, #f59e0b, #fbbf24);
|
||||
}
|
||||
.payg-bar__fill[data-state="DEGRADED"] {
|
||||
background: linear-gradient(90deg, #dc2626, #f87171);
|
||||
}
|
||||
|
||||
/* ── "What counts as a document?" expandable help ───────────────────── */
|
||||
.payg-help {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.payg-help__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.payg-help__toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-help__chevron {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.payg-help__toggle[aria-expanded="true"] .payg-help__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.payg-help__panel {
|
||||
margin-top: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--payg-card-border);
|
||||
background: var(--bg-muted);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.payg-help__panel ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ── Cap preview strip ──────────────────────────────────────────────── */
|
||||
.payg-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 16px;
|
||||
border-radius: 10px;
|
||||
background: var(--payg-accent-soft);
|
||||
border: 1px solid rgba(10, 139, 255, 0.2);
|
||||
}
|
||||
.payg-preview__icon {
|
||||
color: var(--payg-accent);
|
||||
display: flex;
|
||||
}
|
||||
.payg-preview__main {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 550;
|
||||
}
|
||||
.payg-preview__note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Gates grid ─────────────────────────────────────────────────────── */
|
||||
.payg-gates {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.payg-gates {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.payg-gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 13px 15px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid var(--payg-card-border);
|
||||
background: var(--payg-inset-bg);
|
||||
}
|
||||
.payg-gate[data-enabled="false"] {
|
||||
border-style: dashed;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.payg-gate__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payg-gate[data-enabled="true"] .payg-gate__chip {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
color: #22c55e;
|
||||
}
|
||||
.payg-gate[data-enabled="false"] .payg-gate__chip {
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
color: #f87171;
|
||||
}
|
||||
.payg-gate__label {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.35;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.payg-gate__tag {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.payg-gate__tag[data-variant="on"] {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
.payg-gate__tag[data-variant="pause"] {
|
||||
color: #dc2626;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .payg-gate__tag[data-variant="pause"] {
|
||||
color: #f87171;
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
/* ── Member rows ────────────────────────────────────────────────────── */
|
||||
.payg-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 4px;
|
||||
border-bottom: 1px solid var(--payg-divider);
|
||||
}
|
||||
.payg-member:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.payg-member__avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payg-member__name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 550;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-member__email {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-member__usage {
|
||||
text-align: right;
|
||||
min-width: 120px;
|
||||
}
|
||||
.payg-member__usage-num {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.payg-member__minibar {
|
||||
height: 5px;
|
||||
width: 90px;
|
||||
border-radius: 999px;
|
||||
background: var(--payg-inset-bg);
|
||||
overflow: hidden;
|
||||
margin-top: 5px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.payg-member__minibar-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--payg-accent);
|
||||
}
|
||||
|
||||
/* ── Activity feed ──────────────────────────────────────────────────── */
|
||||
.payg-activity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 4px;
|
||||
border-bottom: 1px solid var(--payg-divider);
|
||||
}
|
||||
.payg-activity-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.payg-activity__dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.payg-activity__dot[data-kind="ai"] {
|
||||
background: var(--category-color-formatting); /* purple */
|
||||
}
|
||||
.payg-activity__dot[data-kind="automation"] {
|
||||
background: var(--category-color-automation); /* pink */
|
||||
}
|
||||
.payg-activity__label {
|
||||
font-size: 0.8438rem;
|
||||
color: var(--text-primary);
|
||||
flex: 1;
|
||||
}
|
||||
.payg-activity__ts {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.payg-activity__kind {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.payg-activity__units {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 58px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Stripe CTA card ────────────────────────────────────────────────── */
|
||||
.payg-stripe {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
border-radius: var(--payg-radius);
|
||||
border: 1px solid rgba(99, 91, 255, 0.28);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(99, 91, 255, 0.1) 0%,
|
||||
rgba(99, 91, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
.payg-stripe__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 650;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.payg-stripe__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
/**
|
||||
* Pay-as-you-go billing & usage section — the SUBSCRIBED leader/member views.
|
||||
*
|
||||
* All data comes from the real {@code Wallet} snapshot ({@code GET
|
||||
* /api/v1/payg/wallet} via {@link useWallet}); there is no mock fallback. What
|
||||
* the wallet doesn't provide, the UI doesn't show:
|
||||
*
|
||||
* - spend + cap render in the units/USD the backend actually returns
|
||||
* ({@code spendUnitsThisPeriod}, {@code capUsd}, {@code noCap})
|
||||
* - the per-category breakdown comes from {@code categoryBreakdown}
|
||||
* (wallet_category_summary view)
|
||||
* - money-equivalent display and the units↔money cap preview need
|
||||
* stripe.prices via Sync Engine (design §13 / PR-C2) and are deliberately
|
||||
* absent until that ships
|
||||
* - the activity feed renders {@code wallet.recent}, which is {@code []} in
|
||||
* V1 — so it shows a real empty state, not fabricated rows
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useRenderCount } from "@app/hooks/useRenderCount";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
import CheckIcon from "@mui/icons-material/CheckRounded";
|
||||
import HelpOutlineIcon from "@mui/icons-material/HelpOutlineRounded";
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMoreRounded";
|
||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
|
||||
import { alert as showToast } from "@app/components/toast";
|
||||
// Relative (not @app/*) so the co-located CSS + sibling component resolve directly.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./Payg.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import SpendCapControl from "./SpendCapControl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Wallet } from "@app/hooks/useWallet";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type Gate = "OFFSITE_PROCESSING" | "AUTOMATION" | "AI_SUPPORT" | "CLIENT_SIDE";
|
||||
|
||||
interface PaygProps {
|
||||
role: "LEADER" | "MEMBER";
|
||||
/** Real wallet snapshot from {@code useWallet}. Single source of truth. */
|
||||
wallet: Wallet;
|
||||
/**
|
||||
* Persist a cap change. Provided by {@code Plan} → {@code useWallet} for the
|
||||
* leader view; absent on the member view (read-only).
|
||||
*/
|
||||
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
|
||||
/**
|
||||
* Open the Stripe Customer Portal. When omitted the Stripe card is hidden.
|
||||
* On error the implementation shows a friendly toast and resolves — callers
|
||||
* don't need to wrap in try/catch.
|
||||
*/
|
||||
onOpenPortal?: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Stable-ish avatar colour from a string.
|
||||
const AVATAR_COLORS = [
|
||||
"#0a8bff",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#10b981",
|
||||
"#f59e0b",
|
||||
"#06b6d4",
|
||||
];
|
||||
function avatarColor(seed: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) | 0;
|
||||
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
function gateLabel(
|
||||
g: Gate,
|
||||
t: (k: string, fallback: string) => string,
|
||||
): string {
|
||||
switch (g) {
|
||||
case "OFFSITE_PROCESSING":
|
||||
return t(
|
||||
"payg.gates.offsite",
|
||||
"Server tools (compress, OCR, convert, watermark…)",
|
||||
);
|
||||
case "AUTOMATION":
|
||||
return t("payg.gates.automation", "Automations & pipelines");
|
||||
case "AI_SUPPORT":
|
||||
return t("payg.gates.ai", "AI tools (AI Create, suggestions, AI-OCR)");
|
||||
case "CLIENT_SIDE":
|
||||
return t(
|
||||
"payg.gates.client",
|
||||
"Browser-only tools (viewer, page editor, file management)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── "What counts as a document?" help ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Expandable explainer for the billing unit. Shared by the subscribed hero
|
||||
* here and the free-tier hero in {@code PaygFree.tsx}. The bullets state the
|
||||
* real charge mechanics (DefaultDocumentClassifier + JobChargeService)
|
||||
* without hardcoding the policy-tunable thresholds: each non-empty file is
|
||||
* at least one document; page count / file size can make it more; chained
|
||||
* steps on the same file join the open process instead of re-charging; a
|
||||
* first-step failure writes a compensating refund.
|
||||
*/
|
||||
export function DocHelp() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="payg-help">
|
||||
<button
|
||||
type="button"
|
||||
className="payg-help__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15 }} />
|
||||
{t("payg.docHelp.toggle", "What counts as a PDF?")}
|
||||
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="payg-help__panel">
|
||||
<ul>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.billable",
|
||||
"Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.perFile",
|
||||
"Each file you process counts as one PDF. Very long or very large files can count as more than one.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.chains",
|
||||
"Running the same file through several steps of one automation counts it once, not once per step.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"payg.docHelp.refunds",
|
||||
"If a job fails on its first step, the PDF is credited back automatically.",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hero usage panel ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format minor units of an ISO currency for display ("$2.24", "£0.40"). Only
|
||||
* called when the backend resolved the rate — currency is always present
|
||||
* alongside a non-null money amount.
|
||||
*/
|
||||
function formatMinor(minor: number, currency: string | null): string {
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
}).format(minor / 100);
|
||||
} catch {
|
||||
return `${(minor / 100).toFixed(2)} ${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
||||
function currencySymbol(currency: string | null): string {
|
||||
switch ((currency ?? "").toLowerCase()) {
|
||||
case "usd":
|
||||
return "$";
|
||||
case "eur":
|
||||
return "€";
|
||||
case "gbp":
|
||||
return "£";
|
||||
default:
|
||||
return currency ? currency.toUpperCase() + " " : "$";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage hero — gradient panel with the allowance bar. Every number is real:
|
||||
* {@code billableUsed} is the ledger's period sum over the team's actual
|
||||
* billing window (the Stripe subscription period); {@code billableLimit} is
|
||||
* the backend-derived document ceiling (free allowance + what the money cap
|
||||
* buys at the subscription Price's per-document rate, null when uncapped);
|
||||
* {@code estimatedBillMinor} is spend beyond the allowance at that rate.
|
||||
* Fields the backend couldn't resolve are null and simply not rendered.
|
||||
*/
|
||||
function UsageHero({ wallet }: { wallet: Wallet }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const periodEnd = new Date(wallet.billingPeriodEnd);
|
||||
const daysLeft = Math.max(
|
||||
0,
|
||||
Math.ceil((periodEnd.getTime() - Date.now()) / 86_400_000),
|
||||
);
|
||||
const hasCap = !wallet.noCap && wallet.capUsd != null;
|
||||
const breakdown = wallet.categoryBreakdown;
|
||||
|
||||
const limit = wallet.billableLimit;
|
||||
const hasLimit = limit != null && limit > 0;
|
||||
const pct = hasLimit
|
||||
? Math.min(100, (wallet.billableUsed / limit) * 100)
|
||||
: 0;
|
||||
const state = hasLimit
|
||||
? pct >= 100
|
||||
? "DEGRADED"
|
||||
: pct >= 80
|
||||
? "WARNED"
|
||||
: "FULL"
|
||||
: "FULL";
|
||||
const stateLabel = {
|
||||
FULL: t("payg.state.full", "Healthy"),
|
||||
WARNED: t("payg.state.warned", "Approaching cap"),
|
||||
DEGRADED: t("payg.state.degraded", "Cap reached"),
|
||||
}[state];
|
||||
|
||||
return (
|
||||
<div className="payg-hero" data-state={state}>
|
||||
<div className="payg-hero__inner">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<div>
|
||||
<div className="payg-hero__eyebrow">
|
||||
{t("payg.usage.thisPeriod", "This billing period")}
|
||||
</div>
|
||||
<div className="payg-hero__figure">
|
||||
<span className="payg-hero__spend">
|
||||
{wallet.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="payg-hero__cap">
|
||||
{hasLimit
|
||||
? t(
|
||||
"payg.usage.ofLimitProcessed",
|
||||
"/ {{limit}} PDFs processed",
|
||||
{ limit: limit.toLocaleString() },
|
||||
)
|
||||
: t("payg.usage.processed", "PDFs processed")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="payg-status" data-state={state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{hasLimit && (
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="payg-hero__meta">
|
||||
<span>
|
||||
{t("payg.usage.firstFree", "First {{free}} free", {
|
||||
free: wallet.freeAllowance.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
{wallet.estimatedBillMinor != null && (
|
||||
<>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{t("payg.usage.estBill", "≈ {{amount}} so far this period", {
|
||||
amount: formatMinor(
|
||||
wallet.estimatedBillMinor,
|
||||
wallet.currency,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{hasCap
|
||||
? t("payg.usage.capLine", "{{cap}}/mo cap", {
|
||||
cap: `${currencySymbol(wallet.currency)}${wallet.capUsd}`,
|
||||
})
|
||||
: t("payg.usage.noCap", "No monthly cap")}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>
|
||||
{daysLeft === 1
|
||||
? t("payg.usage.resetsTomorrow", "Resets tomorrow")
|
||||
: t("payg.usage.resetsIn", "Resets in {{days}} days", {
|
||||
days: daysLeft,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-hero__meta" style={{ marginTop: 6 }}>
|
||||
<span>
|
||||
{t(
|
||||
"payg.usage.breakdown",
|
||||
"AI {{ai}} • Automation {{automation}} • API {{api}}",
|
||||
{
|
||||
ai: breakdown.ai.toLocaleString(),
|
||||
automation: breakdown.automation.toLocaleString(),
|
||||
api: breakdown.api.toLocaleString(),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocHelp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cap editor ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface CapEditorProps {
|
||||
/** Current cap in major currency units; null = no cap set. */
|
||||
capUsd: number | null;
|
||||
/** True when the leader explicitly disabled the cap. */
|
||||
noCap: boolean;
|
||||
/** Per-document rate in minor units; null when unknown — preview hides. */
|
||||
pricePerDocMinor: number | null;
|
||||
/** Currency of the rate; pairs with {@link CapEditorProps#pricePerDocMinor}. */
|
||||
currency: string | null;
|
||||
/**
|
||||
* Persist the cap change. Receives whole major units (matches the backend's
|
||||
* {@code PATCH /api/v1/payg/cap} body) or null for no-cap.
|
||||
*/
|
||||
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-row cap editor: the shared {@link SpendCapControl} (preset chips +
|
||||
* inline custom-entry pill + no-cap + inline Save) over a live "≈ N paid
|
||||
* PDFs/month" estimate, wrapped in the plan-page card chrome + the cap-reached
|
||||
* disclosure. Save-only — the working value is local, so abandoning the card
|
||||
* abandons the edit. The very same control drives the upgrade checkout flow.
|
||||
*/
|
||||
function CapEditor({
|
||||
capUsd,
|
||||
noCap,
|
||||
pricePerDocMinor,
|
||||
currency,
|
||||
onSaveCap,
|
||||
}: CapEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const savedCap = noCap || capUsd == null ? null : capUsd;
|
||||
const [working, setWorking] = useState<number | null>(savedCap);
|
||||
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.cap.title", "Monthly spending cap")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.cap.subtitle",
|
||||
"The most your team can spend per month. Billable processing pauses at the cap and resumes next period.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpendCapControl
|
||||
capUsd={working}
|
||||
onChange={setWorking}
|
||||
pricePerDocMinor={pricePerDocMinor}
|
||||
currency={currency}
|
||||
savedCapUsd={savedCap}
|
||||
onSave={onSaveCap}
|
||||
/>
|
||||
|
||||
<CapReachedHelp />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const hasCap = !noCap && capUsd != null;
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div className="payg-card__title">
|
||||
{t("payg.cap.title", "Monthly spending cap")}
|
||||
</div>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text fz={28} fw={750} lh={1}>
|
||||
{hasCap ? `$${capUsd}` : t("payg.cap.noneShort", "No cap")}
|
||||
</Text>
|
||||
{hasCap && (
|
||||
<Text c="dimmed">{t("payg.cap.perMonth", "/ month")}</Text>
|
||||
)}
|
||||
</Group>
|
||||
<div className="payg-preview" style={{ marginTop: 6 }}>
|
||||
<div>
|
||||
<div className="payg-preview__main">
|
||||
{t(
|
||||
"payg.member.askLeader",
|
||||
"Only your team owner can change the cap.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CapReachedHelp />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Gates ────────────────────────────────────────────────────────────────
|
||||
|
||||
// What still works once the cap is hit. Everyday tools keep running; only AI
|
||||
// and automation/pipelines pause until the cap resets or is raised.
|
||||
const GATE_CAP_BEHAVIOR: { gate: Gate; staysAtCap: boolean }[] = [
|
||||
{ gate: "CLIENT_SIDE", staysAtCap: true },
|
||||
{ gate: "OFFSITE_PROCESSING", staysAtCap: true },
|
||||
{ gate: "AUTOMATION", staysAtCap: false },
|
||||
{ gate: "AI_SUPPORT", staysAtCap: false },
|
||||
];
|
||||
|
||||
/**
|
||||
* Folded-in "what happens at the cap" — previously its own GatesCard, now a
|
||||
* collapsed disclosure rendered inside the cap card(s) to save vertical space.
|
||||
* Mirrors the {@link DocHelp} toggle pattern; default-collapsed so it costs no
|
||||
* height until the user opens it.
|
||||
*/
|
||||
function CapReachedHelp() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="payg-help">
|
||||
<button
|
||||
type="button"
|
||||
className="payg-help__toggle"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<HelpOutlineIcon sx={{ fontSize: 15 }} />
|
||||
{t("payg.gates.title", "What happens when the cap is reached")}
|
||||
<ExpandMoreIcon className="payg-help__chevron" sx={{ fontSize: 16 }} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="payg-help__panel">
|
||||
<div className="payg-gates">
|
||||
{GATE_CAP_BEHAVIOR.map(({ gate, staysAtCap }) => (
|
||||
<div className="payg-gate" data-enabled={staysAtCap} key={gate}>
|
||||
<span className="payg-gate__chip">
|
||||
{staysAtCap ? (
|
||||
<CheckIcon sx={{ fontSize: 18 }} />
|
||||
) : (
|
||||
<LockIcon sx={{ fontSize: 16 }} />
|
||||
)}
|
||||
</span>
|
||||
<span className="payg-gate__label">{gateLabel(gate, t)}</span>
|
||||
{!staysAtCap && (
|
||||
<span className="payg-gate__tag" data-variant="pause">
|
||||
{t("payg.gates.pauses", "pauses at cap")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Per-member usage ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Leader-only roster of each teammate's billable usage this period. Display-only — per-member
|
||||
* sub-cap enforcement isn't shipped (see the follow-ups note), so there's no cap control here.
|
||||
*/
|
||||
function MemberUsage({ members }: { members: Wallet["members"] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.members.title", "Team member usage")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.members.subtitle",
|
||||
"Billable PDFs each teammate has processed this period.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{members.map((m) => (
|
||||
<div className="payg-member" key={m.userId}>
|
||||
<span
|
||||
className="payg-member__avatar"
|
||||
style={{ background: avatarColor(m.userId) }}
|
||||
>
|
||||
{m.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="payg-member__name">{m.name}</div>
|
||||
<div className="payg-member__email">{m.email}</div>
|
||||
</div>
|
||||
<div className="payg-member__usage">
|
||||
<div className="payg-member__usage-num">
|
||||
{m.spendUnits.toLocaleString()}{" "}
|
||||
<Text span size="xs" c="dimmed">
|
||||
{t("payg.members.docs", "PDFs")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Activity feed ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Feature flag — the activity feed is hidden until the meter-event surface is
|
||||
* built and polished (Wave 2). The backend returns {@code []} today, so an
|
||||
* unpolished "No billable activity yet" card adds nothing. Flip to {@code true}
|
||||
* once {@code wallet.recent} carries real rows. Kept as a flag (not deleted) so
|
||||
* the renderer below stays wired and ready.
|
||||
*/
|
||||
const SHOW_ACTIVITY_FEED = false;
|
||||
|
||||
/**
|
||||
* Renders {@code wallet.recent} — the backend returns {@code []} in V1 (the
|
||||
* meter-event surface ships in Wave 2), so today this shows a real empty
|
||||
* state. The row renderer is ready for when the rows arrive; fields are read
|
||||
* defensively because the activity-row shape isn't finalised yet (the Wallet
|
||||
* type carries {@code Record<string, unknown>} for the same reason).
|
||||
*/
|
||||
function ActivityFeed({ recent }: { recent: Wallet["recent"] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-card">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<div className="payg-card__title">
|
||||
{t("payg.activity.title", "Recent billable activity")}
|
||||
</div>
|
||||
<div className="payg-card__subtitle">
|
||||
{t(
|
||||
"payg.activity.subtitle",
|
||||
"Only AI and automation draw from your budget. Everyday tools are free and aren't listed here.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"payg.activity.empty",
|
||||
"No billable activity yet this period.",
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
<div>
|
||||
{recent.map((r, i) => (
|
||||
<div className="payg-activity-row" key={String(r.id ?? i)}>
|
||||
<span
|
||||
className="payg-activity__dot"
|
||||
data-kind={String(r.kind ?? "")}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="payg-activity__label">
|
||||
{String(r.label ?? "")}
|
||||
</div>
|
||||
<div className="payg-activity__ts">{String(r.ts ?? "")}</div>
|
||||
</div>
|
||||
<span className="payg-activity__kind">
|
||||
{String(r.kind ?? "")}
|
||||
</span>
|
||||
<span className="payg-activity__units">
|
||||
{String(r.docUnits ?? 0)} {t("payg.activity.docs", "docs")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stripe CTA ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise<void> }) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onOpenPortal();
|
||||
} catch (e: unknown) {
|
||||
// 503 = Supabase edge fn isn't configured (local dev without
|
||||
// PORTAL_NOT_CONFIGURED env). 404 = no Stripe customer yet (e.g. the
|
||||
// team was force-subscribed via dev hooks). Both are user-actionable
|
||||
// in roughly the same way ("try again later or contact support") so
|
||||
// we don't bother branching the copy.
|
||||
console.warn("[Payg] portal session failed", e);
|
||||
showToast({
|
||||
alertType: "warning",
|
||||
title: t(
|
||||
"payg.stripe.toast.unavailable.title",
|
||||
"Billing portal unavailable",
|
||||
),
|
||||
body: t(
|
||||
"payg.stripe.toast.unavailable.body",
|
||||
"Billing portal isn't available right now. Try again in a moment.",
|
||||
),
|
||||
location: "bottom-right",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="payg-stripe">
|
||||
<div>
|
||||
<div className="payg-stripe__title">
|
||||
{t("payg.stripe.title", "Manage billing in Stripe")}
|
||||
</div>
|
||||
<div className="payg-stripe__subtitle">
|
||||
{t(
|
||||
"payg.stripe.subtitle",
|
||||
"Receipts, invoices, payment method, billing currency.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
loading={loading}
|
||||
rightSection={<OpenInNewIcon sx={{ fontSize: 16 }} />}
|
||||
variant="light"
|
||||
>
|
||||
{t("payg.stripe.open", "Open billing portal")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────
|
||||
|
||||
const Payg: React.FC<PaygProps> = ({ role, wallet, onSaveCap, onOpenPortal }) => {
|
||||
useRenderCount(role === "LEADER" ? "PaygLeader" : "PaygMember");
|
||||
const { t } = useTranslation();
|
||||
const isLeader = role === "LEADER";
|
||||
|
||||
const fmt = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="md">
|
||||
{/* The modal chrome already renders the section title ("Billing &
|
||||
usage"), so we lead with the descriptive subtitle + role pill. */}
|
||||
<div className="payg-planhead">
|
||||
<div className="payg-planhead__top">
|
||||
<span className="payg-planhead__eyebrow">
|
||||
{t("payg.header.eyebrow", "Processor plan · {{start}} – {{end}}", {
|
||||
start: fmt(wallet.billingPeriodStart),
|
||||
end: fmt(wallet.billingPeriodEnd),
|
||||
})}
|
||||
</span>
|
||||
<span className="payg-role-pill" data-leader={isLeader}>
|
||||
{isLeader
|
||||
? t("payg.role.leader", "Team owner")
|
||||
: t("payg.role.member", "Member")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-planhead__split">
|
||||
<div className="payg-planhead__col">
|
||||
<div className="payg-planhead__lbl payg-planhead__lbl--free">
|
||||
<AllInclusiveIcon
|
||||
className="payg-planhead__lbl-icon"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t("payg.header.freeLabel", "Always free")}
|
||||
</div>
|
||||
<p className="payg-planhead__title">
|
||||
{t("payg.header.freeTitle", "Unlimited PDF editing")}
|
||||
</p>
|
||||
<p className="payg-planhead__body">
|
||||
{t(
|
||||
"payg.header.freeBody",
|
||||
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="payg-planhead__col payg-planhead__col--meter">
|
||||
<div className="payg-planhead__lbl payg-planhead__lbl--meter">
|
||||
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
|
||||
{t("payg.header.meterLabel", "Metered")}
|
||||
</div>
|
||||
<p className="payg-planhead__title">
|
||||
{t("payg.header.meterTitle", "Automation · AI · API")}
|
||||
</p>
|
||||
<p className="payg-planhead__body">
|
||||
{t(
|
||||
"payg.header.meterBody",
|
||||
"{{limit}} free PDFs to start, then billed per PDF up to your cap.",
|
||||
{ limit: wallet.freeAllowance.toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsageHero wallet={wallet} />
|
||||
|
||||
{isLeader ? (
|
||||
<CapEditor
|
||||
capUsd={wallet.capUsd}
|
||||
noCap={wallet.noCap}
|
||||
pricePerDocMinor={wallet.pricePerDocMinor}
|
||||
currency={wallet.currency}
|
||||
onSaveCap={onSaveCap}
|
||||
/>
|
||||
) : (
|
||||
<CapReadOnly capUsd={wallet.capUsd} noCap={wallet.noCap} />
|
||||
)}
|
||||
|
||||
{isLeader && wallet.members.length > 0 && (
|
||||
<MemberUsage members={wallet.members} />
|
||||
)}
|
||||
|
||||
{SHOW_ACTIVITY_FEED && <ActivityFeed recent={wallet.recent} />}
|
||||
|
||||
{isLeader && onOpenPortal && (
|
||||
<StripePortalLink onOpenPortal={onOpenPortal} />
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Payg;
|
||||
|
||||
// Convenience exports for the config nav to render either variant directly.
|
||||
export interface PaygLeaderProps {
|
||||
/** See {@link PaygProps#wallet}. */
|
||||
wallet: Wallet;
|
||||
/** See {@link PaygProps#onSaveCap}. */
|
||||
onSaveCap?: (capUsd: number | null) => Promise<void> | void;
|
||||
/** See {@link PaygProps#onOpenPortal}. */
|
||||
onOpenPortal?: () => Promise<void>;
|
||||
}
|
||||
export const PaygLeader: React.FC<PaygLeaderProps> = ({
|
||||
wallet,
|
||||
onSaveCap,
|
||||
onOpenPortal,
|
||||
}) => (
|
||||
<Payg
|
||||
role="LEADER"
|
||||
wallet={wallet}
|
||||
onSaveCap={onSaveCap}
|
||||
onOpenPortal={onOpenPortal}
|
||||
/>
|
||||
);
|
||||
export const PaygMember: React.FC<{ wallet: Wallet }> = ({ wallet }) => (
|
||||
<Payg role="MEMBER" wallet={wallet} />
|
||||
);
|
||||
@@ -0,0 +1,322 @@
|
||||
/* Styles for the free-tier Plan views (PaygFreeLeader + PaygFreeMember).
|
||||
Extends Payg.css — the hero + bar + status pill reuse those classes so the
|
||||
visual reads as a continuation of the Processor dashboard. New classes here
|
||||
are prefixed `paygf-`. */
|
||||
|
||||
.payg-hero__head-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* ── Editor plan card (always-free tools only, no billing window) ─────── */
|
||||
/* Reuses .payg-planhead chrome; the eyebrow sits in the flex top row so its
|
||||
default 8px bottom margin would misalign it against the role pill. */
|
||||
.paygf-editorcard__eyebrow {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ── Processor plan card: two-column (pitch + benefits | meter + CTA) ──── */
|
||||
.paygf-proc {
|
||||
gap: 14px;
|
||||
}
|
||||
.paygf-proc__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--payg-accent);
|
||||
}
|
||||
.paygf-proc__split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.paygf-proc__pitch {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.paygf-proc__pitch .paygf-cta__subtitle {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* Force the benefits into a single column inside the narrower left column. */
|
||||
.paygf-proc__benefits {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.paygf-proc__aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid var(--payg-divider);
|
||||
}
|
||||
.paygf-proc__cta {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.paygf-proc__reassure {
|
||||
text-align: center;
|
||||
}
|
||||
.paygf-proc__membernote {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.paygf-proc__membernote-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem !important;
|
||||
margin-top: 1px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.paygf-proc__split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.paygf-proc__aside {
|
||||
padding-left: 0;
|
||||
padding-top: 16px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--payg-divider);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Compact one-time free meter (inside the Processor aside) ─────────── */
|
||||
.paygf-meter {
|
||||
padding: 14px 16px;
|
||||
border-radius: 11px;
|
||||
background: var(--payg-inset-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
}
|
||||
.paygf-meter__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.paygf-meter__figure {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
}
|
||||
.paygf-meter__num {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 750;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.paygf-meter__cap {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.paygf-meter .payg-bar {
|
||||
margin-top: 11px;
|
||||
}
|
||||
.paygf-meter__meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Leader CTA card ──────────────────────────────────────────────────── */
|
||||
|
||||
.paygf-cta {
|
||||
padding: 18px 22px;
|
||||
border-radius: var(--payg-radius);
|
||||
background: var(--payg-card-bg);
|
||||
/* Gradient border using padding-box / border-box trick so the inner bg
|
||||
stays solid + the outline gets the brand gradient. */
|
||||
border: 1.5px solid transparent;
|
||||
background:
|
||||
linear-gradient(var(--payg-card-bg), var(--payg-card-bg)) padding-box,
|
||||
linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%) border-box;
|
||||
box-shadow: 0 10px 28px -18px rgba(10, 139, 255, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.paygf-cta__heading-row {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.paygf-cta__icon {
|
||||
flex-shrink: 0;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
|
||||
color: white !important;
|
||||
font-size: 1.7rem !important;
|
||||
}
|
||||
.paygf-cta__heading-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.paygf-cta__title {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.paygf-cta__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.paygf-cta__benefits {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px 16px;
|
||||
}
|
||||
.paygf-cta__benefits li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.paygf-cta__check {
|
||||
flex-shrink: 0;
|
||||
color: var(--payg-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Footer row: button on the left, reassurance text on the right.
|
||||
Right-side text wraps to second line below button on narrow viewports. */
|
||||
.paygf-cta__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid var(--payg-divider);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.paygf-cta__button {
|
||||
padding: 13px 22px;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, var(--payg-accent) 0%, #6c5ce7 100%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.paygf-cta__button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 22px -6px rgba(10, 139, 255, 0.55);
|
||||
}
|
||||
.paygf-cta__reassurance {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Member ask-the-owner note ───────────────────────────────────────── */
|
||||
|
||||
.paygf-member-note {
|
||||
padding: 18px 22px;
|
||||
border-radius: var(--payg-radius);
|
||||
background: var(--payg-inset-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.paygf-member-note__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
padding: 8px;
|
||||
background: var(--payg-card-bg);
|
||||
border-radius: 10px;
|
||||
font-size: 1.6rem !important;
|
||||
}
|
||||
.paygf-member-note__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.paygf-member-note__body {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Explainer (free vs metered) — shared by leader + member ──────────── */
|
||||
|
||||
.paygf-explainer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-top: var(--payg-gap);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.paygf-explainer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.paygf-explainer__col {
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
background: var(--payg-card-bg);
|
||||
border: 1px solid var(--payg-card-border);
|
||||
}
|
||||
.paygf-explainer__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.paygf-explainer__icon {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
.paygf-explainer__icon--free {
|
||||
color: #10b981;
|
||||
}
|
||||
.paygf-explainer__icon--paid {
|
||||
color: var(--payg-accent);
|
||||
}
|
||||
.paygf-explainer__text {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Free-tier Plan views (leader + member) shown before the team subscribes to
|
||||
* Processor. Mirrors the visual language of {@code Payg.tsx} so the upgrade
|
||||
* feels like enabling a switch, not visiting a new product.
|
||||
*
|
||||
* <p><b>Free tier model (set 2026-06):</b> users only pay for
|
||||
* <em>automation</em>, <em>AI</em>, and <em>API</em> operations. Manual tools
|
||||
* — viewing, editing, signing, merging, splitting, conversion, manual OCR,
|
||||
* watermarks, compression — are unmetered, no matter where they're triggered
|
||||
* from. The distinction is the <em>type of work</em> (manual tool vs
|
||||
* automation / AI / API), not where the click happens, because automation and
|
||||
* AI also have UI surfaces. The one-time free grant (default 500) applies
|
||||
* <em>only</em> to the three billable categories — it is a lifetime allowance,
|
||||
* not a monthly one, and a team keeps any unused portion after subscribing.
|
||||
*
|
||||
* <p>Layout: a slim <b>Editor plan</b> card (always-free tools only — no dates,
|
||||
* no metered split) on top, then a single <b>Processor plan</b> card that
|
||||
* two-columns the upgrade pitch + benefits (left) against the one-time free
|
||||
* meter stacked over the call-to-action (right).
|
||||
*
|
||||
* <p>Two variants:
|
||||
* - {@link PaygFreeLeader} — the right column's CTA opens the upgrade modal.
|
||||
* - {@link PaygFreeMember} — read-only; the CTA is replaced with an
|
||||
* ask-the-owner note.
|
||||
*/
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import BoltIcon from "@mui/icons-material/BoltRounded";
|
||||
import AllInclusiveIcon from "@mui/icons-material/AllInclusiveRounded";
|
||||
import CheckIcon from "@mui/icons-material/CheckRounded";
|
||||
import LockIcon from "@mui/icons-material/LockOutlined";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRenderCount } from "@app/hooks/useRenderCount";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./Payg.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./PaygFree.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import UpgradeModal from "./UpgradeModal";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { DocHelp } from "./Payg";
|
||||
|
||||
// ─── Shared free-tier snapshot ────────────────────────────
|
||||
|
||||
interface FreeSnapshot {
|
||||
/** One-time free documents used so far (grant − remaining). */
|
||||
billableUsed: number;
|
||||
/**
|
||||
* The team's one-time free grant size in documents. Real value from the
|
||||
* wallet endpoint (pricing_policy.free_tier_units); 500 below is only the
|
||||
* pre-load placeholder for the first paint.
|
||||
*/
|
||||
billableLimit: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read free-tier snapshot from the real {@link useWallet} hook. Falls back to
|
||||
* a zeroed view if the wallet hasn't loaded yet — this only happens briefly on
|
||||
* first paint; once the snapshot arrives the component re-renders with real
|
||||
* numbers. Earlier versions returned a mock "62 of 500" sentinel which leaked
|
||||
* into the rendered UI and made the page look like nothing was wired up.
|
||||
*/
|
||||
function useFreeSnapshot(): FreeSnapshot {
|
||||
const { wallet } = useWallet();
|
||||
return useMemo(() => {
|
||||
if (wallet) {
|
||||
return {
|
||||
// Used = grant − remaining, derived straight from the one-time grant so
|
||||
// the free view never depends on the per-state meaning of billableUsed.
|
||||
billableUsed: Math.max(0, wallet.freeAllowance - wallet.freeRemaining),
|
||||
// The free view's ceiling IS the one-time grant size.
|
||||
billableLimit: wallet.freeAllowance,
|
||||
};
|
||||
}
|
||||
return { billableUsed: 0, billableLimit: 500 };
|
||||
}, [wallet]);
|
||||
}
|
||||
|
||||
type MeterState = "FULL" | "WARNED" | "DEGRADED";
|
||||
|
||||
/** Warn/degrade band for the one-time grant meter (mirrors the BE thresholds). */
|
||||
function meterState(used: number, limit: number): { state: MeterState; pct: number } {
|
||||
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 100;
|
||||
const state: MeterState =
|
||||
pct >= 100 ? "DEGRADED" : pct >= 80 ? "WARNED" : "FULL";
|
||||
return { state, pct };
|
||||
}
|
||||
|
||||
// ─── Editor plan card (always-free tools only) ────────────────────────────
|
||||
|
||||
interface EditorPlanCardProps {
|
||||
/** Role pill text on the right. */
|
||||
pill: string;
|
||||
/** LEADER pill colour treatment. */
|
||||
leader?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The top card: the free Editor plan. Manual tools only, no billing window —
|
||||
* the one-time grant lives in the Processor card below, so there's no period
|
||||
* to show here.
|
||||
*/
|
||||
function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="payg-planhead paygf-editorcard">
|
||||
<div className="payg-planhead__top">
|
||||
<span className="payg-planhead__lbl payg-planhead__lbl--free paygf-editorcard__eyebrow">
|
||||
<AllInclusiveIcon
|
||||
className="payg-planhead__lbl-icon"
|
||||
fontSize="small"
|
||||
/>
|
||||
{t("payg.free.editor.eyebrow", "Editor plan · Always free")}
|
||||
</span>
|
||||
<span className="payg-role-pill" data-leader={leader ? "true" : "false"}>
|
||||
{pill}
|
||||
</span>
|
||||
</div>
|
||||
<p className="payg-planhead__title">
|
||||
{t("payg.free.header.freeTitle", "Unlimited PDF editing")}
|
||||
</p>
|
||||
<p className="payg-planhead__body">
|
||||
{t(
|
||||
"payg.free.header.freeBody",
|
||||
"View, edit, merge, split, sign, watermark, compress, convert and manual OCR — as much as you want, no matter where you trigger it.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Compact one-time free meter (right column of the Processor card) ──────
|
||||
|
||||
function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
|
||||
const { t } = useTranslation();
|
||||
const { state, pct } = meterState(snap.billableUsed, snap.billableLimit);
|
||||
const stateLabel =
|
||||
state === "DEGRADED"
|
||||
? t("payg.free.state.limitReached", "Limit reached")
|
||||
: state === "WARNED"
|
||||
? t("payg.free.state.approachingLimit", "Approaching limit")
|
||||
: t("payg.free.state.plentyLeft", "Plenty left");
|
||||
|
||||
return (
|
||||
<div className="paygf-meter" data-state={state}>
|
||||
<div className="paygf-meter__top">
|
||||
<div className="paygf-meter__figure">
|
||||
<span className="paygf-meter__num">
|
||||
{snap.billableUsed.toLocaleString()}
|
||||
</span>
|
||||
<span className="paygf-meter__cap">
|
||||
{t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", {
|
||||
limit: snap.billableLimit.toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span className="payg-status" data-state={state}>
|
||||
<span className="payg-status__dot" />
|
||||
{stateLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="payg-bar">
|
||||
<div
|
||||
className="payg-bar__fill"
|
||||
data-state={state}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="paygf-meter__meta">
|
||||
<span>
|
||||
{t("payg.free.hero.metaCategories", "Automation · AI · API requests")}
|
||||
</span>
|
||||
<span className="payg-hero__meta-dot">•</span>
|
||||
<span>{t("payg.free.hero.neverResets", "One-time — never resets")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
|
||||
|
||||
interface ProcessorCardProps {
|
||||
snap: FreeSnapshot;
|
||||
/** Leaders get the live CTA; members get the ask-owner note. */
|
||||
isLeader: boolean;
|
||||
/** Opens the upgrade modal — leader only. */
|
||||
onTurnOn?: () => void;
|
||||
}
|
||||
|
||||
function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="paygf-cta paygf-proc">
|
||||
<span className="paygf-proc__eyebrow">
|
||||
<BoltIcon className="payg-planhead__lbl-icon" fontSize="small" />
|
||||
{t("payg.free.proc.eyebrow", "Processor plan · metered")}
|
||||
</span>
|
||||
|
||||
<div className="paygf-proc__split">
|
||||
<div className="paygf-proc__pitch">
|
||||
<h3 className="paygf-cta__title">
|
||||
{t("payg.free.cta.title", "Turn on the Processor plan")}
|
||||
</h3>
|
||||
<p className="paygf-cta__subtitle">
|
||||
{t(
|
||||
"payg.free.cta.subtitle",
|
||||
"Keep going past your {{limit}} free PDFs with automation, AI, and the API. Set a monthly ceiling — you stay in control.",
|
||||
{ limit: snap.billableLimit.toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ul className="paygf-cta__benefits paygf-proc__benefits">
|
||||
<li>
|
||||
<CheckIcon className="paygf-cta__check" fontSize="small" />
|
||||
<span>
|
||||
<strong>
|
||||
{t("payg.free.cta.benefit1Title", "Automation pipelines")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.free.cta.benefit1Body",
|
||||
"chain tools, schedule runs, batch process",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<CheckIcon className="paygf-cta__check" fontSize="small" />
|
||||
<span>
|
||||
<strong>{t("payg.free.cta.benefit2Title", "AI tools")}</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.free.cta.benefit2Body",
|
||||
"summarise, classify, redact, AI-OCR",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<CheckIcon className="paygf-cta__check" fontSize="small" />
|
||||
<span>
|
||||
<strong>{t("payg.free.cta.benefit3Title", "API access")}</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.free.cta.benefit3Body",
|
||||
"call any Stirling endpoint programmatically",
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<DocHelp />
|
||||
</div>
|
||||
|
||||
<div className="paygf-proc__aside">
|
||||
<FreeMeterPanel snap={snap} />
|
||||
{isLeader ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="paygf-cta__button paygf-proc__cta"
|
||||
onClick={onTurnOn}
|
||||
data-testid="turn-on-processor"
|
||||
>
|
||||
{t("payg.free.cta.button", "Turn on Processor →")}
|
||||
</button>
|
||||
<span className="paygf-cta__reassurance paygf-proc__reassure">
|
||||
{t(
|
||||
"payg.free.cta.reassurance",
|
||||
"No minimum · Set a $0 cap to test · Cancel anytime",
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="paygf-proc__membernote">
|
||||
<LockIcon
|
||||
className="paygf-proc__membernote-icon"
|
||||
fontSize="small"
|
||||
/>
|
||||
<span>
|
||||
{t(
|
||||
"payg.free.member.ownerOnly",
|
||||
"Only your team owner can turn on Processor. Manual tools stay free for you to use as much as you like.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free LEADER ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaygFreeLeaderProps {
|
||||
/**
|
||||
* Called when the user finishes the {@link UpgradeModal} checkout flow.
|
||||
* Plumbed up to {@code PlanSection} so the page can flip to the subscribed
|
||||
* view immediately. When undefined we fall back to a demo {@code alert} so
|
||||
* the dev preview route still works in isolation.
|
||||
*/
|
||||
onUpgraded?: (result: { capUsd: number | null }) => void;
|
||||
}
|
||||
|
||||
function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) {
|
||||
useRenderCount("PaygFreeLeader");
|
||||
const { t } = useTranslation();
|
||||
const snap = useFreeSnapshot();
|
||||
const { wallet } = useWallet();
|
||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="md">
|
||||
<EditorPlanCard pill={t("payg.role.leader", "Team owner")} leader />
|
||||
<ProcessorCard
|
||||
snap={snap}
|
||||
isLeader
|
||||
onTurnOn={() => setUpgradeOpen(true)}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{wallet?.teamId != null && (
|
||||
<UpgradeModal
|
||||
open={upgradeOpen}
|
||||
teamId={wallet.teamId}
|
||||
freeLimit={snap.billableLimit}
|
||||
pricePerDocMinor={wallet.pricePerDocMinor}
|
||||
rateCurrency={wallet.currency}
|
||||
onClose={() => setUpgradeOpen(false)}
|
||||
onComplete={({ capUsd }) => {
|
||||
setUpgradeOpen(false);
|
||||
if (onUpgraded) {
|
||||
onUpgraded({ capUsd });
|
||||
} else {
|
||||
// Standalone fallback (dev preview route renders without a parent
|
||||
// handler). Real flow always passes onUpgraded via PlanSection.
|
||||
alert(
|
||||
`Demo: subscription complete. Cap = ${
|
||||
capUsd === null ? "no cap" : `$${capUsd}/mo`
|
||||
}.`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Free MEMBER ──────────────────────────────────────────────────────────
|
||||
|
||||
function PaygFreeMemberInner() {
|
||||
useRenderCount("PaygFreeMember");
|
||||
const { t } = useTranslation();
|
||||
const snap = useFreeSnapshot();
|
||||
|
||||
return (
|
||||
<div className="payg">
|
||||
<Stack gap="md">
|
||||
<EditorPlanCard pill={t("payg.role.member", "Member")} />
|
||||
<ProcessorCard snap={snap} isLeader={false} />
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// React.memo so Plan re-rendering on loading/error toggles doesn't cascade
|
||||
// down to these leaves. Plan passes a stable onUpgraded callback (hoisted in
|
||||
// Plan.tsx) so the prop identity stays stable across wallet refetches.
|
||||
export const PaygFreeLeader = React.memo(PaygFreeLeaderInner);
|
||||
export const PaygFreeMember = React.memo(PaygFreeMemberInner);
|
||||
@@ -1,245 +1,91 @@
|
||||
import React, { lazy, Suspense, useState, useCallback, useEffect } from "react";
|
||||
import { Divider, Loader, Alert, Select, Group, Text } from "@mantine/core";
|
||||
import { usePlans, PlanTier } from "@app/hooks/usePlans";
|
||||
import type {
|
||||
PurchaseType,
|
||||
CreditsPack,
|
||||
PlanID,
|
||||
} from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
const StripeCheckout = lazy(
|
||||
() => import("@app/components/shared/StripeCheckoutSaas"),
|
||||
);
|
||||
import AvailablePlansSection from "@app/components/shared/config/configSections/plan/AvailablePlansSection";
|
||||
import ApiPackagesSection from "@app/components/shared/config/configSections/plan/ApiPackagesSection";
|
||||
import ActivePlanSection from "@app/components/shared/config/configSections/plan/ActivePlanSection";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
/**
|
||||
* SaaS "Plan" page — the single entry point for billing, plan state, and
|
||||
* usage. Branches on the team's wallet state and the viewer's role:
|
||||
*
|
||||
* - free + leader → {@link PaygFreeLeader} (upgrade CTA + manual-tools
|
||||
* framing)
|
||||
* - free + member → {@link PaygFreeMember} (ask-the-owner note)
|
||||
* - subscribed + leader → {@link PaygLeader} (full dashboard, editable cap)
|
||||
* - subscribed + member → {@link PaygMember} (member dashboard)
|
||||
*
|
||||
* <p>The hook handles loading + error states locally so the four view
|
||||
* components stay focused on rendering the data they own. {@code Plan} is
|
||||
* intentionally tiny (under 60 lines) so future "Plan-level" affordances —
|
||||
* a top-level error toast, a subscription confirmation card, etc — have
|
||||
* obvious places to land.
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Alert, Center, Loader } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWallet } from "@app/hooks/useWallet";
|
||||
import { useRenderCount } from "@app/hooks/useRenderCount";
|
||||
import {
|
||||
PaygLeader,
|
||||
PaygMember,
|
||||
} from "@app/components/shared/config/configSections/Payg";
|
||||
import {
|
||||
PaygFreeLeader,
|
||||
PaygFreeMember,
|
||||
} from "@app/components/shared/config/configSections/PaygFree";
|
||||
|
||||
const Plan: React.FC = () => {
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
|
||||
const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package
|
||||
const [purchaseType, setPurchaseType] =
|
||||
useState<PurchaseType>("subscription");
|
||||
const [selectedCreditsPack, setSelectedCreditsPack] =
|
||||
useState<CreditsPack>(null);
|
||||
const [currency, setCurrency] = useState<string>("gbp");
|
||||
const { trialStatus } = useAuth();
|
||||
const { data, loading, error, updateCurrentPlan } = usePlans(currency);
|
||||
useRenderCount("Plan");
|
||||
const { t } = useTranslation();
|
||||
const { wallet, loading, error, markSubscribed, updateCap, openPortal } =
|
||||
useWallet();
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: "cny", label: "Chinese yuan (CNY, ¥)" },
|
||||
{ value: "usd", label: "US dollar (USD, $)" },
|
||||
{ value: "inr", label: "Indian rupee (INR, ₹)" },
|
||||
{ value: "brl", label: "Brazilian real (BRL, R$)" },
|
||||
{ value: "eur", label: "Euro (EUR, €)" },
|
||||
{ value: "idr", label: "Indonesian rupiah (IDR, Rp)" },
|
||||
{ value: "gbp", label: "British pound (GBP, £)" },
|
||||
];
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(plan: PlanTier) => {
|
||||
if (!data) return;
|
||||
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open(
|
||||
"mailto:[email protected]?subject=Enterprise Plan Inquiry",
|
||||
"_blank",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plan.id !== data.currentPlan.id) {
|
||||
setSelectedPlan(plan);
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
// Stable callback so PaygFreeLeader's React.memo doesn't see a new prop
|
||||
// identity on every Plan render (e.g. loading flips false→true→false on
|
||||
// a refetch). Closing over the stable markSubscribed from useWallet
|
||||
// means we don't need to add wallet state to deps.
|
||||
const onUpgraded = useCallback(
|
||||
({ capUsd }: { capUsd: number | null }) => {
|
||||
// Bridges the modal's local success → backend mock → refetch loop.
|
||||
// Real Stripe flow: the customer.subscription.created webhook is
|
||||
// what flips status; we still call markSubscribed locally so the
|
||||
// optimistic refetch hits immediately.
|
||||
void markSubscribed(capUsd);
|
||||
},
|
||||
[data],
|
||||
[markSubscribed],
|
||||
);
|
||||
|
||||
const handleCreditPurchaseClick = useCallback(
|
||||
(creditsPack: CreditsPack) => {
|
||||
if (!data) return;
|
||||
|
||||
setSelectedCreditsPack(creditsPack);
|
||||
setPurchaseType("credits");
|
||||
setSelectedPlan(null);
|
||||
setCheckoutOpen(true);
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
const handlePaymentSuccess = useCallback(
|
||||
(sessionId: string) => {
|
||||
console.log("Payment successful, session:", sessionId);
|
||||
|
||||
// Update local state immediately - no page reload needed
|
||||
if (selectedPlan && purchaseType === "subscription") {
|
||||
updateCurrentPlan(selectedPlan.id);
|
||||
}
|
||||
|
||||
// Close modal after brief delay to show success message
|
||||
setTimeout(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, 2000);
|
||||
},
|
||||
[selectedPlan, purchaseType, updateCurrentPlan],
|
||||
);
|
||||
|
||||
const handlePaymentError = useCallback((error: string) => {
|
||||
console.error("Payment error:", error);
|
||||
// Error is already displayed in the StripeCheckout component
|
||||
}, []);
|
||||
|
||||
const handleCheckoutClose = useCallback(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setSelectedCreditsPack(null);
|
||||
}, []);
|
||||
|
||||
const handleAddPaymentClick = useCallback(() => {
|
||||
if (!data) return;
|
||||
|
||||
// Find Pro plan from available plans
|
||||
const proPlan = Array.from(data.plans.values()).find(
|
||||
(plan) => plan.id === "pro",
|
||||
);
|
||||
|
||||
if (proPlan) {
|
||||
setSelectedPlan(proPlan);
|
||||
setPurchaseType("subscription");
|
||||
setSelectedCreditsPack(null);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// Check URL parameters for action=add-payment
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("action") === "add-payment") {
|
||||
handleAddPaymentClick();
|
||||
// Clean up URL
|
||||
params.delete("action");
|
||||
const newUrl = params.toString()
|
||||
? `${window.location.pathname}?${params.toString()}`
|
||||
: window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}, [data, handleAddPaymentClick]);
|
||||
|
||||
// Early returns after all hooks are called
|
||||
if (loading) {
|
||||
if (loading && !wallet) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
<Center mih={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || !wallet) {
|
||||
return (
|
||||
<Alert color="red" title="Error loading plans">
|
||||
{error}
|
||||
<Alert color="red" title={t("payg.error.title", "Couldn't load your plan")}>
|
||||
{error ??
|
||||
t(
|
||||
"payg.error.body",
|
||||
"We couldn't reach the billing service. Refresh the page to try again.",
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
</Alert>
|
||||
if (wallet.status === "subscribed") {
|
||||
return wallet.role === "leader" ? (
|
||||
<PaygLeader
|
||||
wallet={wallet}
|
||||
onSaveCap={updateCap}
|
||||
onOpenPortal={openPortal}
|
||||
/>
|
||||
) : (
|
||||
<PaygMember wallet={wallet} />
|
||||
);
|
||||
}
|
||||
|
||||
const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } =
|
||||
data;
|
||||
const plansArray = Array.from(plans.values());
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
|
||||
{/* Currency Selector */}
|
||||
<div>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text size="lg" fw={600}>
|
||||
Currency
|
||||
</Text>
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => setCurrency(value || "gbp")}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={true}
|
||||
w={300}
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<ActivePlanSection
|
||||
currentPlan={currentPlan}
|
||||
_activeSince={activeSince}
|
||||
_nextBillingDate={nextBillingDate}
|
||||
trialStatus={trialStatus ?? undefined}
|
||||
onAddPaymentClick={handleAddPaymentClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plansArray}
|
||||
currentPlan={currentPlan}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ApiPackagesSection
|
||||
apiPackages={apiPackages}
|
||||
selectedCredits={selectedCredits}
|
||||
onSelectedCreditsChange={setSelectedCredits}
|
||||
onCreditPurchaseClick={handleCreditPurchaseClick}
|
||||
/>
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
{checkoutOpen &&
|
||||
(selectedPlan !== null || selectedCreditsPack !== null) && (
|
||||
<Suspense fallback={null}>
|
||||
<StripeCheckout
|
||||
opened={true}
|
||||
onClose={handleCheckoutClose}
|
||||
purchaseType={purchaseType}
|
||||
planId={
|
||||
purchaseType === "subscription"
|
||||
? (selectedPlan?.id as PlanID)
|
||||
: null
|
||||
}
|
||||
creditsPack={
|
||||
purchaseType === "credits" ? selectedCreditsPack : null
|
||||
}
|
||||
planName={
|
||||
purchaseType === "subscription"
|
||||
? selectedPlan?.name || ""
|
||||
: data?.apiPackages.find(
|
||||
(pkg) => pkg.id === selectedCreditsPack,
|
||||
)?.name || ""
|
||||
}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
isTrialConversion={
|
||||
trialStatus?.isTrialing && purchaseType === "subscription"
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// Free tier — only the leader sees the upgrade CTA.
|
||||
if (wallet.role === "leader") {
|
||||
return <PaygFreeLeader onUpgraded={onUpgraded} />;
|
||||
}
|
||||
return <PaygFreeMember />;
|
||||
};
|
||||
|
||||
export default Plan;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/* Reusable monthly spend-cap control — preset chips + an inline custom-entry
|
||||
pill + a no-cap chip, with a live "≈ N PDFs/month" estimate beneath. Shared
|
||||
by the subscribed plan-page cap editor (Payg.tsx) and the upgrade checkout
|
||||
flow (UpgradeModal.tsx).
|
||||
|
||||
Self-contained tokens so the control looks right whether it's dropped into
|
||||
the Plan card (light/dark modal content) or the dark upgrade modal. We lean
|
||||
on the app's semantic theme tokens and override per scheme, mirroring the
|
||||
Payg.css / UpgradeModal.css treatments the control sits beside. */
|
||||
|
||||
.scc {
|
||||
--scc-accent: #0a8bff;
|
||||
--scc-accent-text: #0a8bff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.12);
|
||||
--scc-accent-border: rgba(10, 139, 255, 0.25);
|
||||
--scc-chip-bg: var(--bg-muted);
|
||||
--scc-chip-border: var(--border-default);
|
||||
/* Consistent vertical rhythm between the control row, the estimate, and any
|
||||
note — without it the blocks sit flush. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .scc {
|
||||
--scc-accent-text: #66b8ff;
|
||||
--scc-accent-soft: rgba(10, 139, 255, 0.16);
|
||||
--scc-chip-bg: #272d35;
|
||||
--scc-chip-border: #3d444e;
|
||||
}
|
||||
|
||||
/* ── Inline row: presets · custom · no-cap · (save) ──────────────────── */
|
||||
.scc-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Shared pill shape for presets, the custom-entry pill, and no-cap. Keeping a
|
||||
single height/radius/border is what makes the custom field read as "one more
|
||||
button" rather than a form input bolted on. */
|
||||
.scc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--scc-chip-border);
|
||||
background: var(--scc-chip-bg);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.scc-chip:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.scc-chip[data-selected="true"] {
|
||||
background: var(--scc-accent-soft);
|
||||
border-color: var(--scc-accent);
|
||||
color: var(--scc-accent-text);
|
||||
}
|
||||
.scc-chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Custom-entry pill — Option A: a dashed pill that matches the presets and
|
||||
reads as "or type your own", filling solid like a selected chip once it
|
||||
carries a value. */
|
||||
.scc-custom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--scc-chip-border);
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.scc-custom:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.scc-custom[data-active="true"] {
|
||||
border-style: solid;
|
||||
border-color: var(--scc-accent);
|
||||
background: var(--scc-accent-soft);
|
||||
}
|
||||
.scc-custom__symbol {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.scc-custom[data-active="true"] .scc-custom__symbol,
|
||||
.scc-custom[data-active="true"] .scc-custom__input {
|
||||
color: var(--scc-accent-text);
|
||||
}
|
||||
.scc-custom__input {
|
||||
width: 70px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.scc-custom__input::placeholder {
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.scc-custom:disabled,
|
||||
.scc-custom[data-disabled="true"] {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scc-row__spacer {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Live PDF estimate ───────────────────────────────────────────────── */
|
||||
.scc-estimate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--scc-accent-soft);
|
||||
border: 1px solid var(--scc-accent-border);
|
||||
}
|
||||
.scc-estimate__icon {
|
||||
color: var(--scc-accent-text);
|
||||
display: flex;
|
||||
}
|
||||
.scc-estimate__main {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 550;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.scc-estimate__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* Quiet helper line (e.g. "Shown in USD — change later in your currency"). */
|
||||
.scc-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Reusable monthly spend-cap control.
|
||||
*
|
||||
* One inline row — preset chips, a custom-entry pill that matches the presets,
|
||||
* a "No cap" chip, and (optionally) a Save button — over a live "≈ N PDFs /
|
||||
* month" estimate. Extracted from the subscribed plan-page cap editor so the
|
||||
* exact same control drives the upgrade checkout flow.
|
||||
*
|
||||
* <h2>Currency-agnostic by design</h2>
|
||||
*
|
||||
* The control never decides a currency. It takes {@code pricePerDocMinor} +
|
||||
* {@code currency} and renders whatever it's handed: the subscribed plan page
|
||||
* passes the team's real Stripe-subscription rate/currency; the unsubscribed
|
||||
* checkout flow passes a USD rate (Stripe hasn't assigned the team a currency
|
||||
* yet) plus a {@code note} explaining the cap is editable later. When no rate
|
||||
* is supplied the estimate simply hides.
|
||||
*
|
||||
* <h2>Controlled</h2>
|
||||
*
|
||||
* Fully controlled via {@code capUsd} ({@code null} = no cap, {@code 0} = a
|
||||
* real $0 cap that keeps everything free) + {@code onChange}. The parent owns
|
||||
* the working value. When {@code onSave} is provided the control renders the
|
||||
* inline Save button and computes "dirty" against {@code savedCapUsd}.
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import DescriptionIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./SpendCapControl.css";
|
||||
|
||||
// Quick amounts offered everywhere — recognition over recall.
|
||||
export const DEFAULT_CAP_PRESETS = [500, 1000, 2500, 5000] as const;
|
||||
|
||||
export interface SpendCapControlProps {
|
||||
/** Current cap in major currency units; {@code null} = no cap. Controlled. */
|
||||
capUsd: number | null;
|
||||
/** Working-value setter. {@code null} signals no-cap. */
|
||||
onChange: (capUsd: number | null) => void;
|
||||
/** Per-document rate in minor units; null/0 hides the estimate. May be fractional. */
|
||||
pricePerDocMinor?: number | null;
|
||||
/** Lower-case ISO currency of the rate; pairs with {@link #pricePerDocMinor}. */
|
||||
currency?: string | null;
|
||||
/** Quick-amount presets (major units). Defaults to {@link DEFAULT_CAP_PRESETS}. */
|
||||
presets?: readonly number[];
|
||||
/**
|
||||
* When provided, the control renders an inline Save button. Receives whole
|
||||
* major units, or {@code null} for no-cap.
|
||||
*/
|
||||
onSave?: (capUsd: number | null) => Promise<void> | void;
|
||||
/** Label for the Save button. */
|
||||
saveLabel?: string;
|
||||
/**
|
||||
* The persisted value to diff against for the dirty check. Same encoding as
|
||||
* {@link #capUsd} ({@code null} = persisted no-cap). Only used with
|
||||
* {@link #onSave}.
|
||||
*/
|
||||
savedCapUsd?: number | null;
|
||||
/** Quiet helper line under the estimate (e.g. the USD / editable-later note). */
|
||||
note?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Format minor units of an ISO currency ("$2.24", "£0.40"). */
|
||||
function formatMinor(minor: number, currency: string | null | undefined): string {
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
try {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
// Per-doc rates are often sub-cent (e.g. $0.02 → 2 minor, but a half-cent
|
||||
// rate is 0.5). Allow up to 3 fraction digits so they don't round to $0.
|
||||
maximumFractionDigits: 3,
|
||||
}).format(minor / 100);
|
||||
} catch {
|
||||
return `${(minor / 100).toFixed(2)} ${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Currency symbol for compact inline use; falls back to the ISO code. */
|
||||
function currencySymbol(currency: string | null | undefined): string {
|
||||
switch ((currency ?? "").toLowerCase()) {
|
||||
case "usd":
|
||||
case "":
|
||||
return "$";
|
||||
case "eur":
|
||||
return "€";
|
||||
case "gbp":
|
||||
return "£";
|
||||
default:
|
||||
return currency!.toUpperCase() + " ";
|
||||
}
|
||||
}
|
||||
|
||||
const SpendCapControl: React.FC<SpendCapControlProps> = ({
|
||||
capUsd,
|
||||
onChange,
|
||||
pricePerDocMinor,
|
||||
currency,
|
||||
presets = DEFAULT_CAP_PRESETS,
|
||||
onSave,
|
||||
saveLabel,
|
||||
savedCapUsd,
|
||||
note,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const sym = currencySymbol(currency);
|
||||
const isNoCap = capUsd === null;
|
||||
const presetSelected = capUsd != null && presets.includes(capUsd);
|
||||
// Custom is "active" when a cap is set that isn't one of the presets — i.e.
|
||||
// the value came from the custom pill.
|
||||
const customActive = capUsd != null && !presets.includes(capUsd);
|
||||
|
||||
// Local mirror of the custom field's text so partial/empty entry doesn't get
|
||||
// clobbered by the controlled value. Seeded from a non-preset incoming cap.
|
||||
const [customText, setCustomText] = useState<string>(
|
||||
customActive ? String(capUsd) : "",
|
||||
);
|
||||
|
||||
// Mirror of the backend's docCapForMoney: floor(capMinor / rate). The
|
||||
// one-time free grant is a separate lifetime pool and is NOT added here —
|
||||
// this is the paid PDFs the monthly cap buys.
|
||||
const rate =
|
||||
pricePerDocMinor != null && pricePerDocMinor > 0 ? pricePerDocMinor : null;
|
||||
const previewDocs =
|
||||
capUsd != null && rate != null ? Math.floor((capUsd * 100) / rate) : null;
|
||||
|
||||
const dirty = onSave != null && capUsd !== (savedCapUsd ?? null);
|
||||
|
||||
const selectPreset = (preset: number) => {
|
||||
setCustomText("");
|
||||
onChange(preset);
|
||||
};
|
||||
const selectNoCap = () => {
|
||||
setCustomText("");
|
||||
onChange(null);
|
||||
};
|
||||
const onCustomInput = (raw: string) => {
|
||||
// Digits only; an empty field reads as "no custom value yet" → 0 so the
|
||||
// estimate still renders sensibly without flipping to no-cap.
|
||||
const cleaned = raw.replace(/[^0-9]/g, "");
|
||||
setCustomText(cleaned);
|
||||
const v = cleaned === "" ? 0 : parseInt(cleaned, 10);
|
||||
onChange(Number.isNaN(v) ? 0 : v);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(isNoCap ? null : Math.round(capUsd ?? 0));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="scc">
|
||||
<div className="scc-row">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
className="scc-chip"
|
||||
data-selected={presetSelected && capUsd === preset}
|
||||
onClick={() => selectPreset(preset)}
|
||||
>
|
||||
{sym}
|
||||
{preset.toLocaleString()}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom-entry pill — dashed until it carries a value, then it fills
|
||||
like a selected chip. */}
|
||||
<label className="scc-custom" data-active={customActive}>
|
||||
<span className="scc-custom__symbol">{sym}</span>
|
||||
<input
|
||||
className="scc-custom__input"
|
||||
inputMode="numeric"
|
||||
value={customActive ? customText : ""}
|
||||
placeholder={t("payg.cap.custom", "Custom")}
|
||||
aria-label={t("payg.cap.amount", "Cap amount")}
|
||||
onChange={(e) => onCustomInput(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`scc-chip${onSave ? "" : " scc-row__spacer"}`}
|
||||
data-selected={isNoCap}
|
||||
onClick={selectNoCap}
|
||||
>
|
||||
{t("payg.cap.noCapLabel", "No cap")}
|
||||
</button>
|
||||
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs"
|
||||
className="scc-row__spacer"
|
||||
disabled={!dirty || saving}
|
||||
loading={saving}
|
||||
leftSection={<LocalIcon icon="check-rounded" />}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveLabel ?? t("payg.cap.save", "Update cap")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewDocs != null && (
|
||||
<div className="scc-estimate">
|
||||
<DescriptionIcon className="scc-estimate__icon" sx={{ fontSize: 22 }} />
|
||||
<div>
|
||||
<div className="scc-estimate__main">
|
||||
{t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", {
|
||||
docs: previewDocs.toLocaleString(),
|
||||
})}
|
||||
</div>
|
||||
<div className="scc-estimate__sub">
|
||||
{t("payg.cap.docsRate", "at {{rate}} / PDF", {
|
||||
rate: formatMinor(pricePerDocMinor ?? 0, currency),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNoCap && (
|
||||
<div className="scc-note">
|
||||
{t(
|
||||
"payg.cap.noCapDesc",
|
||||
"Usage is billed without an upper limit. You can re-enable a cap at any time.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{note && <div className="scc-note">{note}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpendCapControl;
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Stripe Embedded Checkout panel — lives in its own module so it can be
|
||||
* imported lazily. {@code @stripe/stripe-js} pulls a fairly chunky third-party
|
||||
* SDK; we don't want it in the main bundle for users who never open the
|
||||
* UpgradeModal, let alone reach step 2.
|
||||
*
|
||||
* <h2>Lazy-load pattern</h2>
|
||||
*
|
||||
* <pre>
|
||||
* // In UpgradeModal.tsx — only when the user advances to step 2:
|
||||
* const StripeCheckoutPanel = React.lazy(
|
||||
* () => import("./StripeCheckoutPanel"),
|
||||
* );
|
||||
* </pre>
|
||||
*
|
||||
* The {@code loadStripe()} call inside this module deferred-imports the SDK
|
||||
* itself, so the chunk graph is:
|
||||
*
|
||||
* <pre>
|
||||
* StripeCheckoutPanel.chunk.js
|
||||
* └─ @stripe/react-stripe-js (pulled in by ESM static import here)
|
||||
* └─ @stripe/stripe-js (pulled in by loadStripe inside fetchStripe)
|
||||
* </pre>
|
||||
*
|
||||
* Both chunks are eligible for Vite tree-shaking + lazy load; nothing in the
|
||||
* main bundle references either package.
|
||||
*
|
||||
* <h2>Architecture</h2>
|
||||
*
|
||||
* Stripe-touching code lives in Supabase edge functions, not the Java
|
||||
* backend. This panel invokes {@code create-checkout-session}
|
||||
* directly via {@code supabase.functions.invoke()} — same pattern {@link
|
||||
* usePlans} already uses for {@code stripe-price-lookup}. The auth JWT is
|
||||
* attached automatically by the Supabase client.
|
||||
*
|
||||
* <p>The edge function (SaaS PR #300) is the canonical place Stripe Checkout
|
||||
* Sessions get created — it uses the Stripe Sync Engine tables, has dedicated
|
||||
* unit tests, and shares Stripe SDK / secret-key plumbing with the metering +
|
||||
* webhook edge functions. Routing through Java would have meant a useless
|
||||
* proxy hop + a second Stripe SDK to maintain.
|
||||
*
|
||||
* <h2>Behaviour</h2>
|
||||
*
|
||||
* <ol>
|
||||
* <li>On mount: calls {@code supabase.functions.invoke("create-checkout-session", {team_id,
|
||||
* currency, success_url, cancel_url})} to obtain a {@code client_secret}. The spending cap is
|
||||
* NOT set here — it's an application-layer setting applied via {@code PATCH /payg/cap} after
|
||||
* the subscription lands.
|
||||
* <li>If no {@code VITE_STRIPE_PUBLISHABLE_KEY} is configured OR the edge
|
||||
* function isn't deployed yet (errors out / returns a {@code cs_mock_}
|
||||
* sentinel), render a clearly-labelled placeholder + "Continue with
|
||||
* mock" button so the post-completion path stays testable.
|
||||
* <li>Otherwise render the real {@code <EmbeddedCheckoutProvider>} +
|
||||
* {@code <EmbeddedCheckout>} iframe.
|
||||
* </ol>
|
||||
*
|
||||
* The parent {@link UpgradeModal} passes {@code onComplete} which fires when
|
||||
* either the real Stripe checkout emits its complete event OR the user
|
||||
* presses "Continue with mock" in unconfigured environments.
|
||||
*/
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
|
||||
// Eager static imports here are OK because this whole module is itself lazy-
|
||||
// imported by the modal. They land in the same lazy chunk.
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
} from "@stripe/react-stripe-js";
|
||||
import type { Stripe } from "@stripe/stripe-js";
|
||||
|
||||
export interface StripeCheckoutPanelProps {
|
||||
/**
|
||||
* The caller's team_id — required by the Supabase edge function, which can't
|
||||
* derive it from the JWT alone (the function runs outside our app's Spring
|
||||
* Security context and has no access to the {@code team_memberships} table
|
||||
* other than via this hint).
|
||||
*/
|
||||
teamId: number;
|
||||
/** Currency lower-case 3-letter ISO (e.g. {@code "gbp"}). Selects the Stripe Price. */
|
||||
currency?: string;
|
||||
/** Cap in USD; null means no cap. Tracked locally; set on the wallet via PATCH after subscription. */
|
||||
capUsd: number | null;
|
||||
/** Called when Stripe (or the mock continue button) signals completion. */
|
||||
onComplete: () => void;
|
||||
/** Called when the call to /api/v1/payg/checkout fails. */
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape from the {@code create-checkout-session} Supabase edge
|
||||
* function. Mirrors what the function returns.
|
||||
*/
|
||||
interface CheckoutResponse {
|
||||
/** Stripe Checkout Session client_secret. */
|
||||
client_secret: string;
|
||||
/**
|
||||
* Optional sentinel: edge functions in non-prod environments may return a
|
||||
* stubbed secret prefixed {@code cs_mock_} so the FE knows to render the
|
||||
* placeholder rather than try to mount a real iframe with a bad secret.
|
||||
*/
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
// Singleton Stripe promise — created on first use and reused for the lifetime
|
||||
// of the tab. {@code loadStripe} is dynamically imported so the actual SDK
|
||||
// chunk is only pulled when this code path runs.
|
||||
let stripePromise: Promise<Stripe | null> | null = null;
|
||||
function getStripe(publishableKey: string): Promise<Stripe | null> {
|
||||
if (stripePromise === null) {
|
||||
stripePromise = import("@stripe/stripe-js").then((mod) =>
|
||||
mod.loadStripe(publishableKey),
|
||||
);
|
||||
}
|
||||
return stripePromise;
|
||||
}
|
||||
|
||||
const StripeCheckoutPanel: React.FC<StripeCheckoutPanelProps> = ({
|
||||
teamId,
|
||||
currency = "gbp",
|
||||
// capUsd is part of the props contract but intentionally unused here — the cap is set
|
||||
// application-side via PATCH /payg/cap after the subscription lands, not during checkout.
|
||||
onComplete,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [isMock, setIsMock] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Billing email for the Stripe Checkout Session. Passing customer_email to
|
||||
// Stripe prefills the email field AND locks it (Stripe's own behaviour for
|
||||
// sessions created with customer_email or an attached customer) — the user
|
||||
// can't bill a different address than the account they're signed in with.
|
||||
const billingEmail = user?.email ?? null;
|
||||
|
||||
// Stable ref for the error callback so we don't have to include it in the
|
||||
// effect deps.
|
||||
const onErrorRef = useRef<typeof onError>(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
// Stash t() in a ref so the effect can read the current translator without
|
||||
// forcing t into its deps.
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
|
||||
const publishableKey =
|
||||
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
|
||||
// Dev preview route has no backend — skip the API call and go straight
|
||||
// to the mock placeholder so the design + completion path stay testable.
|
||||
// Both checks required so a production tenant with a /dev/ URL prefix
|
||||
// can't accidentally trigger the placeholder.
|
||||
const devPreview =
|
||||
import.meta.env.DEV &&
|
||||
typeof window !== "undefined" &&
|
||||
window.location.pathname.startsWith("/dev/");
|
||||
|
||||
useEffect(() => {
|
||||
if (devPreview) {
|
||||
setClientSecret("cs_mock_devpreview");
|
||||
setIsMock(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// React 18 strict-mode dev mounts effects twice. We use `cancelled` to
|
||||
// discard the first mount's response (its setState calls become no-ops),
|
||||
// and the second mount's response wins. We deliberately do NOT short-
|
||||
// circuit the second mount with a ref — earlier versions did that and
|
||||
// ran into the trap where mount 1 was cancelled but the live mount
|
||||
// never re-fetched, leaving `loading` stuck at true. Two network calls
|
||||
// in dev is an acceptable cost; prod has no strict mode → single fetch.
|
||||
let cancelled = false;
|
||||
async function createSession() {
|
||||
try {
|
||||
// Direct Supabase edge function invocation. We call
|
||||
// {@code create-checkout-session} (not {@code create-payg-team-subscription} —
|
||||
// that one creates a subscription directly without going through the
|
||||
// hosted Stripe Embedded Checkout iframe and doesn't return a
|
||||
// client_secret). team_id is required because the edge fn runs
|
||||
// outside our Spring Security context and can't resolve it from the
|
||||
// JWT alone. The cap is *not* set during checkout — it's an
|
||||
// application-layer setting, applied via PATCH /payg/cap after the
|
||||
// subscription lands. window.location.href is the success_url so the
|
||||
// user comes back to the Plan tab after Stripe finishes the redirect.
|
||||
const returnUrl = window.location.href;
|
||||
const { data, error: invokeError } =
|
||||
await supabase.functions.invoke<CheckoutResponse>(
|
||||
"create-checkout-session",
|
||||
{
|
||||
body: {
|
||||
team_id: teamId,
|
||||
currency,
|
||||
success_url: returnUrl,
|
||||
cancel_url: returnUrl,
|
||||
// Maps to Stripe's customer_email when the team has no Stripe
|
||||
// customer yet — prefills + locks the email field in Checkout.
|
||||
// Teams with an existing customer get the email locked from the
|
||||
// customer record instead; this field is ignored for them.
|
||||
...(billingEmail ? { billing_owner_email: billingEmail } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (invokeError) {
|
||||
throw invokeError;
|
||||
}
|
||||
if (!data?.client_secret) {
|
||||
throw new Error("Edge function returned no client_secret");
|
||||
}
|
||||
if (cancelled) return;
|
||||
setClientSecret(data.client_secret);
|
||||
setIsMock(Boolean(data.mock) || data.client_secret.startsWith("cs_mock_"));
|
||||
} catch (e: unknown) {
|
||||
if (cancelled) return;
|
||||
const msg =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: tRef.current(
|
||||
"payg.checkout.error.startFailed",
|
||||
"Couldn't start checkout session",
|
||||
);
|
||||
setError(msg);
|
||||
onErrorRef.current?.(msg);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
void createSession();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamId, currency, devPreview, billingEmail]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="loading">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t("payg.checkout.connecting", "Connecting to Stripe…")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="error">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t("payg.checkout.errorTitle", "Stripe error")}
|
||||
</div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mock mode OR no publishable key configured → friendly placeholder.
|
||||
const showMockPlaceholder = isMock || publishableKey.length === 0;
|
||||
|
||||
if (showMockPlaceholder) {
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="mock">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t(
|
||||
"payg.checkout.mock.title",
|
||||
"Stripe Embedded Checkout (mock mode)",
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
{publishableKey.length === 0
|
||||
? t(
|
||||
"payg.checkout.mock.noKey",
|
||||
"VITE_STRIPE_PUBLISHABLE_KEY is unset. Real iframe mounts here once configured.",
|
||||
)
|
||||
: t(
|
||||
"payg.checkout.mock.backend",
|
||||
"Backend is in mock mode — no real Stripe session was created.",
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={onComplete}
|
||||
>
|
||||
{t(
|
||||
"payg.checkout.mock.continue",
|
||||
"Continue with mock subscription",
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!clientSecret) return null;
|
||||
|
||||
return (
|
||||
<div className="upm-stripe-mount" data-state="live">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={getStripe(publishableKey)}
|
||||
options={{ clientSecret, onComplete }}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckoutPanel;
|
||||
@@ -0,0 +1,440 @@
|
||||
/* Upgrade-to-Processor modal: two steps inside one frame.
|
||||
Step 1: pick a monthly cap.
|
||||
Step 2: Stripe Embedded Checkout (or placeholder when no test key set).
|
||||
The frame doesn't change between steps — only the panel slides — so the
|
||||
user feels like they're filling out one form, not navigating pages. */
|
||||
|
||||
.upm {
|
||||
--upm-accent: #0a8bff;
|
||||
--upm-accent-2: #6c5ce7;
|
||||
--upm-accent-soft: rgba(10, 139, 255, 0.08);
|
||||
--upm-success: #10b981;
|
||||
--upm-radius: 18px;
|
||||
--upm-card-bg: var(--bg-surface);
|
||||
--upm-border: var(--border-default);
|
||||
--upm-divider: var(--border-subtle);
|
||||
--upm-text: var(--text-primary);
|
||||
--upm-muted: var(--text-muted);
|
||||
}
|
||||
[data-mantine-color-scheme="dark"] .upm {
|
||||
--upm-card-bg: #313842;
|
||||
--upm-border: #3d444e;
|
||||
--upm-divider: #3d444e;
|
||||
--upm-accent-soft: rgba(10, 139, 255, 0.14);
|
||||
}
|
||||
|
||||
/* Backdrop locks the page and centres the modal. z-index matches
|
||||
Z_INDEX_OVER_SETTINGS_MODAL (styles/zIndex.ts) — must sit above the
|
||||
AppConfigModal's 1300 (Z_INDEX_OVER_FULLSCREEN_SURFACE). The config modal
|
||||
also hides itself while we're open (appConfig:overlay event), so this is
|
||||
belt-and-braces for the brief overlap during open/close transitions. */
|
||||
.upm-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
animation: upm-fade-in 180ms ease;
|
||||
}
|
||||
|
||||
@keyframes upm-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.upm-frame {
|
||||
background: var(--upm-card-bg);
|
||||
border: 1px solid var(--upm-border);
|
||||
border-radius: var(--upm-radius);
|
||||
width: 100%;
|
||||
/* Stripe Embedded Checkout flips from its single-column layout to the two-column
|
||||
"horizontal" one (order summary beside the payment form) once the iframe is
|
||||
~1000px wide — measured empirically against a live test session; Stripe doesn't
|
||||
document the breakpoint. The live iframe gets frame_width − 44px (the 22px body
|
||||
padding each side; the live mount's own padding is removed — see
|
||||
.upm-stripe-mount[data-state="live"]). So 1100px → ~1056px iframe, comfortably
|
||||
past the ~1000px threshold. Because the frame stays width:100% under this cap,
|
||||
narrow windows shrink it and Stripe falls back to single column — horizontal
|
||||
when it fits, vertical when it doesn't. Cap + confirm steps read fine at this
|
||||
width (their internal text is already capped — see upm-confirm__body,
|
||||
upm-section-help). */
|
||||
max-width: 1100px;
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.4);
|
||||
animation: upm-pop-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes upm-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Header: title + close + step dots ────────────────────────────────── */
|
||||
.upm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 22px 14px;
|
||||
border-bottom: 1px solid var(--upm-divider);
|
||||
}
|
||||
.upm-header__title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--upm-text);
|
||||
margin: 0;
|
||||
}
|
||||
.upm-header__close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-muted);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.upm-header__close:hover {
|
||||
background: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
|
||||
/* Left cluster: optional back arrow + title. The back arrow only renders on the
|
||||
checkout step (cap/confirm have no parent step to return to) — it replaces the
|
||||
old footer "← Back" button. */
|
||||
.upm-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.upm-header__back {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-muted);
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: -6px;
|
||||
transition: background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.upm-header__back:hover {
|
||||
background: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
|
||||
.upm-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 22px 14px;
|
||||
background: var(--upm-accent-soft);
|
||||
border-bottom: 1px solid var(--upm-divider);
|
||||
}
|
||||
.upm-step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--upm-muted);
|
||||
transition: color 200ms ease;
|
||||
}
|
||||
.upm-step__dot {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--upm-card-bg);
|
||||
border: 1.5px solid var(--upm-divider);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
transition: background 200ms ease, border-color 200ms ease, color 200ms ease;
|
||||
}
|
||||
.upm-step[data-state="active"] {
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-step[data-state="active"] .upm-step__dot {
|
||||
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
.upm-step[data-state="done"] {
|
||||
color: var(--upm-success);
|
||||
}
|
||||
.upm-step[data-state="done"] .upm-step__dot {
|
||||
background: var(--upm-success);
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
.upm-step__connector {
|
||||
flex: 1 1 auto;
|
||||
height: 1.5px;
|
||||
background: var(--upm-divider);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ── Body: slides between steps without remounting ───────────────────── */
|
||||
.upm-body {
|
||||
padding: 22px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* Hero promise reinforcement — only shown on step 1 */
|
||||
.upm-promise {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: var(--upm-accent-soft);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-promise__icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--upm-accent);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.upm-promise__highlight {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.upm-section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--upm-text);
|
||||
margin: 0 0 4px;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.upm-section-help {
|
||||
font-size: 0.825rem;
|
||||
color: var(--upm-muted);
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/* Cap selection (presets + custom + no-cap + estimate) now lives in the shared
|
||||
SpendCapControl (SpendCapControl.css). The old upm-cap-* / upm-no-cap-toggle
|
||||
rules were removed with it. */
|
||||
|
||||
/* What counts as "processed" — quiet helper text */
|
||||
.upm-help-card {
|
||||
background: var(--upm-divider);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-top: 14px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--upm-muted);
|
||||
}
|
||||
.upm-help-card__title {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
color: var(--upm-text);
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* On the checkout step, step 1's "Set monthly ceiling" label is replaced (inside
|
||||
the step bar) by the chosen ceiling + an inline Edit affordance — see
|
||||
UpgradeModal.tsx. Kept here next to the step-bar markup it styles. */
|
||||
.upm-step__chosen {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--upm-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.upm-step__edit {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--upm-accent);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.upm-step__edit:hover {
|
||||
background: rgba(10, 139, 255, 0.12);
|
||||
}
|
||||
|
||||
/* Placeholder card for the loading / error / mock states — a dashed, centered
|
||||
box with helper text. The live Stripe iframe is NOT meant to wear this chrome;
|
||||
it gets a full-width reset below (data-state="live"). */
|
||||
.upm-stripe-mount {
|
||||
min-height: 220px;
|
||||
border: 1.5px dashed var(--upm-divider);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
color: var(--upm-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Live Stripe Embedded Checkout — strip the placeholder card chrome so the
|
||||
iframe spans the full body width and Stripe renders its two-column
|
||||
"horizontal" layout. Without this the dashed border + 24px padding + centering
|
||||
above box the iframe ~48px narrower, dropping it back to single column. */
|
||||
.upm-stripe-mount[data-state="live"] {
|
||||
display: block;
|
||||
border: none;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.upm-stripe-mount__title {
|
||||
font-weight: 700;
|
||||
color: var(--upm-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.upm-stripe-mount__code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 6px;
|
||||
background: var(--upm-card-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
|
||||
/* ── Footer / nav actions ─────────────────────────────────────────────── */
|
||||
.upm-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 22px;
|
||||
border-top: 1px solid var(--upm-divider);
|
||||
background: var(--upm-card-bg);
|
||||
}
|
||||
.upm-footer__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.upm-btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.upm-btn[data-variant="ghost"] {
|
||||
background: transparent;
|
||||
border-color: var(--upm-divider);
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-btn[data-variant="ghost"]:hover {
|
||||
border-color: var(--upm-accent);
|
||||
color: var(--upm-accent);
|
||||
}
|
||||
.upm-btn[data-variant="primary"] {
|
||||
background: linear-gradient(135deg, var(--upm-accent), var(--upm-accent-2));
|
||||
color: white;
|
||||
}
|
||||
.upm-btn[data-variant="primary"]:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 18px -6px rgba(10, 139, 255, 0.5);
|
||||
}
|
||||
.upm-btn[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* ── Step 3: confirmation ─────────────────────────────────────────────── */
|
||||
|
||||
.upm-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 12px 8px 4px;
|
||||
gap: 12px;
|
||||
}
|
||||
.upm-confirm__icon {
|
||||
font-size: 64px !important;
|
||||
color: #10b981;
|
||||
filter: drop-shadow(0 6px 18px rgba(16, 185, 129, 0.35));
|
||||
}
|
||||
.upm-confirm__title {
|
||||
margin: 6px 0 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--upm-text);
|
||||
}
|
||||
.upm-confirm__body {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--upm-text-muted);
|
||||
max-width: 380px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.upm-confirm__summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 14px 18px;
|
||||
background: var(--upm-inset-bg, rgba(0, 0, 0, 0.03));
|
||||
border: 1px solid var(--upm-divider);
|
||||
border-radius: 12px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.upm-confirm__summary > strong {
|
||||
color: var(--upm-text);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.upm-confirm__note {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--upm-text-muted);
|
||||
max-width: 380px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* Upgrade-to-Processor modal. Three sequential panels inside one frame:
|
||||
*
|
||||
* Step 1: Cap selection — local state only, no side effects
|
||||
* Step 2: Stripe Checkout — POSTs to /api/v1/payg/checkout, mounts the
|
||||
* Stripe Embedded Checkout iframe (lazy-loaded)
|
||||
* Step 3: Confirmation — brief "Welcome to Processor" beat before
|
||||
* the modal closes and the parent's
|
||||
* {@code onComplete} triggers a wallet refetch
|
||||
*
|
||||
* <p>The Stripe SDK ({@code @stripe/stripe-js} + {@code @stripe/react-stripe-js})
|
||||
* is loaded via {@code React.lazy} on a dedicated module so the chunk only
|
||||
* downloads when the user actually advances to step 2. The main bundle pays
|
||||
* nothing for users who never open the modal.
|
||||
*
|
||||
* <p>Cap state is held locally — nothing reaches the backend until the user
|
||||
* commits in step 2. A user who cancels mid-modal leaves no side effects.
|
||||
*/
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import CloseIcon from "@mui/icons-material/CloseRounded";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
import ShieldIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircleRounded";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import "./UpgradeModal.css";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import SpendCapControl from "./SpendCapControl";
|
||||
|
||||
/**
|
||||
* Tell the AppConfigModal (or any other full-screen surface listening) that an
|
||||
* upgrade overlay is opening/closing so it can hide itself rather than stack
|
||||
* under us. Same window-event pattern the config modal already uses for
|
||||
* appConfig:navigate / appConfig:notice.
|
||||
*/
|
||||
function dispatchOverlay(open: boolean) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("appConfig:overlay", { detail: { open } }),
|
||||
);
|
||||
}
|
||||
|
||||
// Lazy-loaded so the @stripe/stripe-js bundle only downloads when the user
|
||||
// reaches step 2. See StripeCheckoutPanel.tsx for the full pattern + the
|
||||
// chunk-graph reasoning.
|
||||
const StripeCheckoutPanel = React.lazy(() => import("./StripeCheckoutPanel"));
|
||||
|
||||
interface UpgradeModalProps {
|
||||
open: boolean;
|
||||
/**
|
||||
* The caller's team_id. Threaded through to {@link StripeCheckoutPanel} so the
|
||||
* {@code create-checkout-session} edge function can scope the subscription to
|
||||
* the right team.
|
||||
*/
|
||||
teamId: number;
|
||||
/** Called when the user closes the modal without completing checkout. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Called after the user confirms cap + completes Stripe checkout. The parent
|
||||
* is expected to refresh the wallet snapshot which will then show the
|
||||
* subscribed state.
|
||||
*/
|
||||
onComplete: (result: { capUsd: number | null }) => void;
|
||||
/** ISO 4217 currency code for the cap input. Default USD. */
|
||||
currency?: "USD" | "EUR" | "GBP";
|
||||
/**
|
||||
* The team's one-time free grant in documents — the real {@code
|
||||
* wallet.freeAllowance}, threaded from the free-leader view so the step copy
|
||||
* quotes the backend's number instead of a hardcoded one. A lifetime grant,
|
||||
* not a monthly one.
|
||||
*/
|
||||
freeLimit: number;
|
||||
/**
|
||||
* Per-document rate in minor units for the live "≈ N paid PDFs/month"
|
||||
* estimate, threaded from {@code wallet.pricePerDocMinor}. For unsubscribed
|
||||
* teams the backend resolves this from the default pricing policy's USD
|
||||
* Price (Stripe hasn't assigned the team a currency yet). Null hides the
|
||||
* estimate.
|
||||
*/
|
||||
pricePerDocMinor?: number | null;
|
||||
/** Lower-case ISO currency of {@link #pricePerDocMinor} (e.g. {@code "usd"}). */
|
||||
rateCurrency?: string | null;
|
||||
}
|
||||
|
||||
type Step = "cap" | "checkout" | "confirm";
|
||||
|
||||
function currencySymbol(c: UpgradeModalProps["currency"]): string {
|
||||
switch (c) {
|
||||
case "EUR":
|
||||
return "€";
|
||||
case "GBP":
|
||||
return "£";
|
||||
default:
|
||||
return "$";
|
||||
}
|
||||
}
|
||||
|
||||
export default function UpgradeModal({
|
||||
open,
|
||||
teamId,
|
||||
onClose,
|
||||
onComplete,
|
||||
currency = "USD",
|
||||
freeLimit,
|
||||
pricePerDocMinor,
|
||||
rateCurrency,
|
||||
}: UpgradeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>("cap");
|
||||
const [capUsd, setCapUsd] = useState<number>(500);
|
||||
const [noCap, setNoCap] = useState<boolean>(false);
|
||||
|
||||
// The config modal hides itself while we're open (it listens for this event)
|
||||
// so the upgrade flow visually REPLACES it instead of stacking inside it.
|
||||
// Cleanup fires open=false on unmount too, so the config modal can't get
|
||||
// stuck hidden if we unmount without a clean close.
|
||||
useEffect(() => {
|
||||
dispatchOverlay(open);
|
||||
return () => dispatchOverlay(false);
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveCap = noCap ? null : capUsd;
|
||||
const sym = currencySymbol(currency);
|
||||
|
||||
const goToCheckout = () => setStep("checkout");
|
||||
const goBackToCap = () => setStep("cap");
|
||||
const goToConfirm = () => setStep("confirm");
|
||||
|
||||
// Modal close → reset internal step so reopening starts at step 1.
|
||||
const closeAndReset = () => {
|
||||
setStep("cap");
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Portal to document.body so the overlay escapes the config modal's portal /
|
||||
// stacking context. Without this the fixed-position backdrop layers inside
|
||||
// the Mantine modal (z-index 1300) instead of over the whole page, producing
|
||||
// the modal-in-modal look.
|
||||
return createPortal(
|
||||
<div className="upm" role="dialog" aria-modal="true">
|
||||
<div className="upm-backdrop" onClick={closeAndReset}>
|
||||
<div className="upm-frame" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header — title + close. Title stays constant; the step indicator
|
||||
below tells the user where they are. */}
|
||||
<header className="upm-header">
|
||||
<div className="upm-header__left">
|
||||
{step === "checkout" && (
|
||||
<button
|
||||
type="button"
|
||||
className="upm-header__back"
|
||||
aria-label={t("payg.upgrade.backAria", "Back")}
|
||||
onClick={goBackToCap}
|
||||
>
|
||||
<ArrowBackIcon fontSize="small" />
|
||||
</button>
|
||||
)}
|
||||
<h2 className="upm-header__title">
|
||||
{step === "confirm"
|
||||
? t("payg.upgrade.title.confirm", "You're subscribed")
|
||||
: t("payg.upgrade.title.default", "Upgrade to Processor plan")}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-header__close"
|
||||
aria-label={t("payg.upgrade.closeAria", "Close")}
|
||||
onClick={closeAndReset}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Step indicator. Hidden on the confirmation panel since the
|
||||
modal is winding down at that point. */}
|
||||
{step !== "confirm" && (
|
||||
<div className="upm-steps">
|
||||
<div
|
||||
className="upm-step"
|
||||
data-state={step === "cap" ? "active" : "done"}
|
||||
>
|
||||
<span className="upm-step__dot">1</span>
|
||||
{step === "checkout" ? (
|
||||
<span className="upm-step__chosen">
|
||||
{effectiveCap === null
|
||||
? t("payg.upgrade.checkout.noCap", "No cap")
|
||||
: t(
|
||||
"payg.upgrade.checkout.capValue",
|
||||
"{{symbol}}{{amount}} / month",
|
||||
{ symbol: sym, amount: effectiveCap },
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="upm-step__edit"
|
||||
onClick={goBackToCap}
|
||||
>
|
||||
{t("payg.upgrade.checkout.edit", "Edit")}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{t("payg.upgrade.steps.cap", "Set monthly ceiling")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="upm-step__connector" />
|
||||
<div
|
||||
className="upm-step"
|
||||
data-state={step === "checkout" ? "active" : "idle"}
|
||||
>
|
||||
<span className="upm-step__dot">2</span>
|
||||
<span>
|
||||
{t(
|
||||
"payg.upgrade.steps.payment",
|
||||
"Add payment method",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="upm-body">
|
||||
{step === "cap" && (
|
||||
<CapStep
|
||||
capUsd={capUsd}
|
||||
setCapUsd={setCapUsd}
|
||||
noCap={noCap}
|
||||
setNoCap={setNoCap}
|
||||
pricePerDocMinor={pricePerDocMinor}
|
||||
rateCurrency={rateCurrency}
|
||||
/>
|
||||
)}
|
||||
{step === "checkout" && (
|
||||
// Keyed on the cap value so editing the cap → returning to
|
||||
// step 2 unmounts + remounts the panel, triggering a fresh
|
||||
// POST /checkout for the new cap. Without the key, the
|
||||
// StripeCheckoutPanel's fetchedRef short-circuits and the
|
||||
// session keeps the stale cap.
|
||||
<CheckoutStep
|
||||
key={`co:${effectiveCap ?? "nocap"}`}
|
||||
teamId={teamId}
|
||||
effectiveCap={effectiveCap}
|
||||
currency={currency}
|
||||
onComplete={goToConfirm}
|
||||
/>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<ConfirmationStep
|
||||
effectiveCap={effectiveCap}
|
||||
currency={currency}
|
||||
freeLimit={freeLimit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step !== "checkout" && (
|
||||
<footer className="upm-footer">
|
||||
<div className="upm-footer__actions">
|
||||
{step === "cap" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="ghost"
|
||||
onClick={closeAndReset}
|
||||
>
|
||||
{t("payg.upgrade.button.cancel", "Cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={goToCheckout}
|
||||
>
|
||||
{t("payg.upgrade.button.continue", "Continue →")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{step === "confirm" && (
|
||||
<button
|
||||
type="button"
|
||||
className="upm-btn"
|
||||
data-variant="primary"
|
||||
onClick={() => {
|
||||
setStep("cap");
|
||||
onComplete({ capUsd: effectiveCap });
|
||||
}}
|
||||
>
|
||||
{t("payg.upgrade.button.finish", "Finish")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 1: cap selection ──────────────────────────────────────────────
|
||||
|
||||
interface CapStepProps {
|
||||
capUsd: number;
|
||||
setCapUsd: (v: number) => void;
|
||||
noCap: boolean;
|
||||
setNoCap: (v: boolean) => void;
|
||||
pricePerDocMinor?: number | null;
|
||||
rateCurrency?: string | null;
|
||||
}
|
||||
|
||||
function CapStep({
|
||||
capUsd,
|
||||
setCapUsd,
|
||||
noCap,
|
||||
setNoCap,
|
||||
pricePerDocMinor,
|
||||
rateCurrency,
|
||||
}: CapStepProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="upm-promise">
|
||||
<ShieldIcon className="upm-promise__icon" fontSize="small" />
|
||||
<div>
|
||||
<span className="upm-promise__highlight">
|
||||
{t(
|
||||
"payg.upgrade.promise.highlight",
|
||||
"Manual tools stay free, always.",
|
||||
)}
|
||||
</span>{" "}
|
||||
{t(
|
||||
"payg.upgrade.promise.body",
|
||||
"You only pay for automation pipelines, AI tools, and API calls — the work that goes beyond a single click. Edit, merge, split, sign, compress as much as you want, no charge.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="upm-section-title">
|
||||
{t(
|
||||
"payg.upgrade.cap.title",
|
||||
"Set your monthly spend ceiling",
|
||||
)}
|
||||
</h3>
|
||||
<p className="upm-section-help">
|
||||
{t(
|
||||
"payg.upgrade.cap.help",
|
||||
"We'll never charge above this. Set $0 if you want to keep everything free while testing.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Same control the subscribed plan page renders. null = no cap; the
|
||||
shared control owns the presets, the inline custom-entry pill, the
|
||||
no-cap chip, and the live processed-PDF estimate. */}
|
||||
<SpendCapControl
|
||||
capUsd={noCap ? null : capUsd}
|
||||
onChange={(v) => {
|
||||
if (v === null) {
|
||||
setNoCap(true);
|
||||
} else {
|
||||
setNoCap(false);
|
||||
setCapUsd(v);
|
||||
}
|
||||
}}
|
||||
pricePerDocMinor={pricePerDocMinor}
|
||||
currency={rateCurrency}
|
||||
note={t(
|
||||
"payg.upgrade.cap.usdNote",
|
||||
"Estimated in USD. You can adjust your cap any time after subscribing — in your own currency.",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="upm-help-card" style={{ marginTop: 16 }}>
|
||||
<span className="upm-help-card__title">
|
||||
{t(
|
||||
"payg.upgrade.help.title",
|
||||
"What we count toward billing",
|
||||
)}
|
||||
</span>
|
||||
<ul style={{ margin: "4px 0 0", paddingLeft: 18, lineHeight: 1.55 }}>
|
||||
<li>
|
||||
<strong>
|
||||
{t(
|
||||
"payg.upgrade.help.automationTitle",
|
||||
"Automation pipelines",
|
||||
)}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.automationBody",
|
||||
"chained tools or scheduled runs that don't need clicks",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
{t("payg.upgrade.help.aiTitle", "AI tools")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.aiBody",
|
||||
"summarise, classify, redact, AI-OCR",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>
|
||||
{t("payg.upgrade.help.apiTitle", "API calls")}
|
||||
</strong>
|
||||
{" — "}
|
||||
{t(
|
||||
"payg.upgrade.help.apiBody",
|
||||
"programmatic access to any Stirling endpoint",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: 8, fontStyle: "italic" }}>
|
||||
{t(
|
||||
"payg.upgrade.help.footnote",
|
||||
"Manual tools — viewing, editing, merging, splitting, signing, watermarking, compressing, manual OCR — are always free, even past 500. The distinction is the type of work, not where you click.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 2: Stripe Embedded Checkout (lazy-loaded) ────────────────────
|
||||
|
||||
interface CheckoutStepProps {
|
||||
teamId: number;
|
||||
effectiveCap: number | null;
|
||||
currency: UpgradeModalProps["currency"];
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
function CheckoutStep({
|
||||
teamId,
|
||||
effectiveCap,
|
||||
currency,
|
||||
onComplete,
|
||||
}: CheckoutStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<h3 className="upm-section-title">
|
||||
{t(
|
||||
"payg.upgrade.checkout.title",
|
||||
"Add your payment method",
|
||||
)}
|
||||
</h3>
|
||||
<p className="upm-section-help">
|
||||
{t(
|
||||
"payg.upgrade.checkout.help",
|
||||
"Stripe handles your card details. Stirling never sees them.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="upm-stripe-mount" data-state="loading">
|
||||
<div className="upm-stripe-mount__title">
|
||||
{t("payg.upgrade.checkout.loading", "Loading checkout…")}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StripeCheckoutPanel
|
||||
teamId={teamId}
|
||||
currency={currency?.toLowerCase() ?? "gbp"}
|
||||
capUsd={effectiveCap}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Step 3: confirmation ──────────────────────────────────────────────
|
||||
|
||||
interface ConfirmationStepProps {
|
||||
effectiveCap: number | null;
|
||||
currency: UpgradeModalProps["currency"];
|
||||
freeLimit: number;
|
||||
}
|
||||
|
||||
function ConfirmationStep({
|
||||
effectiveCap,
|
||||
currency,
|
||||
freeLimit,
|
||||
}: ConfirmationStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const sym = currencySymbol(currency);
|
||||
return (
|
||||
<div className="upm-confirm">
|
||||
<CheckCircleIcon className="upm-confirm__icon" />
|
||||
<h3 className="upm-confirm__title">
|
||||
{t(
|
||||
"payg.confirm.title",
|
||||
"Welcome to the Processor plan",
|
||||
)}
|
||||
</h3>
|
||||
<p className="upm-confirm__body">
|
||||
{t(
|
||||
"payg.confirm.body",
|
||||
"Your team can now process documents with automation, AI, and the API beyond your {{limit}} free PDFs.",
|
||||
{ limit: freeLimit.toLocaleString() },
|
||||
)}
|
||||
</p>
|
||||
<div className="upm-confirm__summary">
|
||||
<span>{t("payg.confirm.summaryLabel", "Monthly ceiling")}</span>
|
||||
<strong>
|
||||
{effectiveCap === null
|
||||
? t("payg.confirm.noCap", "No cap")
|
||||
: t("payg.confirm.capValue", "{{symbol}}{{amount}} / month", {
|
||||
symbol: sym,
|
||||
amount: effectiveCap,
|
||||
})}
|
||||
</strong>
|
||||
</div>
|
||||
<p className="upm-confirm__note">
|
||||
{t(
|
||||
"payg.confirm.note",
|
||||
"You can change your cap, cancel, or open the Stripe customer portal any time from this page.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-60
@@ -1,79 +1,32 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { ApiCredits } from "@app/types/credits";
|
||||
import { isUserAnonymous } from "@app/auth/supabase";
|
||||
|
||||
function coerceNumber(value: unknown, fallback = 0): number {
|
||||
const n = typeof value === "string" ? Number(value) : (value as number);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function normalizeCredits(raw: Record<string, unknown>): ApiCredits {
|
||||
// Accept a variety of possible backend keys to be resilient
|
||||
return {
|
||||
weeklyCreditsRemaining: coerceNumber(
|
||||
raw?.weeklyCreditsRemaining ?? raw?.weeklyRemaining ?? raw?.weekly_left,
|
||||
),
|
||||
weeklyCreditsAllocated: coerceNumber(
|
||||
raw?.weeklyCreditsAllocated ?? raw?.weeklyAllocated ?? raw?.weekly_total,
|
||||
),
|
||||
boughtCreditsRemaining: coerceNumber(
|
||||
raw?.boughtCreditsRemaining ?? raw?.boughtRemaining ?? raw?.bought_left,
|
||||
),
|
||||
totalBoughtCredits: coerceNumber(
|
||||
raw?.totalBoughtCredits ?? raw?.boughtTotal ?? raw?.bought_total,
|
||||
),
|
||||
totalAvailableCredits: coerceNumber(
|
||||
raw?.totalAvailableCredits ?? raw?.totalRemaining ?? raw?.available_total,
|
||||
),
|
||||
weeklyResetDate: String(
|
||||
raw?.weeklyResetDate ?? raw?.weeklyReset ?? raw?.reset_date ?? "",
|
||||
),
|
||||
lastApiUsage: String(
|
||||
raw?.lastApiUsage ?? raw?.lastApiUse ?? raw?.last_used_at ?? "",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function useCredits() {
|
||||
const { session, loading, user } = useAuth();
|
||||
const isAnonymous = Boolean(user && isUserAnonymous(user));
|
||||
const [data, setData] = useState<ApiCredits | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
// Gutted hook (legacy /api/v1/credits is dead) — these stay at their initial values; only
|
||||
// hasAttempted toggles, so the rest have no setters.
|
||||
const [data] = useState<ApiCredits | null>(null);
|
||||
const [isLoading] = useState<boolean>(false);
|
||||
const [error] = useState<Error | null>(null);
|
||||
const [hasAttempted, setHasAttempted] = useState<boolean>(false);
|
||||
|
||||
// Legacy weekly-credits endpoint (/api/v1/credits) is dead. PAYG replaces this — the
|
||||
// wallet hook (useWallet) carries the equivalent state via /api/v1/payg/wallet. The
|
||||
// hook surface is preserved for the ApiKeys page consumer (it destructures `data`,
|
||||
// `isLoading`, `error`, `refetch`, `hasAttempted`), but `data` always stays null —
|
||||
// the consumer's usage widget will render its "no data" state.
|
||||
const fetchCredits = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res =
|
||||
await apiClient.get<Record<string, unknown>>("/api/v1/credits");
|
||||
const normalized = normalizeCredits(res.data);
|
||||
// If backend returns an "empty" payload, keep data null so the UI stays in loading/skeleton
|
||||
const isEmpty =
|
||||
!normalized.weeklyCreditsAllocated &&
|
||||
!normalized.weeklyCreditsRemaining &&
|
||||
!normalized.totalBoughtCredits &&
|
||||
!normalized.boughtCreditsRemaining &&
|
||||
!normalized.totalAvailableCredits &&
|
||||
!normalized.weeklyResetDate &&
|
||||
!normalized.lastApiUsage;
|
||||
setData(isEmpty ? null : normalized);
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setHasAttempted(true);
|
||||
}
|
||||
setHasAttempted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && session && !hasAttempted && !isAnonymous) {
|
||||
fetchCredits();
|
||||
setHasAttempted(true);
|
||||
}
|
||||
}, [loading, session, hasAttempted, isAnonymous, fetchCredits]);
|
||||
}, [loading, session, hasAttempted, isAnonymous]);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
-143
@@ -1,143 +0,0 @@
|
||||
import React from "react";
|
||||
import { Card, Text, Group, Flex, Alert, Button, Badge } from "@mantine/core";
|
||||
import AccessTimeIcon from "@mui/icons-material/AccessTime";
|
||||
import CreditCardIcon from "@mui/icons-material/CreditCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
import { ManageBillingButton } from "@app/components/shared/ManageBillingButton";
|
||||
|
||||
interface TrialStatus {
|
||||
isTrialing: boolean;
|
||||
trialEnd: string;
|
||||
daysRemaining: number;
|
||||
hasPaymentMethod: boolean;
|
||||
hasScheduledSub: boolean;
|
||||
}
|
||||
|
||||
interface ActivePlanSectionProps {
|
||||
currentPlan: PlanTier;
|
||||
_activeSince?: string;
|
||||
_nextBillingDate?: string;
|
||||
trialStatus?: TrialStatus;
|
||||
onAddPaymentClick?: () => void;
|
||||
}
|
||||
|
||||
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({
|
||||
currentPlan,
|
||||
_activeSince,
|
||||
_nextBillingDate,
|
||||
trialStatus,
|
||||
onAddPaymentClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.activePlan.title", "Active Plan")}
|
||||
</h3>
|
||||
<ManageBillingButton
|
||||
returnUrl={`${window.location.origin}/account`}
|
||||
trialStatus={trialStatus}
|
||||
/>
|
||||
</Flex>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.activePlan.subtitle", "Your current subscription details")}
|
||||
</p>
|
||||
|
||||
{/* Trial Status Alert */}
|
||||
{trialStatus?.isTrialing && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<AccessTimeIcon sx={{ fontSize: 16 }} />}
|
||||
mt="md"
|
||||
mb="md"
|
||||
title={t("plan.trial.title", "Free Trial Active")}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", {
|
||||
days: trialStatus.daysRemaining,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("plan.trial.endDate", "Expires: {{date}}", {
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
|
||||
})}
|
||||
</Text>
|
||||
{trialStatus.hasScheduledSub ? (
|
||||
<Text size="xs" c="green" fw={500} mt="sm">
|
||||
✓{" "}
|
||||
{t(
|
||||
"plan.trial.subscriptionScheduled",
|
||||
"Subscription scheduled - starts {{date}}",
|
||||
{
|
||||
date: new Date(trialStatus.trialEnd).toLocaleDateString(),
|
||||
},
|
||||
)}
|
||||
</Text>
|
||||
) : (
|
||||
onAddPaymentClick && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
mt="sm"
|
||||
onClick={onAddPaymentClick}
|
||||
leftSection={<CreditCardIcon sx={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t("plan.trial.subscribeToPro", "Subscribe to Pro")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Group gap="xs">
|
||||
<Text size="lg" fw={600}>
|
||||
{currentPlan.name}
|
||||
</Text>
|
||||
{trialStatus?.isTrialing && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t("plan.trial.badge", "Trial")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{/* {activeSince && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.activeSince', 'Active since {{date}}', { date: activeSince })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.currency}
|
||||
{currentPlan.price}/month
|
||||
</Text>
|
||||
{/* {nextBillingDate && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })}
|
||||
</Text>
|
||||
)} */}
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivePlanSection;
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Text, Stack, Flex, Slider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CreditsPack } from "@app/components/shared/StripeCheckoutSaas";
|
||||
|
||||
interface ApiPackage {
|
||||
id: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
price: number;
|
||||
currency: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ApiPackagesSectionProps {
|
||||
apiPackages: ApiPackage[];
|
||||
selectedCredits: number;
|
||||
onSelectedCreditsChange: (value: number) => void;
|
||||
onCreditPurchaseClick: (creditsPack: CreditsPack) => void;
|
||||
}
|
||||
|
||||
const ApiPackagesSection: React.FC<ApiPackagesSectionProps> = ({
|
||||
apiPackages,
|
||||
selectedCredits,
|
||||
onSelectedCreditsChange,
|
||||
onCreditPurchaseClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.apiPackages.title", "API Credit Packages")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.apiPackages.subtitle",
|
||||
"Purchase API credits for your applications",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Card padding="xl" radius="md" className="mb-4">
|
||||
<Stack gap="xl">
|
||||
{/* Credits Selection */}
|
||||
<div>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.selectCredits", "Select Credit Amount")}
|
||||
</Text>
|
||||
|
||||
<div className="px-4">
|
||||
<Slider
|
||||
value={selectedCredits}
|
||||
onChange={onSelectedCreditsChange}
|
||||
onChangeEnd={(value) =>
|
||||
onSelectedCreditsChange(Math.round(value))
|
||||
}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.01}
|
||||
marks={[
|
||||
{ value: 0, label: "100" },
|
||||
{ value: 1, label: "500" },
|
||||
{ value: 2, label: "1K" },
|
||||
{ value: 3, label: "5K" },
|
||||
]}
|
||||
size="lg"
|
||||
className="mb-6"
|
||||
label={null}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Package Display */}
|
||||
<Flex gap={"xl"} justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[
|
||||
Math.round(selectedCredits)
|
||||
].credits.toLocaleString()}{" "}
|
||||
Credits
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{apiPackages[Math.round(selectedCredits)].description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<Text size="xl" fw={700}>
|
||||
{apiPackages[Math.round(selectedCredits)].currency}
|
||||
{apiPackages[Math.round(selectedCredits)].price}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("plan.totalCost", "Total Cost")}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={() =>
|
||||
onCreditPurchaseClick(
|
||||
apiPackages[Math.round(selectedCredits)].id as CreditsPack,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("plan.purchase", "Purchase")}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiPackagesSection;
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Badge, Text, Collapse } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
import PlanCard from "@app/components/shared/config/configSections/plan/PlanCard";
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlan?: PlanTier;
|
||||
currentLicenseInfo?: unknown;
|
||||
onUpgradeClick: (plan: PlanTier) => void;
|
||||
onManageClick?: (plan: PlanTier) => void;
|
||||
currency?: string;
|
||||
onCurrencyChange?: (currency: string) => void;
|
||||
currencyOptions?: Array<{ value: string; label: string }>;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentPlan,
|
||||
onUpgradeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
const isUserProOrAbove =
|
||||
currentPlan?.id === "pro" || currentPlan?.id === "enterprise";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "var(--mantine-color-text)",
|
||||
fontSize: "1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.availablePlans.title", "Available Plans")}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: "0.25rem 0 1rem 0",
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"plan.availablePlans.subtitle",
|
||||
"Choose the plan that fits your needs",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="flex h-[20rem] mb-4 "
|
||||
style={{ gap: "1rem", overflowX: "auto" }}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrentPlan={plan.id === currentPlan?.id}
|
||||
isUserProOrAbove={isUserProOrAbove}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => setShowComparison(!showComparison)}
|
||||
>
|
||||
{showComparison
|
||||
? t("plan.hideComparison", "Hide Feature Comparison")
|
||||
: t("plan.showComparison", "Compare All Features")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={showComparison}>
|
||||
<Card padding="lg" radius="md" withBorder className="mt-4">
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t("plan.featureComparison", "Feature Comparison")}
|
||||
</Text>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2">
|
||||
{t("plan.feature.title", "Feature")}
|
||||
</th>
|
||||
{plans.map((plan) => (
|
||||
<th
|
||||
key={plan.id}
|
||||
className="text-center p-2 min-w-24 relative"
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0rem",
|
||||
right: "-2rem",
|
||||
fontSize: "0.5rem",
|
||||
fontWeight: "500",
|
||||
height: "1rem",
|
||||
padding: "0 0.1rem",
|
||||
}}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[0].features.map((_, featureIndex) => (
|
||||
<tr key={featureIndex} className="border-b">
|
||||
<td className="p-2">
|
||||
{plans[0].features[featureIndex].name}
|
||||
</td>
|
||||
{plans.map((plan) => (
|
||||
<td key={plan.id} className="text-center p-2">
|
||||
{plan.features[featureIndex].included ? (
|
||||
<Text c="green" fw={600}>
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray">-</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailablePlansSection;
|
||||
@@ -1,113 +0,0 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Badge, Text, Group, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlanTier } from "@app/hooks/usePlans";
|
||||
|
||||
interface PlanCardProps {
|
||||
plan?: PlanTier;
|
||||
planGroup?: { monthly?: PlanTier; yearly?: PlanTier }; // For proprietary PlanTierGroup compatibility
|
||||
isCurrentPlan?: boolean;
|
||||
isCurrentTier?: boolean;
|
||||
isDowngrade?: boolean;
|
||||
isUserProOrAbove?: boolean;
|
||||
currentLicenseInfo?: unknown;
|
||||
currentTier?: string | null; // Accept null for proprietary compatibility
|
||||
onUpgradeClick?: (plan: PlanTier) => void;
|
||||
onManageClick?: (plan: PlanTier) => void;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({
|
||||
plan: propPlan,
|
||||
planGroup,
|
||||
isCurrentPlan,
|
||||
isCurrentTier: _isCurrentTier,
|
||||
isDowngrade: _isDowngrade,
|
||||
isUserProOrAbove,
|
||||
currentLicenseInfo: _currentLicenseInfo,
|
||||
currentTier: _currentTier,
|
||||
onUpgradeClick,
|
||||
onManageClick: _onManageClick,
|
||||
loginEnabled: _loginEnabled,
|
||||
}) => {
|
||||
// Use plan from props, or extract from planGroup if proprietary is using it
|
||||
const plan = propPlan || planGroup?.monthly || planGroup?.yearly;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!plan) return null; // Safety check
|
||||
|
||||
const shouldHideUpgrade = plan.id === "free" && isUserProOrAbove;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="sm"
|
||||
withBorder
|
||||
className="h-full w-[33%] relative"
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: "absolute", top: "0.5rem", right: "0.5rem" }}
|
||||
>
|
||||
{t("plan.popular", "Popular")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" className="h-full">
|
||||
<div>
|
||||
<Text size="lg" fw={600}>
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="2xl" fw={700}>
|
||||
{plan.isContactOnly
|
||||
? t("plan.customPricing", "Custom")
|
||||
: `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight: string, index: number) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div className="flex-grow" />
|
||||
|
||||
{!shouldHideUpgrade && (
|
||||
<Button
|
||||
variant={
|
||||
isCurrentPlan
|
||||
? "filled"
|
||||
: plan.isContactOnly
|
||||
? "outline"
|
||||
: "filled"
|
||||
}
|
||||
disabled={isCurrentPlan}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick?.(plan)}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t("plan.current", "Current Plan")
|
||||
: plan.isContactOnly
|
||||
? t("plan.contact", "Get in Touch")
|
||||
: t("plan.upgrade", "Upgrade")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
@@ -151,7 +151,11 @@ function appendMcpSection(
|
||||
export function createSaasConfigNavSections(
|
||||
Overview: OverviewComponent,
|
||||
onLogoutClick: () => void,
|
||||
{ isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions,
|
||||
{
|
||||
isDev = false,
|
||||
isAnonymous = false,
|
||||
t,
|
||||
}: CreateSaasConfigNavSectionsOptions,
|
||||
): ConfigNavSection[] {
|
||||
const baseSections = createCoreConfigNavSections(false, false, false);
|
||||
|
||||
@@ -203,6 +207,9 @@ export function createSaasConfigNavSections(
|
||||
sections = appendMcpSection(sections, t);
|
||||
|
||||
if (!isAnonymous) {
|
||||
// The Plan tab is now the single billing surface — it internally branches
|
||||
// free vs subscribed × leader vs member via useWallet(). The old separate
|
||||
// "Pay-as-you-go" tab and paygEnabled / isLeader options were removed.
|
||||
sections = appendBillingSection(sections, t);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +1,26 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCredits } from "@app/hooks/useCredits";
|
||||
import { getToolCreditCost } from "@app/utils/creditCosts";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
export function useCreditCheck(operationType?: string, _endpoint?: string) {
|
||||
const { hasSufficientCredits, isPro, creditBalance, refreshCredits } =
|
||||
useCredits();
|
||||
const { t } = useTranslation();
|
||||
|
||||
/**
|
||||
* Pre-flight credit-balance check, formerly run before every billable tool call.
|
||||
*
|
||||
* Replaced by PAYG's reactive 402 FEATURE_DEGRADED handler (see paygErrorInterceptor.ts):
|
||||
* we no longer try to predict whether the user has "enough credits" before the request —
|
||||
* we make the request, and if the wallet hits the free-tier ceiling the BE returns 402
|
||||
* with a discriminating code that the global axios interceptor turns into a toast +
|
||||
* prompt-to-add-card. This is more accurate (no race between FE balance cache and the
|
||||
* BE's atomic debit) and avoids the round-trip latency of a pre-flight call.
|
||||
*
|
||||
* The hook signature is preserved as a no-op so {@code useToolOperation} (the sole
|
||||
* caller) compiles without modification. {@code checkCredits} always resolves to null
|
||||
* — the BE's 402 handler is now the only gate.
|
||||
*/
|
||||
export function useCreditCheck(
|
||||
_operationType?: string,
|
||||
_endpoint?: string,
|
||||
): { checkCredits: (_runtimeEndpoint?: string) => Promise<string | null> } {
|
||||
const checkCredits = useCallback(
|
||||
async (_runtimeEndpoint?: string): Promise<string | null> => {
|
||||
const requiredCredits = getToolCreditCost(operationType as ToolId);
|
||||
const creditCheck = hasSufficientCredits(requiredCredits);
|
||||
|
||||
if (creditBalance === null) {
|
||||
try {
|
||||
await refreshCredits();
|
||||
} catch (_e) {
|
||||
void _e;
|
||||
}
|
||||
return t("loadingCredits", "Checking credits...");
|
||||
}
|
||||
|
||||
if (isPro === null) {
|
||||
return t("loadingProStatus", "Checking subscription status...");
|
||||
}
|
||||
|
||||
if (!isPro && !creditCheck.hasSufficientCredits) {
|
||||
const shortfall = creditCheck.shortfall || 0;
|
||||
const error = t(
|
||||
"insufficientCredits",
|
||||
"Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}",
|
||||
{
|
||||
requiredCredits,
|
||||
currentBalance: creditCheck.currentBalance,
|
||||
shortfall,
|
||||
},
|
||||
);
|
||||
const notice = t(
|
||||
"noticeTopUpOrPlan",
|
||||
"Not enough credits, please top up or upgrade to a plan",
|
||||
);
|
||||
openPlanSettings(notice);
|
||||
return error;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[
|
||||
hasSufficientCredits,
|
||||
isPro,
|
||||
creditBalance,
|
||||
refreshCredits,
|
||||
operationType,
|
||||
t,
|
||||
],
|
||||
async (_runtimeEndpoint?: string): Promise<string | null> => null,
|
||||
[],
|
||||
);
|
||||
|
||||
return { checkCredits };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Dev-only render counter. Logs every render with a stable label so we can
|
||||
* eyeball excessive re-renders during PAYG Plan-page work. Counts to
|
||||
* {@code window.__renderCounts[label]} so a Playwright eval can assert
|
||||
* "Plan rendered ≤ 3 times after a wallet refetch."
|
||||
*
|
||||
* <p>In production builds {@code import.meta.env.DEV} is constant-folded to
|
||||
* {@code false}, so the module-level constant {@code IS_DEV} below allows
|
||||
* Vite's dead-code-elimination to drop the whole hook body — no extra refs,
|
||||
* no extra renders, no window pollution.
|
||||
*/
|
||||
import { useRef } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__renderCounts?: Record<string, number>;
|
||||
}
|
||||
}
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
|
||||
export function useRenderCount(label: string): number {
|
||||
// useRef must run unconditionally to satisfy rules-of-hooks. In production
|
||||
// the rest of the body is dead-code-eliminated, leaving just this single
|
||||
// ref allocation.
|
||||
const count = useRef(0);
|
||||
if (!IS_DEV) return 0;
|
||||
count.current += 1;
|
||||
if (typeof window !== "undefined") {
|
||||
if (!window.__renderCounts) window.__renderCounts = {};
|
||||
window.__renderCounts[label] = count.current;
|
||||
}
|
||||
return count.current;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Hook backing the PAYG Plan page. Wraps {@code GET /api/v1/payg/wallet}
|
||||
* (served by {@code PaygWalletController} once Wave 1 BE lands; until then
|
||||
* the dev preview route synthesises a wallet from localStorage) and exposes
|
||||
* mutations for marking-subscribed and updating-the-cap.
|
||||
*
|
||||
* <h2>Render efficiency</h2>
|
||||
*
|
||||
* The hook is designed so {@code Plan}, {@code PaygFreeLeader/Member}, and
|
||||
* {@code PaygLeader/Member} re-render only on actual data change:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link Wallet} snapshot is stored as a plain object — but every
|
||||
* successful fetch deep-compares with the previous snapshot and reuses
|
||||
* the prior reference if the payload is unchanged. Consumers that hold
|
||||
* a stable {@code wallet} reference get stable child memoisation.
|
||||
* <li>The returned {@link UseWalletResult} keeps stable callback identities
|
||||
* via {@code useCallback}. {@code Plan} can pass {@code markSubscribed}
|
||||
* to {@code UpgradeModal} without forcing a remount.
|
||||
* <li>{@code refetch / markSubscribed / updateCap} bump an internal counter
|
||||
* that the {@code useEffect} watches — no global state plumbing.
|
||||
* <li>A monotonic {@code requestId} ref drops stale responses so a slow
|
||||
* refetch from tick=N can't overwrite a faster one from tick=N+1
|
||||
* (out-of-order resolution would otherwise show old data).
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Mutation semantics</h2>
|
||||
*
|
||||
* Both {@code markSubscribed} and {@code updateCap} resolve only after the
|
||||
* post-mutation wallet refetch completes. So callers like the cap-editor
|
||||
* "Update cap" button that gate a {@code loading} state on the returned
|
||||
* promise see the UI flip exactly once the new state is visible — no
|
||||
* intermediate flash of the old value.
|
||||
*
|
||||
* <h2>Dev preview fallback</h2>
|
||||
*
|
||||
* When the hook is rendered outside the saas app (e.g. on {@code
|
||||
* /dev/payg-preview} during local design work) the {@code AppConfigContext}
|
||||
* provider is not mounted and no backend is available. The hook detects that
|
||||
* via {@code import.meta.env.DEV} + the {@code /dev/} path and falls back to
|
||||
* a synthesised snapshot whose subscription state is read from
|
||||
* {@code localStorage} (key {@code stirling.payg.devSubscription}). Both
|
||||
* conditions are required so a production tenant whose URL happens to start
|
||||
* with {@code /dev/} can't trigger the fallback.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { supabase } from "@app/auth/supabase";
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────
|
||||
|
||||
export type WalletStatus = "free" | "subscribed";
|
||||
export type WalletRole = "leader" | "member";
|
||||
|
||||
/**
|
||||
* A single team member's billing-relevant info — name + email for the avatar
|
||||
* row, {@code spendUnits} for their per-member usage display. Mirrors a row of
|
||||
* the backend's {@code members} array on {@code WalletSnapshot} (joined with
|
||||
* {@code team_memberships}).
|
||||
*/
|
||||
export interface WalletMember {
|
||||
/** Supabase user id of the member. */
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** Member's current-period billable spend. */
|
||||
spendUnits: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-category breakdown of current-period spend in billable units. The
|
||||
* categories mirror the {@code FeatureGate} buckets the backend tracks:
|
||||
* server-side tool calls ({@code api}), AI-backed tools ({@code ai}), and
|
||||
* pipeline / automation runs ({@code automation}). Numbers sum to {@code
|
||||
* billableUsed} (modulo rounding in mock data).
|
||||
*/
|
||||
export interface WalletCategoryBreakdown {
|
||||
api: number;
|
||||
ai: number;
|
||||
automation: number;
|
||||
}
|
||||
|
||||
/** Mirror of the backend's {@code WalletSnapshot} record (the JSON returned from {@code GET /api/v1/payg/wallet}). */
|
||||
export interface Wallet {
|
||||
/**
|
||||
* The caller's primary team_id. Needed when invoking Supabase edge functions
|
||||
* (create-checkout-session, etc.) that run outside Spring Security and have
|
||||
* no other way to resolve the caller's team. May be null on the synthetic
|
||||
* empty snapshot returned to anonymous / team-less callers.
|
||||
*/
|
||||
teamId: number | null;
|
||||
status: WalletStatus;
|
||||
role: WalletRole;
|
||||
/**
|
||||
* ISO yyyy-mm-dd. The Stripe subscription's current period when subscribed;
|
||||
* the calendar month for free teams.
|
||||
*/
|
||||
billingPeriodStart: string;
|
||||
billingPeriodEnd: string;
|
||||
/**
|
||||
* For a free team: the one-time free documents used so far ({@code
|
||||
* freeAllowance − freeRemaining}). For a subscribed team: documents
|
||||
* processed this month across automation + AI + API.
|
||||
*/
|
||||
billableUsed: number;
|
||||
/**
|
||||
* The team's document ceiling for the matching window: the one-time free
|
||||
* grant ({@code freeAllowance}) for free teams; the monthly paid-doc cap
|
||||
* {@code floor(cap / perDocRate)} for capped subscribed teams; null when
|
||||
* subscribed with no cap (uncapped).
|
||||
*/
|
||||
billableLimit: number | null;
|
||||
/**
|
||||
* The team's one-time free document grant size — the "N" in "X of N free".
|
||||
* A lifetime grant ({@code pricing_policy.free_tier_units}): it never resets
|
||||
* and is not lost when the team subscribes.
|
||||
*/
|
||||
freeAllowance: number;
|
||||
/**
|
||||
* One-time free documents still available to the team
|
||||
* ({@code payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
|
||||
* Survives subscribing — a subscribed team keeps any unused grant.
|
||||
*/
|
||||
freeRemaining: number;
|
||||
/**
|
||||
* Paid per-document rate in minor units of {@link Wallet#currency} (may be
|
||||
* fractional); null when the rate can't be resolved — render "unknown",
|
||||
* never substitute.
|
||||
*/
|
||||
pricePerDocMinor: number | null;
|
||||
/** Lower-case ISO 4217 currency of the subscription's Stripe Price; null when unknown. */
|
||||
currency: string | null;
|
||||
/**
|
||||
* Estimated charges so far this period in minor units of currency: paid
|
||||
* (Stripe-metered) documents this period × rate. The free portion was
|
||||
* already netted out at charge time. Informational — the Stripe invoice
|
||||
* is authoritative. Null when the rate is unknown.
|
||||
*/
|
||||
estimatedBillMinor: number | null;
|
||||
/** Monthly cap in major currency units when subscribed; null when noCap or status=='free'. */
|
||||
capUsd: number | null;
|
||||
/** Only meaningful when status=='subscribed'. */
|
||||
noCap: boolean;
|
||||
/** Stripe subscription id when subscribed; null when free. */
|
||||
stripeSubscriptionId: string | null;
|
||||
/** Current-period spend in billable units. */
|
||||
spendUnitsThisPeriod: number;
|
||||
/** Per-category spend breakdown (api / ai / automation). */
|
||||
categoryBreakdown: WalletCategoryBreakdown;
|
||||
/**
|
||||
* Team members, populated for the leader view; empty for members or
|
||||
* single-seat tenants. Leader-vs-member is still resolved via {@link
|
||||
* Wallet#role} — this field just carries the per-member rows the leader's
|
||||
* sub-cap table needs.
|
||||
*/
|
||||
members: WalletMember[];
|
||||
/**
|
||||
* Recent billable-activity rows. V1 returns {@code []} from the backend;
|
||||
* the field exists so the Plan page can render an empty state without
|
||||
* branching on undefined. Each entry is a {@code Record<string, unknown>}
|
||||
* because the activity-row shape is not yet finalised — when the meter-
|
||||
* event surface lands, this widens to a real interface.
|
||||
*/
|
||||
recent: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface UseWalletResult {
|
||||
wallet: Wallet | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Force a refetch — e.g. after Stripe redirects back into the app. */
|
||||
refetch: () => Promise<void>;
|
||||
/**
|
||||
* Dev-only side-channel that simulates the Stripe webhook flipping the
|
||||
* team to subscribed. Used by {@code UpgradeModal} when the backend is
|
||||
* running the mock checkout — the real flow waits for the webhook
|
||||
* instead and the next {@code refetch} picks up the change. Resolves
|
||||
* once the post-mutation refetch completes.
|
||||
*/
|
||||
markSubscribed: (capUsd: number | null) => Promise<void>;
|
||||
/**
|
||||
* Update the team's monthly cap. {@code null} means "no cap". Resolves
|
||||
* once the post-mutation refetch completes so a save-button
|
||||
* {@code loading} state can be safely cleared on resolution.
|
||||
*/
|
||||
updateCap: (capUsd: number | null) => Promise<void>;
|
||||
/**
|
||||
* Mint a Stripe Customer Portal session and navigate to it. Calls
|
||||
* the {@code create-customer-portal-session} Supabase edge function
|
||||
* directly with the user's JWT (same pattern as checkout — no backend
|
||||
* proxy; the function's RPC enforces team membership) and assigns
|
||||
* {@code window.location} to the returned URL — same-tab redirect.
|
||||
* We do not use {@code window.open(...,"_blank")} after an {@code await}
|
||||
* because browsers treat it as non-user-gesture and silently popup-block
|
||||
* it; the portal is a full-page experience anyway and Stripe redirects
|
||||
* back to {@code return_url} on close. Throws on error so the caller can
|
||||
* show a friendly toast — notably 404 {@code team_not_subscribed}.
|
||||
*/
|
||||
openPortal: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Implementation ─────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = "stirling.payg.devSubscription";
|
||||
|
||||
/**
|
||||
* Stable reference reuse — if the new payload deep-equals the previous one,
|
||||
* return the previous object so React's reference check short-circuits child
|
||||
* renders. Walks the top-level scalars first (cheapest), then the nested
|
||||
* {@code categoryBreakdown} object, then the {@code members} array. The
|
||||
* {@code recent} array is identity-compared only — Wave 1 always returns
|
||||
* {@code []} so a reference-stability check is sufficient; we'll deepen
|
||||
* this once the activity surface lands.
|
||||
*/
|
||||
function reuseIfEqual(prev: Wallet | null, next: Wallet): Wallet {
|
||||
if (!prev) return next;
|
||||
if (
|
||||
prev.status !== next.status ||
|
||||
prev.role !== next.role ||
|
||||
prev.billingPeriodStart !== next.billingPeriodStart ||
|
||||
prev.billingPeriodEnd !== next.billingPeriodEnd ||
|
||||
prev.billableUsed !== next.billableUsed ||
|
||||
prev.billableLimit !== next.billableLimit ||
|
||||
prev.freeAllowance !== next.freeAllowance ||
|
||||
prev.freeRemaining !== next.freeRemaining ||
|
||||
prev.pricePerDocMinor !== next.pricePerDocMinor ||
|
||||
prev.currency !== next.currency ||
|
||||
prev.estimatedBillMinor !== next.estimatedBillMinor ||
|
||||
prev.capUsd !== next.capUsd ||
|
||||
prev.noCap !== next.noCap ||
|
||||
prev.stripeSubscriptionId !== next.stripeSubscriptionId ||
|
||||
prev.spendUnitsThisPeriod !== next.spendUnitsThisPeriod
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
if (prev.recent.length !== next.recent.length) {
|
||||
return next;
|
||||
}
|
||||
if (
|
||||
prev.categoryBreakdown.api !== next.categoryBreakdown.api ||
|
||||
prev.categoryBreakdown.ai !== next.categoryBreakdown.ai ||
|
||||
prev.categoryBreakdown.automation !== next.categoryBreakdown.automation
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
if (prev.members.length !== next.members.length) {
|
||||
return next;
|
||||
}
|
||||
for (let i = 0; i < prev.members.length; i++) {
|
||||
const a = prev.members[i];
|
||||
const b = next.members[i];
|
||||
if (
|
||||
a.userId !== b.userId ||
|
||||
a.name !== b.name ||
|
||||
a.email !== b.email ||
|
||||
a.spendUnits !== b.spendUnits
|
||||
) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
// recent length-mismatch already returned `next` above; content (Wave 1 = []) is identical
|
||||
// otherwise, so reuse the prior reference for stable child memoisation.
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesise a wallet snapshot for the dev preview route. Mirrors the same
|
||||
* shape the backend returns. Subscription state comes from localStorage so
|
||||
* the modal's "mark subscribed" action survives a reload.
|
||||
*/
|
||||
function buildDevPreviewWallet(role: WalletRole): Wallet {
|
||||
const subscribed =
|
||||
typeof window !== "undefined" &&
|
||||
(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "subscribed";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
|
||||
|
||||
return {
|
||||
teamId: null,
|
||||
status: subscribed ? "subscribed" : "free",
|
||||
role,
|
||||
billingPeriodStart: isoDay(periodStart),
|
||||
billingPeriodEnd: isoDay(periodEnd),
|
||||
billableUsed: 62,
|
||||
billableLimit: subscribed ? 1250 : 500,
|
||||
freeAllowance: 500,
|
||||
// One-time grant: a free team has used 62 of 500 (438 left); the dev
|
||||
// subscribed team is shown with its grant fully spent (kept across the
|
||||
// subscribe — it just no longer gates them).
|
||||
freeRemaining: subscribed ? 0 : 438,
|
||||
// Free teams also carry a rate now — the backend resolves it from the
|
||||
// default policy's USD Price so the upgrade-flow cap estimate ("≈ N paid
|
||||
// PDFs/month") can render before subscribing. Mirror that here.
|
||||
pricePerDocMinor: 2,
|
||||
currency: "usd",
|
||||
estimatedBillMinor: subscribed ? 0 : null,
|
||||
capUsd: subscribed ? 25 : null,
|
||||
noCap: false,
|
||||
stripeSubscriptionId: subscribed ? "sub_devpreview" : null,
|
||||
spendUnitsThisPeriod: 62,
|
||||
// Wave 1 backend (PR #6574) returns a per-category breakdown so the
|
||||
// hero panel can split AI / automation / API. Use realistic but
|
||||
// tier-distinguishable mock values so the dev preview shows a
|
||||
// different visual when the localStorage flip toggles subscribed.
|
||||
categoryBreakdown: subscribed
|
||||
? { api: 12, ai: 35, automation: 15 }
|
||||
: { api: 5, ai: 40, automation: 17 },
|
||||
// Members are populated in the leader view by the real backend
|
||||
// (joining team_memberships); the dev preview returns an empty
|
||||
// array — Plan.tsx + PaygLeader still resolve role via wallet.role,
|
||||
// so empty members just hides the sub-caps card.
|
||||
members: [],
|
||||
// Activity feed is V1 = [], the backend ships this in Wave 2 once
|
||||
// payg_meter_event_log is read-accessible from the wallet endpoint.
|
||||
recent: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** True when we're rendered outside the real saas app (e.g. dev preview route). */
|
||||
function isDevPreviewContext(): boolean {
|
||||
// Both checks required: production builds drop the path check, so a real
|
||||
// tenant whose URL begins with /dev/ can't accidentally hit the synthesised
|
||||
// fallback.
|
||||
if (!import.meta.env.DEV) return false;
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.location.pathname.startsWith("/dev/");
|
||||
}
|
||||
|
||||
/** Best-effort role read for dev preview — flips per query string ?role=member. */
|
||||
function devPreviewRole(): WalletRole {
|
||||
if (typeof window === "undefined") return "leader";
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get("role") === "member" ? "member" : "leader";
|
||||
}
|
||||
|
||||
export function useWallet(): UseWalletResult {
|
||||
const devPreview = useRef<boolean>(isDevPreviewContext()).current;
|
||||
|
||||
const [wallet, setWallet] = useState<Wallet | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refetchTick, setRefetchTick] = useState(0);
|
||||
|
||||
// Monotonic request id — used to discard stale responses if a faster
|
||||
// refetch lands first. Only the latest issued id is permitted to commit
|
||||
// its result.
|
||||
const latestReqId = useRef(0);
|
||||
|
||||
// Promise tracking the most recent in-flight load. Mutations await this
|
||||
// so their resolution semantics are "the new state is visible," not
|
||||
// "the request fired." Cleared when no load is pending.
|
||||
const inFlight = useRef<Promise<void> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const reqId = ++latestReqId.current;
|
||||
let cancelled = false;
|
||||
|
||||
const promise = (async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (devPreview) {
|
||||
const synth = buildDevPreviewWallet(devPreviewRole());
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
setWallet((prev) => reuseIfEqual(prev, synth));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiClient.get<Wallet>("/api/v1/payg/wallet");
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
setWallet((prev) => reuseIfEqual(prev, res.data));
|
||||
} catch (e: unknown) {
|
||||
if (cancelled || reqId !== latestReqId.current) return;
|
||||
console.warn("[useWallet] fetch failed", e);
|
||||
setError(e instanceof Error ? e.message : "Failed to load wallet");
|
||||
} finally {
|
||||
if (!cancelled && reqId === latestReqId.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.current = promise;
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
// Don't clear inFlight here — let it resolve so mutations awaiting it
|
||||
// still see a definitive "load completed" point. The reqId guard
|
||||
// upstream ensures stale results don't commit.
|
||||
};
|
||||
}, [devPreview, refetchTick]);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
setRefetchTick((t) => t + 1);
|
||||
// Snapshot the next-tick promise so the caller awaits this refetch
|
||||
// specifically — the in-flight ref will be updated to it on the next
|
||||
// effect run, but we can't reference that synchronously, so settle for
|
||||
// a microtask handoff: await the *current* effect to flush, then await
|
||||
// the new in-flight promise.
|
||||
await Promise.resolve();
|
||||
if (inFlight.current) {
|
||||
await inFlight.current;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markSubscribed = useCallback(
|
||||
async (capUsd: number | null) => {
|
||||
if (devPreview) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, "subscribed");
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
await refetch();
|
||||
return;
|
||||
}
|
||||
const noCap = capUsd === null;
|
||||
// The dev side-channel only exists when the BE mock service is
|
||||
// running (FE-branch local dev). Once the real backend (PR #6574)
|
||||
// is in play, /dev/mark-subscribed is removed and the webhook
|
||||
// (customer.subscription.created) is what flips the team to
|
||||
// subscribed. We swallow 404s so the modal's completion path —
|
||||
// which awaits this promise before rendering the confirmation
|
||||
// screen — doesn't error out on a perfectly normal "the real
|
||||
// backend doesn't expose this dev hook" response. A subsequent
|
||||
// refetch picks up the webhook-driven flip whenever it lands.
|
||||
try {
|
||||
await apiClient.post("/api/v1/payg/dev/mark-subscribed", {
|
||||
capUsd: capUsd ?? 0,
|
||||
noCap,
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
const status =
|
||||
typeof e === "object" && e !== null && "response" in e
|
||||
? (e as { response?: { status?: number } }).response?.status
|
||||
: undefined;
|
||||
if (status === 404) {
|
||||
// Real BE in play — webhook will land the subscription
|
||||
// state; log and continue. Loud-but-harmless so the dev
|
||||
// notices their /dev/mark-subscribed isn't wired up.
|
||||
console.info(
|
||||
"[useWallet] /dev/mark-subscribed not available (404) — relying on Stripe webhook to flip subscription state",
|
||||
);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await refetch();
|
||||
},
|
||||
[devPreview, refetch],
|
||||
);
|
||||
|
||||
const updateCap = useCallback(
|
||||
async (capUsd: number | null) => {
|
||||
const noCap = capUsd === null;
|
||||
if (devPreview) {
|
||||
await refetch();
|
||||
return;
|
||||
}
|
||||
await apiClient.patch("/api/v1/payg/cap", {
|
||||
capUsd: capUsd ?? 0,
|
||||
noCap,
|
||||
});
|
||||
await refetch();
|
||||
},
|
||||
[devPreview, refetch],
|
||||
);
|
||||
|
||||
const openPortal = useCallback(async () => {
|
||||
if (devPreview) {
|
||||
// No real Stripe in dev preview — navigate to a placeholder so the
|
||||
// click still feels alive. Same-tab to match the real-flow behaviour.
|
||||
window.location.assign("https://billing.stripe.com/p/login/mock");
|
||||
return;
|
||||
}
|
||||
// Direct edge-fn invocation with the user's JWT — same pattern as
|
||||
// create-checkout-session. The payg_get_checkout_context RPC inside the
|
||||
// fn enforces team membership, so no backend proxy is needed; team_id
|
||||
// must be a NUMBER (the fn type-checks and rejects strings).
|
||||
const teamId = wallet?.teamId;
|
||||
if (teamId == null) {
|
||||
throw new Error("No team resolved yet");
|
||||
}
|
||||
const { data, error: invokeError } = await supabase.functions.invoke<{
|
||||
url?: string;
|
||||
error?: string;
|
||||
}>("create-customer-portal-session", {
|
||||
body: { team_id: teamId, return_url: window.location.href },
|
||||
});
|
||||
if (invokeError) {
|
||||
// FunctionsHttpError etc. — the StripePortalLink caller catches and
|
||||
// shows a friendly toast (404 team_not_subscribed, 403, outage).
|
||||
throw invokeError;
|
||||
}
|
||||
if (!data?.url) {
|
||||
throw new Error(data?.error ?? "Portal session response missing url");
|
||||
}
|
||||
// Same-tab navigation rather than window.open(...,"_blank"): Stripe's
|
||||
// customer portal is a full-page experience and brings the user back
|
||||
// via return_url, so a popup buys us nothing — and window.open after
|
||||
// an awaited promise is treated as non-user-gesture and silently
|
||||
// popup-blocked by most browsers.
|
||||
window.location.assign(data.url);
|
||||
}, [devPreview, wallet?.teamId]);
|
||||
|
||||
return {
|
||||
wallet,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
markSubscribed,
|
||||
updateCap,
|
||||
openPortal,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { supabase } from "@app/auth/supabase";
|
||||
import { handleHttpError } from "@app/services/httpErrorHandler";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import {
|
||||
classifyPaygError,
|
||||
handlePaygError,
|
||||
} from "@app/services/paygErrorInterceptor";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
|
||||
// Global credit update callback - will be set by the AuthProvider
|
||||
@@ -159,12 +163,6 @@ apiClient.interceptors.response.use(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.config?.url?.includes("/api/v1/credits")) {
|
||||
console.debug(
|
||||
"[API Client] Credits endpoint response headers:",
|
||||
response.headers,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
@@ -174,6 +172,32 @@ apiClient.interceptors.response.use(
|
||||
originalRequest.url?.includes(endpoint),
|
||||
);
|
||||
|
||||
// PAYG entitlement errors come from the EntitlementGuard on the server
|
||||
// and have specific sentinels in the response body that we want to
|
||||
// recognise *before* the generic 401/401-refresh logic kicks in:
|
||||
//
|
||||
// 402 FEATURE_DEGRADED — free-tier monthly cap exhausted; show a
|
||||
// toast nudging the user to the Plan tab to upgrade.
|
||||
// 401 SIGNUP_REQUIRED — anonymous user hit a billable endpoint;
|
||||
// show a "Sign up to use [category]" modal instead of redirecting
|
||||
// to /login (which is the default 401 behaviour). The user IS
|
||||
// authenticated as anonymous — refreshing their session wouldn't
|
||||
// unlock the endpoint, only signing up will.
|
||||
//
|
||||
// We classify the error here. If it matches either sentinel, we
|
||||
// surface the appropriate UI and short-circuit the rest of the
|
||||
// response interceptor so:
|
||||
// - 401 SIGNUP_REQUIRED won't trigger the session-refresh / redirect-
|
||||
// to-login dance below.
|
||||
// - The handleHttpError() generic toast at the bottom won't fire.
|
||||
// The error itself is still propagated to the caller so any
|
||||
// component-level catch can react if needed.
|
||||
const paygKind = classifyPaygError(error);
|
||||
if (paygKind !== null) {
|
||||
handlePaygError(paygKind, error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// On a first 401, refresh and retry — public endpoints included, since an
|
||||
// expired Bearer token is rejected on any route during cold load.
|
||||
if (status === 401 && !originalRequest._retry) {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the toast layer and openPlanSettings so we can assert what the
|
||||
// handler dispatches without needing a real DOM context for the toast
|
||||
// portal. Mocks are hoisted by vitest so the module under test imports
|
||||
// these in place of the real implementations.
|
||||
vi.mock("@app/components/toast", () => ({
|
||||
alert: vi.fn(),
|
||||
}));
|
||||
vi.mock("@app/utils/appSettings", () => ({
|
||||
openPlanSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
import { alert } from "@app/components/toast";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
import {
|
||||
classifyPaygError,
|
||||
extractSignupCategory,
|
||||
handlePaygError,
|
||||
} from "@app/services/paygErrorInterceptor";
|
||||
|
||||
describe("classifyPaygError", () => {
|
||||
it("returns FEATURE_DEGRADED for 402 + error sentinel", () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 402,
|
||||
data: {
|
||||
error: "FEATURE_DEGRADED",
|
||||
missingGates: ["AUTOMATION"],
|
||||
state: "DEGRADED",
|
||||
periodEnd: "2026-06-30",
|
||||
capUnits: 500,
|
||||
spendUnits: 500,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(classifyPaygError(err)).toBe("FEATURE_DEGRADED");
|
||||
});
|
||||
|
||||
it("returns SIGNUP_REQUIRED for 401 + error sentinel", () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: "SIGNUP_REQUIRED", category: "AI" },
|
||||
},
|
||||
};
|
||||
expect(classifyPaygError(err)).toBe("SIGNUP_REQUIRED");
|
||||
});
|
||||
|
||||
it("returns null for plain 401 (session expired path is untouched)", () => {
|
||||
const err = {
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: "Unauthorized" },
|
||||
},
|
||||
};
|
||||
expect(classifyPaygError(err)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for 402 without the FEATURE_DEGRADED sentinel", () => {
|
||||
const err = {
|
||||
response: { status: 402, data: { error: "Payment required" } },
|
||||
};
|
||||
expect(classifyPaygError(err)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when status mismatches sentinel (defence-in-depth)", () => {
|
||||
// 500 + FEATURE_DEGRADED shouldn't match — server may have a bug and
|
||||
// we don't want a generic 500 to silently degrade into an upgrade
|
||||
// prompt. Same for 403 + SIGNUP_REQUIRED.
|
||||
const a = {
|
||||
response: { status: 500, data: { error: "FEATURE_DEGRADED" } },
|
||||
};
|
||||
const b = {
|
||||
response: { status: 403, data: { error: "SIGNUP_REQUIRED" } },
|
||||
};
|
||||
expect(classifyPaygError(a)).toBeNull();
|
||||
expect(classifyPaygError(b)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for malformed errors (null / non-object / no response)", () => {
|
||||
expect(classifyPaygError(null)).toBeNull();
|
||||
expect(classifyPaygError(undefined)).toBeNull();
|
||||
expect(classifyPaygError("oops")).toBeNull();
|
||||
expect(classifyPaygError({})).toBeNull();
|
||||
expect(classifyPaygError({ response: null })).toBeNull();
|
||||
expect(classifyPaygError({ response: { status: 402 } })).toBeNull();
|
||||
expect(
|
||||
classifyPaygError({ response: { status: 402, data: null } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyPaygError({ response: { status: 402, data: "bare-string" } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
classifyPaygError({ response: { status: 402, data: { error: 123 } } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSignupCategory", () => {
|
||||
it("returns the category string when present", () => {
|
||||
expect(
|
||||
extractSignupCategory({
|
||||
response: { data: { error: "SIGNUP_REQUIRED", category: "AI" } },
|
||||
}),
|
||||
).toBe("AI");
|
||||
});
|
||||
|
||||
it("returns null when missing or wrong type", () => {
|
||||
expect(extractSignupCategory(null)).toBeNull();
|
||||
expect(extractSignupCategory({})).toBeNull();
|
||||
expect(
|
||||
extractSignupCategory({ response: { data: { category: 7 } } }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
extractSignupCategory({ response: { data: {} } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePaygError", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows the persistent upgrade toast on FEATURE_DEGRADED", () => {
|
||||
handlePaygError("FEATURE_DEGRADED", {
|
||||
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
|
||||
});
|
||||
expect(alert).toHaveBeenCalledTimes(1);
|
||||
const opts = vi.mocked(alert).mock.calls[0][0];
|
||||
expect(opts.alertType).toBe("warning");
|
||||
expect(opts.isPersistentPopup).toBe(true);
|
||||
expect(opts.buttonText).toBe("Go to billing");
|
||||
// Body should reference the 500-op free monthly allowance so the
|
||||
// user understands what they hit.
|
||||
expect(String(opts.body)).toMatch(/500/);
|
||||
});
|
||||
|
||||
it("invoking the toast's buttonCallback opens the Plan settings tab", () => {
|
||||
handlePaygError("FEATURE_DEGRADED", {
|
||||
response: { status: 402, data: { error: "FEATURE_DEGRADED" } },
|
||||
});
|
||||
const opts = vi.mocked(alert).mock.calls[0][0];
|
||||
expect(opts.buttonCallback).toBeDefined();
|
||||
opts.buttonCallback?.();
|
||||
expect(openPlanSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dispatches payg:signupRequired on SIGNUP_REQUIRED with category in detail", () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener("payg:signupRequired", handler);
|
||||
try {
|
||||
handlePaygError("SIGNUP_REQUIRED", {
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: "SIGNUP_REQUIRED", category: "AUTOMATION" },
|
||||
},
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const ev = handler.mock.calls[0][0] as CustomEvent;
|
||||
expect(ev.detail).toEqual({ category: "AUTOMATION" });
|
||||
// No toast for SIGNUP_REQUIRED — the modal carries the message.
|
||||
expect(alert).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener("payg:signupRequired", handler);
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches with category=null when the body has no category", () => {
|
||||
const handler = vi.fn();
|
||||
window.addEventListener("payg:signupRequired", handler);
|
||||
try {
|
||||
handlePaygError("SIGNUP_REQUIRED", {
|
||||
response: { status: 401, data: { error: "SIGNUP_REQUIRED" } },
|
||||
});
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
const ev = handler.mock.calls[0][0] as CustomEvent;
|
||||
expect(ev.detail).toEqual({ category: null });
|
||||
} finally {
|
||||
window.removeEventListener("payg:signupRequired", handler);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Classifies and reacts to PAYG-specific error responses surfaced by the
|
||||
* backend's {@code EntitlementGuard} (Wave 1 BE on PR #6574). Two sentinels
|
||||
* are recognised:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code 402 FEATURE_DEGRADED} — free-tier user has burned through
|
||||
* their 500-op monthly allowance. Surface a toast that nudges them to
|
||||
* the Plan tab so they can upgrade.</li>
|
||||
* <li>{@code 401 SIGNUP_REQUIRED} — anonymous (guest) user hit a billable
|
||||
* endpoint. Open a modal explaining why they need a real account and
|
||||
* where their 500-op free monthly allowance comes in. The body's
|
||||
* {@code category} field ({@code AI}, {@code AUTOMATION}, {@code API})
|
||||
* feeds the modal title so the user understands *which* feature they
|
||||
* just hit. We dispatch a {@code CustomEvent} rather than rendering
|
||||
* directly from this module because the apiClient is created outside
|
||||
* the React tree and can't import JSX; the listener lives on a
|
||||
* bootstrap component mounted near the app root.</li>
|
||||
* </ul>
|
||||
*
|
||||
* The classifier is exported separately from the handler so unit tests can
|
||||
* exercise the parsing logic without touching the toast / event side
|
||||
* effects.
|
||||
*/
|
||||
import { alert } from "@app/components/toast";
|
||||
import i18n from "@app/i18n";
|
||||
import { openPlanSettings } from "@app/utils/appSettings";
|
||||
|
||||
/**
|
||||
* Possible PAYG entitlement sentinels the EntitlementGuard returns.
|
||||
* {@code null} when the error is not a PAYG entitlement response.
|
||||
*/
|
||||
export type PaygErrorKind = "FEATURE_DEGRADED" | "SIGNUP_REQUIRED";
|
||||
|
||||
/**
|
||||
* Detail payload broadcast on {@code payg:signupRequired} when an anonymous
|
||||
* user hits a billable endpoint. The listener (a Bootstrap component near
|
||||
* the app root) opens a modal whose copy is parameterised by
|
||||
* {@link #category}.
|
||||
*/
|
||||
export interface PaygSignupRequiredDetail {
|
||||
/** Category that triggered the gate — {@code AI}, {@code AUTOMATION}, or {@code API}. */
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect an axios-style error and decide whether it's one of the known
|
||||
* PAYG sentinels. Returns the kind, or {@code null} if it isn't.
|
||||
*
|
||||
* The check is intentionally strict (status code AND body.error sentinel)
|
||||
* so we don't hijack incidental 401/402 responses from other endpoints —
|
||||
* notably the existing session-expired 401 flow.
|
||||
*/
|
||||
export function classifyPaygError(error: unknown): PaygErrorKind | null {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const response = (error as { response?: unknown }).response;
|
||||
if (!response || typeof response !== "object") return null;
|
||||
const status = (response as { status?: unknown }).status;
|
||||
const data = (response as { data?: unknown }).data;
|
||||
if (typeof status !== "number") return null;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const sentinel = (data as { error?: unknown }).error;
|
||||
if (typeof sentinel !== "string") return null;
|
||||
|
||||
if (status === 402 && sentinel === "FEATURE_DEGRADED") {
|
||||
return "FEATURE_DEGRADED";
|
||||
}
|
||||
if (status === 401 && sentinel === "SIGNUP_REQUIRED") {
|
||||
return "SIGNUP_REQUIRED";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Extract {@code data.category} (a string) from an axios error, or {@code null}. */
|
||||
export function extractSignupCategory(error: unknown): string | null {
|
||||
if (!error || typeof error !== "object") return null;
|
||||
const response = (error as { response?: unknown }).response;
|
||||
if (!response || typeof response !== "object") return null;
|
||||
const data = (response as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const category = (data as { category?: unknown }).category;
|
||||
return typeof category === "string" ? category : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface the appropriate UI for a classified PAYG error. Toast for
|
||||
* {@code FEATURE_DEGRADED}, modal-via-CustomEvent for {@code SIGNUP_REQUIRED}.
|
||||
*
|
||||
* Idempotent / safe to call multiple times — the toast layer coalesces
|
||||
* duplicates by (alertType, title, body) and the modal listener already
|
||||
* dedupes by its own opened-state. Suppress-respecting: if the caller
|
||||
* passed {@code suppressErrorToast: true} on the axios config (the
|
||||
* established pattern for component-level error handling), we still fire
|
||||
* the PAYG UI because these are user-facing gates, not transient
|
||||
* error toasts — the suppression flag was for the *generic* error toast,
|
||||
* which we're replacing with something more actionable.
|
||||
*/
|
||||
export function handlePaygError(kind: PaygErrorKind, error: unknown): void {
|
||||
if (kind === "FEATURE_DEGRADED") {
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: i18n.t(
|
||||
"payg.exhausted.title",
|
||||
"You've hit your free monthly limit",
|
||||
),
|
||||
body: i18n.t(
|
||||
"payg.exhausted.body",
|
||||
"You've used your free 500 operations this month. Upgrade to Processor to keep going.",
|
||||
),
|
||||
buttonText: i18n.t("payg.exhausted.cta", "Go to billing"),
|
||||
buttonCallback: () => openPlanSettings(),
|
||||
isPersistentPopup: true,
|
||||
location: "bottom-right",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "SIGNUP_REQUIRED") {
|
||||
const category = extractSignupCategory(error);
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<PaygSignupRequiredDetail>("payg:signupRequired", {
|
||||
detail: { category },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// SSR / test environments without a real window — no-op.
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user