From cf513c255baf551b7c29fb713b69f1554ff21fff Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:56:01 +0100 Subject: [PATCH] =?UTF-8?q?PAYG:=20pay-as-you-go=20billing=20=E2=80=94=20m?= =?UTF-8?q?etered=20automation/AI/API=20+=20one-time=20free=20grant=20(#65?= =?UTF-8?q?89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../common/service/InternalApiClient.java | 15 + .../common/service/InternalApiClientTest.java | 47 ++ .../proprietary/security/model/User.java | 6 +- .../ai/controller/AiCreateController.java | 3 + .../AiCreateInternalController.java | 3 + .../saas/ai/controller/AiProxyController.java | 3 + .../saas/config/CreditInterceptorConfig.java | 4 +- .../saas/controller/CreditController.java | 3 +- .../saas/interceptor/CreditErrorAdvice.java | 4 +- .../saas/interceptor/CreditSuccessAdvice.java | 4 +- .../interceptor/UnifiedCreditInterceptor.java | 4 +- .../software/saas/payg/api/CapMoneyUnits.java | 56 ++ .../saas/payg/api/PaygWalletController.java | 424 ++++++++++ .../saas/payg/api/WalletSnapshotResponse.java | 101 +++ .../saas/payg/billing/TeamBillingContext.java | 47 ++ .../saas/payg/billing/TeamBillingService.java | 267 ++++++ .../software/saas/payg/cap/CapEvaluator.java | 104 +++ .../saas/payg/cap/RequiresFeature.java | 46 + .../saas/payg/charge/ChargeContext.java | 15 +- .../saas/payg/charge/JobChargeService.java | 259 +++++- .../payg/entitlement/EntitlementGuard.java | 347 ++++++++ .../payg/entitlement/EntitlementService.java | 182 ++++ .../payg/entitlement/EntitlementSnapshot.java | 46 + .../payg/filter/PaygChargeInterceptor.java | 126 ++- .../saas/payg/filter/PaygWebMvcConfig.java | 22 + .../saas/payg/job/StaleJobCloser.java | 51 +- .../saas/payg/meter/PaygMeterEventLog.java | 69 ++ .../meter/PaygMeterReconcileScheduler.java | 139 +++ .../payg/meter/PaygMeterReportingService.java | 221 +++++ .../saas/payg/model/BillingCategory.java | 18 + .../saas/payg/policy/PaygTeamExtensions.java | 10 + .../saas/payg/policy/PricingPolicy.java | 18 +- .../PaygMeterEventLogRepository.java | 81 ++ .../PaygShadowChargeRepository.java | 16 + .../PaygTeamExtensionsRepository.java | 28 + .../repository/WalletLedgerRepository.java | 43 +- .../saas/payg/shadow/PaygShadowCharge.java | 29 + .../payg/stripe/StripeSubscriptionDao.java | 215 +++++ .../saas/payg/wallet/WalletLedgerEntry.java | 10 + .../repository/TeamMembershipRepository.java | 18 + .../saas/repository/UserCreditRepository.java | 10 +- .../main/resources/application-dev.properties | 7 + .../resources/application-saas.properties | 25 + .../saas/V16__payg_billing_category.sql | 58 ++ .../V17__merge_supabase_id_into_auth_id.sql | 34 + .../saas/V19__payg_lifetime_free_grant.sql | 95 +++ .../saas/V20__payg_launch_free_grant.sql | 42 + ...V21__drop_wallet_category_summary_view.sql | 11 + .../payg/api/PaygWalletControllerTest.java | 489 +++++++++++ .../saas/payg/cap/CapEvaluatorTest.java | 150 ++++ .../RequiresFeatureAnnotationRolloutTest.java | 52 ++ .../payg/charge/JobChargeServiceTest.java | 548 +++++++++++- .../entitlement/EntitlementGuardTest.java | 568 +++++++++++++ .../entitlement/EntitlementServiceTest.java | 271 ++++++ .../filter/PaygChargeInterceptorTest.java | 302 ++++++- .../saas/payg/job/StaleJobCloserTest.java | 67 +- .../PaygMeterReconcileSchedulerTest.java | 121 +++ .../meter/PaygMeterReportingServiceTest.java | 245 ++++++ .../payg/model/PaygEntitiesSmokeTest.java | 35 + .../public/locales/en-GB/translation.toml | 245 ++++++ .../core/components/shared/config/types.ts | 1 + frontend/editor/src/core/types/appConfig.ts | 1 + frontend/editor/src/saas/App.tsx | 2 + frontend/editor/src/saas/auth/UseSession.tsx | 79 +- .../components/SignupRequiredBootstrap.tsx | 129 +++ .../saas/components/shared/AppConfigModal.tsx | 35 +- .../components/shared/ManageBillingButton.tsx | 5 +- .../shared/config/configSections/Payg.css | 624 ++++++++++++++ .../shared/config/configSections/Payg.tsx | 788 ++++++++++++++++++ .../shared/config/configSections/PaygFree.css | 322 +++++++ .../shared/config/configSections/PaygFree.tsx | 374 +++++++++ .../shared/config/configSections/Plan.tsx | 294 ++----- .../config/configSections/SpendCapControl.css | 159 ++++ .../config/configSections/SpendCapControl.tsx | 245 ++++++ .../configSections/StripeCheckoutPanel.tsx | 314 +++++++ .../config/configSections/UpgradeModal.css | 440 ++++++++++ .../config/configSections/UpgradeModal.tsx | 532 ++++++++++++ .../apiKeys/hooks/useCredits.ts | 73 +- .../configSections/plan/ActivePlanSection.tsx | 143 ---- .../plan/ApiPackagesSection.tsx | 126 --- .../plan/AvailablePlansSection.tsx | 148 ---- .../config/configSections/plan/PlanCard.tsx | 113 --- .../shared/config/saasConfigNavSections.tsx | 9 +- .../editor/src/saas/hooks/useCreditCheck.ts | 77 +- .../editor/src/saas/hooks/useRenderCount.ts | 34 + frontend/editor/src/saas/hooks/useWallet.ts | 526 ++++++++++++ .../editor/src/saas/services/apiClient.ts | 36 +- .../services/paygErrorInterceptor.test.ts | 184 ++++ .../src/saas/services/paygErrorInterceptor.ts | 131 +++ 89 files changed, 11389 insertions(+), 1034 deletions(-) create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java create mode 100644 app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql create mode 100644 app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java create mode 100644 app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java create mode 100644 frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/Payg.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/SpendCapControl.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css create mode 100644 frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx delete mode 100644 frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx create mode 100644 frontend/editor/src/saas/hooks/useRenderCount.ts create mode 100644 frontend/editor/src/saas/hooks/useWallet.ts create mode 100644 frontend/editor/src/saas/services/paygErrorInterceptor.test.ts create mode 100644 frontend/editor/src/saas/services/paygErrorInterceptor.ts diff --git a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java index f72eca14d..c4663c769 100644 --- a/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java +++ b/app/common/src/main/java/stirling/software/common/service/InternalApiClient.java @@ -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> entity = new HttpEntity<>(body, headers); RequestCallback requestCallback = restTemplate.httpEntityCallback(entity, Resource.class); diff --git a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java index 06543e47b..7bab14188 100644 --- a/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java +++ b/app/common/src/test/java/stirling/software/common/service/InternalApiClientTest.java @@ -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 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 body = new LinkedMultiValueMap<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index e39b1a330..2b2d22cfc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -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") diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 848d596d8..9f279a6b8 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -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 { diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index bf2e4894e..60c8dc461 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -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 { diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java index 8b6ae714a..60549208d 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiProxyController.java @@ -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 diff --git a/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java b/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java index 978e9b34f..2cce36a5a 100644 --- a/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/config/CreditInterceptorConfig.java @@ -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 { diff --git a/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java b/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java index 2c1be26f4..6f33554fc 100644 --- a/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java +++ b/app/saas/src/main/java/stirling/software/saas/controller/CreditController.java @@ -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 diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java index 280f8be6d..24c73ac78 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditErrorAdvice.java @@ -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 { diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java index 51e82b4db..e76e06e3c 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/CreditSuccessAdvice.java @@ -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 { diff --git a/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java b/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java index 631217e1c..f50c1df0f 100644 --- a/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/interceptor/UnifiedCreditInterceptor.java @@ -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 { diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java b/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java new file mode 100644 index 000000000..7ecad6a8b --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/CapMoneyUnits.java @@ -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}. + * + *

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. + * + *

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. + * + *

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; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java new file mode 100644 index 000000000..ee2de60c1 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -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. + * + *

{@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. + * + *

{@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. + * + *

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 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 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 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 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 buildActivity(Long teamId) { + List 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 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 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 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 primaryMembership(Long userId) { + List rows = memberRepo.findPrimaryMembership(userId); + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + } + + private List buildMemberRows( + Long teamId, LocalDateTime periodStart, LocalDateTime periodEnd) { + List all = memberRepo.findByTeamId(teamId); + if (all.isEmpty()) { + return List.of(); + } + LocalDateTime[] window = {periodStart, periodEnd}; + List 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()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java new file mode 100644 index 000000000..3a70cecb8 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/WalletSnapshotResponse.java @@ -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. + * + *

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 members, + List 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) {} +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java new file mode 100644 index 000000000..52cb15bb2 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingContext.java @@ -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: + * + *

    + *
  • the one-time lifetime free grant ({@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; + *
  • the monthly billing window ({@link #periodStart}/{@link #periodEnd}) and the + * optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice + * + cap only. + *
+ * + * @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) {} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java new file mode 100644 index 000000000..9a697392d --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/billing/TeamBillingService.java @@ -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. + * + *

Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant): + * + *

    + *
  • Free grant — 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. + *
  • Monthly window + cap — 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). + *
+ * + *

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 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 extOpt = extensionsRepository.findById(teamId); + Optional 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 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 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. + * + *

    + *
  • not subscribed → null (the free grant, not a money cap, is what bounds them); + *
  • subscribed, no money cap → uncapped (null), unless an admin set raw {@code cap_units}; + *
  • subscribed, money cap + known rate → {@code floor(capMoney / perDocRate)}; + *
  • subscribed, money cap but rate unknown → stored {@code cap_units} fallback (WARN). + *
+ */ + 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 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 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() + }; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java new file mode 100644 index 000000000..43819f3a3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/CapEvaluator.java @@ -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. + * + *

State transitions (matching {@code notes/PAYG_DESIGN.md} §3.6): + * + *

    + *
  • {@code capUnits == null} → {@code FULL} / {@link FeatureSet#FULL} unconditionally. + *
  • {@code spend / cap < warnPct} → {@code FULL}. + *
  • MINIMAL semantics: 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. + *
  • {@code warnPct ≤ spend / cap < degradePct} → {@code WARNED}; feature set still {@link + * FeatureSet#FULL} — the warn band is a notification trigger, not a degradation. + *
  • {@code spend / cap ≥ degradePct} → {@code DEGRADED}; feature set drops to the policy's + * configured {@code degradedFeatureSet} (default {@link FeatureSet#MINIMAL}). + *
+ * + *

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 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 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)); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java b/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java new file mode 100644 index 000000000..97a61d0f3 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/cap/RequiresFeature.java @@ -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. + * + *

The annotation is not required on every endpoint. The guard's default rule is: + * + *

    + *
  • {@code @RequiresFeature} present → use exactly those gates. + *
  • No annotation, but the method has {@code @AutoJobPostMapping} → assume {@link + * FeatureGate#OFFSITE_PROCESSING}. + *
  • Neither → skip (admin endpoints, info, config — these don't accrue charges and shouldn't + * degrade). + *
+ * + *

So the only endpoints that need this annotation explicitly are those whose gate is + * different 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. + * + *

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}). + * + *

{@code
+ * @RequiresFeature(FeatureGate.AUTOMATION)
+ * @AutoJobPostMapping("/pipeline")
+ * public ResponseEntity<...> runPipeline(@ModelAttribute PipelineRequest req) { ... }
+ * }
+ */ +@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(); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java index ff4c94a13..f5914f853 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/ChargeContext.java @@ -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. + * + *

{@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"); + } } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java index e5832bfd6..37875060e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/charge/JobChargeService.java @@ -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 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. + * + *

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. + * + *

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::close}) makes + * the two paths dedup at Stripe, so a job metered at completion isn't billed again when it's + * later stale-closed. + * + *

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}. + * + *

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 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 diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java new file mode 100644 index 000000000..1c2ad28dc --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -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}. + * + *

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. + * + *

Decision matrix: + * + * + * + * + * + * + * + *
authrequired gatessnapshot enabled?outcome
anonymousAUTOMATION or AI_SUPPORTn/a401 SIGNUP_REQUIRED
anonymousOFFSITE_PROCESSING / CLIENT_SIDEn/a200 (pass through)
authenticatedrequired ⊆ enabledyes200
authenticatedrequired ⊄ enabledno402 FEATURE_DEGRADED
+ * + *

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 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 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 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 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 missingGates(FeatureGate[] required, List enabled) { + List 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 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(); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java new file mode 100644 index 000000000..397a714bf --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementService.java @@ -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. + * + *

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 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. + * + *

{@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 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}; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java new file mode 100644 index 000000000..b3d44f3c7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementSnapshot.java @@ -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}. + * + *

Contrast with {@link WalletEntitlementSnapshot}: the JPA entity is the persisted + * snapshot that the recompute path writes (one row per team, optionally per member). This record is + * the computed-now 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 enabledGates, + long periodSpendUnits, + Long periodCapUnits, + LocalDateTime periodStart, + LocalDateTime periodEnd, + boolean subscribed) { + + public boolean isDegraded() { + return state == EntitlementState.DEGRADED; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java index 66aaf5ca7..1006893e3 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygChargeInterceptor.java @@ -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. * - *

{@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}. + *

{@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. * *

{@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}). + * + *

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 diff --git a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java index 8b29874bc..30dd5a4ba 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/filter/PaygWebMvcConfig.java @@ -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 @@ -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); } } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java b/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java index 6c4813988..432965aa7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/job/StaleJobCloser.java @@ -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. + * + *

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. + * + *

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::close}) makes a re-run on the next + * tick safe even if a close half-completed. * *

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 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); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java new file mode 100644 index 000000000..e19cf522e --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterEventLog.java @@ -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 pending ({@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. + * + *

{@code idempotency_key} is UNIQUE and identical to the key sent to Stripe ({@code + * process::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; +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java new file mode 100644 index 000000000..81cde551f --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReconcileScheduler.java @@ -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 same idempotency key, so Stripe dedups rather than double-charging. + * + *

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 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 teamIds = + retryable.stream().map(PaygMeterEventLog::getTeamId).distinct().toList(); + Map 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()); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java new file mode 100644 index 000000000..e578edd15 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/meter/PaygMeterReportingService.java @@ -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. + * + *

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. + * + *

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. + * + *

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::close"}) so a retry, a reconcile replay, or a double-fire from two + * pods never charges twice. + * + *

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. + * + *

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 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 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); + } + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java b/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java new file mode 100644 index 000000000..2b1c0fee7 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/model/BillingCategory.java @@ -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. + * + *

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. + * + *

Listing order matters only as the default sentinel ({@link #BYPASSED} first); no downstream + * relies on {@code ordinal()}. + */ +public enum BillingCategory { + BYPASSED, + API, + AI, + AUTOMATION +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java index 7d6f1b402..c564f8556 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PaygTeamExtensions.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java index 743dcdc8a..958117736 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/policy/PricingPolicy.java @@ -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. - * - *

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 diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java new file mode 100644 index 000000000..7d752bd14 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygMeterEventLogRepository.java @@ -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 { + + /** + * 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 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); +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java index e953285ee..8a2911f9c 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygShadowChargeRepository.java @@ -29,4 +29,20 @@ public interface PaygShadowChargeRepository extends JpaRepository 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); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java index 3473eeeff..35fb6bbf7 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/PaygTeamExtensionsRepository.java @@ -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 { Optional 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 for the same team 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 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); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java index 2a2a00342..095f6cb92 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/repository/WalletLedgerRepository.java @@ -14,7 +14,30 @@ import stirling.software.saas.payg.wallet.WalletLedgerEntry; @Repository public interface WalletLedgerRepository extends JpaRepository { - List findByTeamIdOrderByOccurredAtDesc(Long teamId); + /** Most recent entries for the Plan page activity feed. */ + List 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 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= :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" diff --git a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java index b2b8ed0c2..57aea1a0e 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/shadow/PaygShadowCharge.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java new file mode 100644 index 000000000..9c0f3f2b4 --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/payg/stripe/StripeSubscriptionDao.java @@ -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). + * + *

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. + * + *

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 findBilling(String subscriptionId) { + if (subscriptionId == null || subscriptionId.isBlank()) { + return Optional.empty(); + } + try { + List 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). + * + *

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 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 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()); + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java index 90aed63cd..4971e296f 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/wallet/WalletLedgerEntry.java @@ -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; diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java index b3231224d..d88d96cfc 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java @@ -42,6 +42,24 @@ public interface TeamMembershipRepository extends JpaRepository 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. + * + *

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 findPrimaryMembership(@Param("userId") Long userId); + /** * Find all members with a specific role in a team * diff --git a/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java b/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java index 49a0042b1..c01f3d034 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java +++ b/app/saas/src/main/java/stirling/software/saas/repository/UserCreditRepository.java @@ -91,7 +91,7 @@ public interface UserCreditRepository extends JpaRepository { + " 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 { + " 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 { + " 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 { 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 { 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); } diff --git a/app/saas/src/main/resources/application-dev.properties b/app/saas/src/main/resources/application-dev.properties index 725fe79fe..ee8bf80ff 100644 --- a/app/saas/src/main/resources/application-dev.properties +++ b/app/saas/src/main/resources/application-dev.properties @@ -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 diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 3676ad7d8..71e65de65 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -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 diff --git a/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql new file mode 100644 index 000000000..f78458a1b --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V16__payg_billing_category.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql b/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql new file mode 100644 index 000000000..cc6b5d033 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V17__merge_supabase_id_into_auth_id.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql new file mode 100644 index 000000000..c2743f0e9 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V19__payg_lifetime_free_grant.sql @@ -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)); diff --git a/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql b/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql new file mode 100644 index 000000000..d9dc756e3 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V20__payg_launch_free_grant.sql @@ -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; diff --git a/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql b/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql new file mode 100644 index 000000000..857e0bfa6 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V21__drop_wallet_category_summary_view.sql @@ -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; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java new file mode 100644 index 000000000..8433f5e5a --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -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 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 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 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("alice@example.com"); + 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 resp = + controller.getWallet(jwtAuth(leader.getSupabaseId())); + + WalletSnapshotResponse body = resp.getBody(); + assertThat(body.role()).isEqualTo("leader"); + assertThat(body.members()).hasSize(2); + MemberRow secondMember = + body.members().stream() + .filter(m -> "alice".equals(m.name())) + .findFirst() + .orElseThrow(); + assertThat(secondMember.email()).isEqualTo("alice@example.com"); + assertThat(secondMember.spendUnits()).isEqualTo(42); + } + + @Test + void getWallet_anonymousIsRejected() { + Authentication anon = + new AnonymousAuthenticationToken( + "k", + "anonymousUser", + List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))); + + ResponseEntity 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 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 resp = + controller.updateCap( + new UpdateCapRequest(40, false), jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + ArgumentCaptor 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 resp = + controller.updateCap( + new UpdateCapRequest(28, false), jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + ArgumentCaptor 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 resp = + controller.updateCap( + new UpdateCapRequest(0, true), jwtAuth(leader.getSupabaseId())); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + ArgumentCaptor 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 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 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 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", "user@example.com") + .build(); + return new EnhancedJwtAuthenticationToken( + jwt, List.of(), "user@example.com", supabaseId.toString()); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java new file mode 100644 index 000000000..2118f8579 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/cap/CapEvaluatorTest.java @@ -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(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java b/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java new file mode 100644 index 000000000..c2d5fcd8b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/cap/RequiresFeatureAnnotationRolloutTest.java @@ -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. + * + *

Out of scope: {@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); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java index 29e7041bf..9a8c7848f 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/charge/JobChargeServiceTest.java @@ -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 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 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 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 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 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 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 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 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 stepLimits) { diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java new file mode 100644 index 000000000..395ad95b9 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -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 headers = new HashMap<>(); + headers.put("alg", "RS256"); + Map claims = new HashMap<>(); + claims.put("sub", supabaseId.toString()); + claims.put("email", "user@example.com"); + Jwt jwt = new Jwt("token", Instant.now(), Instant.now().plusSeconds(3600), headers, claims); + return new EnhancedJwtAuthenticationToken( + jwt, + List.of(new SimpleGrantedAuthority("ROLE_USER")), + "user@example.com", + supabaseId.toString()); + } + + private static HandlerMethod handlerFor(String methodName) throws NoSuchMethodException { + Method m = TestController.class.getDeclaredMethod(methodName); + return new HandlerMethod(new TestController(), m); + } + + private static HandlerMethod handlerForClassLevel(String methodName) + throws NoSuchMethodException { + Method m = ClassLevelAiController.class.getDeclaredMethod(methodName); + return new HandlerMethod(new ClassLevelAiController(), m); + } + + /** Fixture mounting route shapes the guard's resolver needs to discriminate. */ + static class TestController { + + @AutoJobPostMapping("/manual") + public String manualTool() { + return "ok"; + } + + @AutoJobPostMapping("/automation") + @RequiresFeature(FeatureGate.AUTOMATION) + public String automationOnly() { + return "ok"; + } + + @AutoJobPostMapping("/ai") + @RequiresFeature(FeatureGate.AI_SUPPORT) + public String aiOnly() { + return "ok"; + } + + /** Endpoint without any annotation — guard must skip. */ + public String plainEndpoint() { + return "ok"; + } + + /** AI-controller shape: @RequiresFeature with NO @AutoJobPostMapping (JSON body). */ + @RequiresFeature(FeatureGate.AI_SUPPORT) + public String aiOnlyNoAutoJobPostMapping() { + return "ok"; + } + } + + /** + * Mirrors {@code AiCreateController} layout: @RequiresFeature on the class, plain methods. The + * guard must pick up the class-level annotation. + */ + @RequiresFeature(FeatureGate.AI_SUPPORT) + static class ClassLevelAiController { + public String classLevelAiMethod() { + return "ok"; + } + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java new file mode 100644 index 000000000..430e1b7e0 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementServiceTest.java @@ -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): + * + *

    + *
  • Unsubscribed — 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. + *
  • Subscribed — gated by the monthly money-derived doc cap. Spend = this period's net + * billable units ({@link WalletLedgerRepository#sumPeriodNetBillable} negated, refunds + * netted). + *
+ * + * 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; + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java index 72af9368e..281b039ca 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/filter/PaygChargeInterceptorTest.java @@ -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 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 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 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 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 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 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. */ diff --git a/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java b/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java index e526bf7ca..6f921a6fd 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/job/StaleJobCloserTest.java @@ -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()); } } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java new file mode 100644 index 000000000..f9ab02c3d --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReconcileSchedulerTest.java @@ -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); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java new file mode 100644 index 000000000..7ee240827 --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/payg/meter/PaygMeterReportingServiceTest.java @@ -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>> entityCaptor = + ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate) + .exchange( + eq(ENDPOINT), + eq(HttpMethod.POST), + entityCaptor.capture(), + eq(String.class)); + + HttpEntity> sent = entityCaptor.getValue(); + Map 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>> 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(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java index bffc17bd9..f1a24d035 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/model/PaygEntitiesSmokeTest.java @@ -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); + } } diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index ff80bc948..e0745cad5 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -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 = "all → 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." diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 22bdce81e..eaba23620 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -31,6 +31,7 @@ export const VALID_NAV_KEYS = [ "adminStorageSharing", "adminMcp", "help", + "payg", ] as const; // Derive the type from the array diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 6a99d61f3..55d73d7e0 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -20,6 +20,7 @@ export interface AppConfig { enableDesktopInstallSlide?: boolean; premiumEnabled?: boolean; premiumKey?: string; + paygEnabled?: boolean; termsAndConditions?: string; privacyPolicy?: string; cookiePolicy?: string; diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 02d4688fb..7cbe522f5 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -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() { + } /> } /> diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 94c1ae824..8ed451ec4 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -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(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("/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) => { diff --git a/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx new file mode 100644 index 000000000..6c28156f3 --- /dev/null +++ b/frontend/editor/src/saas/components/SignupRequiredBootstrap.tsx @@ -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. + * + *

Why an event bus instead of direct render

+ * 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. + * + *

De-duping

+ * 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(null); + + useEffect(() => { + const handler = (ev: Event) => { + const detail = (ev as CustomEvent).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 ( + setOpened(false)} + withCloseButton + centered + size="md" + radius="lg" + zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} + title={ + + {t("payg.signupRequired.title", "Sign up to use {{category}}", { + category: categoryNoun, + })} + + } + > + + + {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.", + )} + + + {t( + "payg.signupRequired.subtext", + "Creating an account is free and takes a few seconds. No credit card required.", + )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 550da1dce..dec671fbc 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -61,6 +61,24 @@ const AppConfigModal: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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} >
{/* Left navigation */} diff --git a/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx b/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx index 2ac6318bb..f5bf270cc 100644 --- a/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx +++ b/frontend/editor/src/saas/components/shared/ManageBillingButton.tsx @@ -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) diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.css b/frontend/editor/src/saas/components/shared/config/configSections/Payg.css new file mode 100644 index 000000000..8e1921d06 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/Payg.css @@ -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; +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx new file mode 100644 index 000000000..b431c1386 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/Payg.tsx @@ -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; + /** + * 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; +} + +// ─── 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 ( +
+ + {open && ( +
+
    +
  • + {t( + "payg.docHelp.billable", + "Only automation runs, AI tools, and API calls count. Manual tools in the editor are always free.", + )} +
  • +
  • + {t( + "payg.docHelp.perFile", + "Each file you process counts as one PDF. Very long or very large files can count as more than one.", + )} +
  • +
  • + {t( + "payg.docHelp.chains", + "Running the same file through several steps of one automation counts it once, not once per step.", + )} +
  • +
  • + {t( + "payg.docHelp.refunds", + "If a job fails on its first step, the PDF is credited back automatically.", + )} +
  • +
+
+ )} +
+ ); +} + +// ─── 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 ( +
+
+ +
+
+ {t("payg.usage.thisPeriod", "This billing period")} +
+
+ + {wallet.billableUsed.toLocaleString()} + + + {hasLimit + ? t( + "payg.usage.ofLimitProcessed", + "/ {{limit}} PDFs processed", + { limit: limit.toLocaleString() }, + ) + : t("payg.usage.processed", "PDFs processed")} + +
+
+
+ + {stateLabel} +
+
+ + {hasLimit && ( +
+
+
+ )} + +
+ + {t("payg.usage.firstFree", "First {{free}} free", { + free: wallet.freeAllowance.toLocaleString(), + })} + + {wallet.estimatedBillMinor != null && ( + <> + + + {t("payg.usage.estBill", "≈ {{amount}} so far this period", { + amount: formatMinor( + wallet.estimatedBillMinor, + wallet.currency, + ), + })} + + + )} + + + {hasCap + ? t("payg.usage.capLine", "{{cap}}/mo cap", { + cap: `${currencySymbol(wallet.currency)}${wallet.capUsd}`, + }) + : t("payg.usage.noCap", "No monthly cap")} + + + + {daysLeft === 1 + ? t("payg.usage.resetsTomorrow", "Resets tomorrow") + : t("payg.usage.resetsIn", "Resets in {{days}} days", { + days: daysLeft, + })} + +
+ +
+ + {t( + "payg.usage.breakdown", + "AI {{ai}} • Automation {{automation}} • API {{api}}", + { + ai: breakdown.ai.toLocaleString(), + automation: breakdown.automation.toLocaleString(), + api: breakdown.api.toLocaleString(), + }, + )} + +
+ + +
+
+ ); +} + +// ─── 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; +} + +/** + * 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(savedCap); + + return ( +
+ +
+
+ {t("payg.cap.title", "Monthly spending cap")} +
+
+ {t( + "payg.cap.subtitle", + "The most your team can spend per month. Billable processing pauses at the cap and resumes next period.", + )} +
+
+ + + + +
+
+ ); +} + +function CapReadOnly({ capUsd, noCap }: { capUsd: number | null; noCap: boolean }) { + const { t } = useTranslation(); + const hasCap = !noCap && capUsd != null; + return ( +
+ +
+ {t("payg.cap.title", "Monthly spending cap")} +
+ + + {hasCap ? `$${capUsd}` : t("payg.cap.noneShort", "No cap")} + + {hasCap && ( + {t("payg.cap.perMonth", "/ month")} + )} + +
+
+
+ {t( + "payg.member.askLeader", + "Only your team owner can change the cap.", + )} +
+
+
+ + +
+
+ ); +} + +// ─── 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 ( +
+ + {open && ( +
+
+ {GATE_CAP_BEHAVIOR.map(({ gate, staysAtCap }) => ( +
+ + {staysAtCap ? ( + + ) : ( + + )} + + {gateLabel(gate, t)} + {!staysAtCap && ( + + {t("payg.gates.pauses", "pauses at cap")} + + )} +
+ ))} +
+
+ )} +
+ ); +} + +// ─── 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 ( +
+ +
+
+ {t("payg.members.title", "Team member usage")} +
+
+ {t( + "payg.members.subtitle", + "Billable PDFs each teammate has processed this period.", + )} +
+
+
+ {members.map((m) => ( +
+ + {m.name.charAt(0).toUpperCase()} + +
+
{m.name}
+
{m.email}
+
+
+
+ {m.spendUnits.toLocaleString()}{" "} + + {t("payg.members.docs", "PDFs")} + +
+
+
+ ))} +
+
+
+ ); +} + +// ─── 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} for the same reason). + */ +function ActivityFeed({ recent }: { recent: Wallet["recent"] }) { + const { t } = useTranslation(); + return ( +
+ +
+
+ {t("payg.activity.title", "Recent billable activity")} +
+
+ {t( + "payg.activity.subtitle", + "Only AI and automation draw from your budget. Everyday tools are free and aren't listed here.", + )} +
+
+ {recent.length === 0 ? ( + + {t( + "payg.activity.empty", + "No billable activity yet this period.", + )} + + ) : ( +
+ {recent.map((r, i) => ( +
+ +
+
+ {String(r.label ?? "")} +
+
{String(r.ts ?? "")}
+
+ + {String(r.kind ?? "")} + + + {String(r.docUnits ?? 0)} {t("payg.activity.docs", "docs")} + +
+ ))} +
+ )} +
+
+ ); +} + +// ─── Stripe CTA ────────────────────────────────────────────────────────────── + +function StripePortalLink({ onOpenPortal }: { onOpenPortal: () => Promise }) { + 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 ( +
+
+
+ {t("payg.stripe.title", "Manage billing in Stripe")} +
+
+ {t( + "payg.stripe.subtitle", + "Receipts, invoices, payment method, billing currency.", + )} +
+
+ +
+ ); +} + +// ─── Main component ─────────────────────────────────────────────────────── + +const Payg: React.FC = ({ 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 ( +
+ + {/* The modal chrome already renders the section title ("Billing & + usage"), so we lead with the descriptive subtitle + role pill. */} +
+
+ + {t("payg.header.eyebrow", "Processor plan · {{start}} – {{end}}", { + start: fmt(wallet.billingPeriodStart), + end: fmt(wallet.billingPeriodEnd), + })} + + + {isLeader + ? t("payg.role.leader", "Team owner") + : t("payg.role.member", "Member")} + +
+ +
+
+
+ + {t("payg.header.freeLabel", "Always free")} +
+

+ {t("payg.header.freeTitle", "Unlimited PDF editing")} +

+

+ {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.", + )} +

+
+ +
+
+ + {t("payg.header.meterLabel", "Metered")} +
+

+ {t("payg.header.meterTitle", "Automation · AI · API")} +

+

+ {t( + "payg.header.meterBody", + "{{limit}} free PDFs to start, then billed per PDF up to your cap.", + { limit: wallet.freeAllowance.toLocaleString() }, + )} +

+
+
+
+ + + + {isLeader ? ( + + ) : ( + + )} + + {isLeader && wallet.members.length > 0 && ( + + )} + + {SHOW_ACTIVITY_FEED && } + + {isLeader && onOpenPortal && ( + + )} +
+
+ ); +}; + +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; + /** See {@link PaygProps#onOpenPortal}. */ + onOpenPortal?: () => Promise; +} +export const PaygLeader: React.FC = ({ + wallet, + onSaveCap, + onOpenPortal, +}) => ( + +); +export const PaygMember: React.FC<{ wallet: Wallet }> = ({ wallet }) => ( + +); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css new file mode 100644 index 000000000..20534849d --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.css @@ -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; +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx new file mode 100644 index 000000000..2c4a58cd1 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/PaygFree.tsx @@ -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. + * + *

Free tier model (set 2026-06): users only pay for + * automation, AI, and API 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 type of work (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 + * only to the three billable categories — it is a lifetime allowance, + * not a monthly one, and a team keeps any unused portion after subscribing. + * + *

Layout: a slim Editor plan card (always-free tools only — no dates, + * no metered split) on top, then a single Processor plan card that + * two-columns the upgrade pitch + benefits (left) against the one-time free + * meter stacked over the call-to-action (right). + * + *

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 ( +

+
+ + + {t("payg.free.editor.eyebrow", "Editor plan · Always free")} + + + {pill} + +
+

+ {t("payg.free.header.freeTitle", "Unlimited PDF editing")} +

+

+ {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.", + )} +

+
+ ); +} + +// ─── 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 ( +
+
+
+ + {snap.billableUsed.toLocaleString()} + + + {t("payg.free.hero.capSuffix", "/ {{limit}} free PDFs", { + limit: snap.billableLimit.toLocaleString(), + })} + +
+ + + {stateLabel} + +
+ +
+
+
+ +
+ + {t("payg.free.hero.metaCategories", "Automation · AI · API requests")} + + + {t("payg.free.hero.neverResets", "One-time — never resets")} +
+
+ ); +} + +// ─── 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 ( +
+ + + {t("payg.free.proc.eyebrow", "Processor plan · metered")} + + +
+
+

+ {t("payg.free.cta.title", "Turn on the Processor plan")} +

+

+ {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() }, + )} +

+ +
    +
  • + + + + {t("payg.free.cta.benefit1Title", "Automation pipelines")} + + {" — "} + {t( + "payg.free.cta.benefit1Body", + "chain tools, schedule runs, batch process", + )} + +
  • +
  • + + + {t("payg.free.cta.benefit2Title", "AI tools")} + {" — "} + {t( + "payg.free.cta.benefit2Body", + "summarise, classify, redact, AI-OCR", + )} + +
  • +
  • + + + {t("payg.free.cta.benefit3Title", "API access")} + {" — "} + {t( + "payg.free.cta.benefit3Body", + "call any Stirling endpoint programmatically", + )} + +
  • +
+ + +
+ +
+ + {isLeader ? ( + <> + + + {t( + "payg.free.cta.reassurance", + "No minimum · Set a $0 cap to test · Cancel anytime", + )} + + + ) : ( +
+ + + {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.", + )} + +
+ )} +
+
+
+ ); +} + +// ─── 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 ( +
+ + + setUpgradeOpen(true)} + /> + + + {wallet?.teamId != null && ( + 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` + }.`, + ); + } + }} + /> + )} +
+ ); +} + +// ─── Free MEMBER ────────────────────────────────────────────────────────── + +function PaygFreeMemberInner() { + useRenderCount("PaygFreeMember"); + const { t } = useTranslation(); + const snap = useFreeSnapshot(); + + return ( +
+ + + + +
+ ); +} + +// 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); diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx index 8571c5db4..eaeccbc72 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/Plan.tsx @@ -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) + * + *

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(null); - const [selectedCredits, setSelectedCredits] = useState(0); // Index of selected credit package - const [purchaseType, setPurchaseType] = - useState("subscription"); - const [selectedCreditsPack, setSelectedCreditsPack] = - useState(null); - const [currency, setCurrency] = useState("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:contact@stirlingpdf.com?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 ( -

- -
+
+ +
); } - if (error) { + if (error || !wallet) { return ( - - {error} + + {error ?? + t( + "payg.error.body", + "We couldn't reach the billing service. Refresh the page to try again.", + )} ); } - if (!data) { - return ( - - Plans data is not available at the moment. - + if (wallet.status === "subscribed") { + return wallet.role === "leader" ? ( + + ) : ( + ); } - const { plans, apiPackages, currentPlan, nextBillingDate, activeSince } = - data; - const plansArray = Array.from(plans.values()); - - return ( -
- {/* Currency Selector */} -
- - - Currency - - onCustomInput(e.target.value)} + /> + + + + + {onSave && ( + + )} +
+ + {previewDocs != null && ( +
+ +
+
+ {t("payg.cap.docsEstimate", "≈ {{docs}} processed PDFs / month", { + docs: previewDocs.toLocaleString(), + })} +
+
+ {t("payg.cap.docsRate", "at {{rate}} / PDF", { + rate: formatMinor(pricePerDocMinor ?? 0, currency), + })} +
+
+
+ )} + + {isNoCap && ( +
+ {t( + "payg.cap.noCapDesc", + "Usage is billed without an upper limit. You can re-enable a cap at any time.", + )} +
+ )} + + {note &&
{note}
} +
+ ); +}; + +export default SpendCapControl; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx new file mode 100644 index 000000000..4df290dde --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/StripeCheckoutPanel.tsx @@ -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. + * + *

Lazy-load pattern

+ * + *
+ * // In UpgradeModal.tsx — only when the user advances to step 2:
+ * const StripeCheckoutPanel = React.lazy(
+ *   () => import("./StripeCheckoutPanel"),
+ * );
+ * 
+ * + * The {@code loadStripe()} call inside this module deferred-imports the SDK + * itself, so the chunk graph is: + * + *
+ *   StripeCheckoutPanel.chunk.js
+ *     └─ @stripe/react-stripe-js  (pulled in by ESM static import here)
+ *     └─ @stripe/stripe-js        (pulled in by loadStripe inside fetchStripe)
+ * 
+ * + * Both chunks are eligible for Vite tree-shaking + lazy load; nothing in the + * main bundle references either package. + * + *

Architecture

+ * + * 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. + * + *

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. + * + *

Behaviour

+ * + *
    + *
  1. 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. + *
  2. 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. + *
  3. Otherwise render the real {@code } + + * {@code } iframe. + *
+ * + * 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 | null = null; +function getStripe(publishableKey: string): Promise { + if (stripePromise === null) { + stripePromise = import("@stripe/stripe-js").then((mod) => + mod.loadStripe(publishableKey), + ); + } + return stripePromise; +} + +const StripeCheckoutPanel: React.FC = ({ + 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(null); + const [isMock, setIsMock] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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( + "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 ( +
+
+ {t("payg.checkout.connecting", "Connecting to Stripe…")} +
+
+ ); + } + + if (error) { + return ( +
+
+ {t("payg.checkout.errorTitle", "Stripe error")} +
+
{error}
+
+ ); + } + + // Mock mode OR no publishable key configured → friendly placeholder. + const showMockPlaceholder = isMock || publishableKey.length === 0; + + if (showMockPlaceholder) { + return ( +
+
+ {t( + "payg.checkout.mock.title", + "Stripe Embedded Checkout (mock mode)", + )} +
+
+ {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.", + )} +
+
+ +
+
+ ); + } + + if (!clientSecret) return null; + + return ( +
+ + + +
+ ); +}; + +export default StripeCheckoutPanel; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css new file mode 100644 index 000000000..5840bd8df --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.css @@ -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; +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx new file mode 100644 index 000000000..ec9f58723 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/configSections/UpgradeModal.tsx @@ -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 + * + *

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. + * + *

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("cap"); + const [capUsd, setCapUsd] = useState(500); + const [noCap, setNoCap] = useState(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( +

+
+
e.stopPropagation()}> + {/* Header — title + close. Title stays constant; the step indicator + below tells the user where they are. */} +
+
+ {step === "checkout" && ( + + )} +

+ {step === "confirm" + ? t("payg.upgrade.title.confirm", "You're subscribed") + : t("payg.upgrade.title.default", "Upgrade to Processor plan")} +

+
+ +
+ + {/* Step indicator. Hidden on the confirmation panel since the + modal is winding down at that point. */} + {step !== "confirm" && ( +
+
+ 1 + {step === "checkout" ? ( + + {effectiveCap === null + ? t("payg.upgrade.checkout.noCap", "No cap") + : t( + "payg.upgrade.checkout.capValue", + "{{symbol}}{{amount}} / month", + { symbol: sym, amount: effectiveCap }, + )} + + + ) : ( + + {t("payg.upgrade.steps.cap", "Set monthly ceiling")} + + )} +
+
+
+ 2 + + {t( + "payg.upgrade.steps.payment", + "Add payment method", + )} + +
+
+ )} + +
+ {step === "cap" && ( + + )} + {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. + + )} + {step === "confirm" && ( + + )} +
+ + {step !== "checkout" && ( +
+
+ {step === "cap" && ( + <> + + + + )} + {step === "confirm" && ( + + )} +
+
+ )} +
+
+
, + 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 ( + <> +
+ +
+ + {t( + "payg.upgrade.promise.highlight", + "Manual tools stay free, always.", + )} + {" "} + {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.", + )} +
+
+ +

+ {t( + "payg.upgrade.cap.title", + "Set your monthly spend ceiling", + )} +

+

+ {t( + "payg.upgrade.cap.help", + "We'll never charge above this. Set $0 if you want to keep everything free while testing.", + )} +

+ + {/* 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. */} + { + 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.", + )} + /> + +
+ + {t( + "payg.upgrade.help.title", + "What we count toward billing", + )} + +
    +
  • + + {t( + "payg.upgrade.help.automationTitle", + "Automation pipelines", + )} + + {" — "} + {t( + "payg.upgrade.help.automationBody", + "chained tools or scheduled runs that don't need clicks", + )} +
  • +
  • + + {t("payg.upgrade.help.aiTitle", "AI tools")} + + {" — "} + {t( + "payg.upgrade.help.aiBody", + "summarise, classify, redact, AI-OCR", + )} +
  • +
  • + + {t("payg.upgrade.help.apiTitle", "API calls")} + + {" — "} + {t( + "payg.upgrade.help.apiBody", + "programmatic access to any Stirling endpoint", + )} +
  • +
+
+ {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.", + )} +
+
+ + ); +} + +// ─── 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 ( + <> +

+ {t( + "payg.upgrade.checkout.title", + "Add your payment method", + )} +

+

+ {t( + "payg.upgrade.checkout.help", + "Stripe handles your card details. Stirling never sees them.", + )} +

+ + +
+ {t("payg.upgrade.checkout.loading", "Loading checkout…")} +
+
+ } + > + + + + ); +} + +// ─── 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 ( +
+ +

+ {t( + "payg.confirm.title", + "Welcome to the Processor plan", + )} +

+

+ {t( + "payg.confirm.body", + "Your team can now process documents with automation, AI, and the API beyond your {{limit}} free PDFs.", + { limit: freeLimit.toLocaleString() }, + )} +

+
+ {t("payg.confirm.summaryLabel", "Monthly ceiling")} + + {effectiveCap === null + ? t("payg.confirm.noCap", "No cap") + : t("payg.confirm.capValue", "{{symbol}}{{amount}} / month", { + symbol: sym, + amount: effectiveCap, + })} + +
+

+ {t( + "payg.confirm.note", + "You can change your cap, cancel, or open the Stripe customer portal any time from this page.", + )} +

+
+ ); +} diff --git a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts b/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts index 5de25063f..5f0f0a321 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts +++ b/frontend/editor/src/saas/components/shared/config/configSections/apiKeys/hooks/useCredits.ts @@ -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): 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(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(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(null); + const [isLoading] = useState(false); + const [error] = useState(null); const [hasAttempted, setHasAttempted] = useState(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>("/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, diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx deleted file mode 100644 index c3c18ecaa..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/ActivePlanSection.tsx +++ /dev/null @@ -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 = ({ - currentPlan, - _activeSince, - _nextBillingDate, - trialStatus, - onAddPaymentClick, -}) => { - const { t } = useTranslation(); - - return ( -
- -

- {t("plan.activePlan.title", "Active Plan")} -

- -
-

- {t("plan.activePlan.subtitle", "Your current subscription details")} -

- - {/* Trial Status Alert */} - {trialStatus?.isTrialing && ( - } - mt="md" - mb="md" - title={t("plan.trial.title", "Free Trial Active")} - > - - {t("plan.trial.daysRemaining", "Your trial ends in {{days}} days", { - days: trialStatus.daysRemaining, - })} - - - {t("plan.trial.endDate", "Expires: {{date}}", { - date: new Date(trialStatus.trialEnd).toLocaleDateString(), - })} - - {trialStatus.hasScheduledSub ? ( - - ✓{" "} - {t( - "plan.trial.subscriptionScheduled", - "Subscription scheduled - starts {{date}}", - { - date: new Date(trialStatus.trialEnd).toLocaleDateString(), - }, - )} - - ) : ( - onAddPaymentClick && ( - - ) - )} - - )} - - - -
- - - {currentPlan.name} - - {trialStatus?.isTrialing && ( - - {t("plan.trial.badge", "Trial")} - - )} - - {/* {activeSince && ( - - {t('plan.activeSince', 'Active since {{date}}', { date: activeSince })} - - )} */} -
-
- - {currentPlan.currency} - {currentPlan.price}/month - - {/* {nextBillingDate && ( - - {t('plan.nextBilling', 'Next billing: {{date}}', { date: nextBillingDate })} - - )} */} -
-
-
-
- ); -}; - -export default ActivePlanSection; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx deleted file mode 100644 index f707d5d9a..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/ApiPackagesSection.tsx +++ /dev/null @@ -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 = ({ - apiPackages, - selectedCredits, - onSelectedCreditsChange, - onCreditPurchaseClick, -}) => { - const { t } = useTranslation(); - - return ( -
-

- {t("plan.apiPackages.title", "API Credit Packages")} -

-

- {t( - "plan.apiPackages.subtitle", - "Purchase API credits for your applications", - )} -

- - - - {/* Credits Selection */} -
- - {t("plan.selectCredits", "Select Credit Amount")} - - -
- - 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} - /> -
-
- - {/* Selected Package Display */} - -
- - {apiPackages[ - Math.round(selectedCredits) - ].credits.toLocaleString()}{" "} - Credits - - - {apiPackages[Math.round(selectedCredits)].description} - -
- -
- - {apiPackages[Math.round(selectedCredits)].currency} - {apiPackages[Math.round(selectedCredits)].price} - - - {t("plan.totalCost", "Total Cost")} - -
- - -
-
-
-
- ); -}; - -export default ApiPackagesSection; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx deleted file mode 100644 index 3942636bd..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ /dev/null @@ -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 = ({ - plans, - currentPlan, - onUpgradeClick, -}) => { - const { t } = useTranslation(); - const [showComparison, setShowComparison] = useState(false); - - const isUserProOrAbove = - currentPlan?.id === "pro" || currentPlan?.id === "enterprise"; - - return ( -
-

- {t("plan.availablePlans.title", "Available Plans")} -

-

- {t( - "plan.availablePlans.subtitle", - "Choose the plan that fits your needs", - )} -

- -
- {plans.map((plan) => ( - - ))} -
- -
- -
- - - - - {t("plan.featureComparison", "Feature Comparison")} - - -
- - - - - {plans.map((plan) => ( - - ))} - - - - {plans[0].features.map((_, featureIndex) => ( - - - {plans.map((plan) => ( - - ))} - - ))} - -
- {t("plan.feature.title", "Feature")} - - {plan.name} - {plan.popular && ( - - {t("plan.popular", "Popular")} - - )} -
- {plans[0].features[featureIndex].name} - - {plan.features[featureIndex].included ? ( - - ✓ - - ) : ( - - - )} -
-
-
-
-
- ); -}; - -export default AvailablePlansSection; diff --git a/frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx deleted file mode 100644 index 6fde6ee49..000000000 --- a/frontend/editor/src/saas/components/shared/config/configSections/plan/PlanCard.tsx +++ /dev/null @@ -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 = ({ - 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 ( - - {plan.popular && ( - - {t("plan.popular", "Popular")} - - )} - - -
- - {plan.name} - - - - {plan.isContactOnly - ? t("plan.customPricing", "Custom") - : `${plan.currency}${plan.price}`} - - {!plan.isContactOnly && ( - - {plan.period} - - )} - -
- - - {plan.highlights.map((highlight: string, index: number) => ( - - • {highlight} - - ))} - - -
- - {!shouldHideUpgrade && ( - - )} - - - ); -}; - -export default PlanCard; diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index 9ec28f42e..562c010ad 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -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); } diff --git a/frontend/editor/src/saas/hooks/useCreditCheck.ts b/frontend/editor/src/saas/hooks/useCreditCheck.ts index 54ad36d7c..6a9863ad9 100644 --- a/frontend/editor/src/saas/hooks/useCreditCheck.ts +++ b/frontend/editor/src/saas/hooks/useCreditCheck.ts @@ -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 } { const checkCredits = useCallback( - async (_runtimeEndpoint?: string): Promise => { - 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 => null, + [], ); - return { checkCredits }; } diff --git a/frontend/editor/src/saas/hooks/useRenderCount.ts b/frontend/editor/src/saas/hooks/useRenderCount.ts new file mode 100644 index 000000000..c3d3a699f --- /dev/null +++ b/frontend/editor/src/saas/hooks/useRenderCount.ts @@ -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." + * + *

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; + } +} + +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; +} diff --git a/frontend/editor/src/saas/hooks/useWallet.ts b/frontend/editor/src/saas/hooks/useWallet.ts new file mode 100644 index 000000000..5daf04242 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useWallet.ts @@ -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. + * + *

Render efficiency

+ * + * The hook is designed so {@code Plan}, {@code PaygFreeLeader/Member}, and + * {@code PaygLeader/Member} re-render only on actual data change: + * + *
    + *
  • {@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. + *
  • The returned {@link UseWalletResult} keeps stable callback identities + * via {@code useCallback}. {@code Plan} can pass {@code markSubscribed} + * to {@code UpgradeModal} without forcing a remount. + *
  • {@code refetch / markSubscribed / updateCap} bump an internal counter + * that the {@code useEffect} watches — no global state plumbing. + *
  • 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). + *
+ * + *

Mutation semantics

+ * + * 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. + * + *

Dev preview fallback

+ * + * 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} + * because the activity-row shape is not yet finalised — when the meter- + * event surface lands, this widens to a real interface. + */ + recent: Array>; +} + +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; + /** + * 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; + /** + * 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; + /** + * 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; +} + +// ─── 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(isDevPreviewContext()).current; + + const [wallet, setWallet] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 | 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("/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, + }; +} diff --git a/frontend/editor/src/saas/services/apiClient.ts b/frontend/editor/src/saas/services/apiClient.ts index 893bda76f..8229c45ef 100644 --- a/frontend/editor/src/saas/services/apiClient.ts +++ b/frontend/editor/src/saas/services/apiClient.ts @@ -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) { diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts new file mode 100644 index 000000000..84be4ecc9 --- /dev/null +++ b/frontend/editor/src/saas/services/paygErrorInterceptor.test.ts @@ -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); + } + }); +}); diff --git a/frontend/editor/src/saas/services/paygErrorInterceptor.ts b/frontend/editor/src/saas/services/paygErrorInterceptor.ts new file mode 100644 index 000000000..76ae0f4f3 --- /dev/null +++ b/frontend/editor/src/saas/services/paygErrorInterceptor.ts @@ -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: + * + *
    + *
  • {@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.
  • + *
  • {@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.
  • + *
+ * + * 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("payg:signupRequired", { + detail: { category }, + }), + ); + } catch { + // SSR / test environments without a real window — no-op. + } + return; + } +}