PAYG E2E test bundle — combined BE (#6574) + FE (#6579)#6589
Draft
ConnorYoh wants to merge 37 commits into
Draft
PAYG E2E test bundle — combined BE (#6574) + FE (#6579)#6589ConnorYoh wants to merge 37 commits into
ConnorYoh wants to merge 37 commits into
Conversation
The PAYG settings screen is mock-only and must stay invisible to users until the backend wallet endpoint (PR-C3.2) lands and sets `appConfig.paygEnabled`. The demo overrides bypassed that gate via a URL query param + localStorage, which would have let anyone with the link see the mock data. - AppConfigModal.tsx: drop the `paygOverride` block; `paygEnabled` and `isLeader` now derive only from the backend `appConfig` flags. - proprietary/configNavSections.tsx: drop the cross-flavor `PaygLeader` import and the demo block that pushed PAYG into the proprietary nav. PAYG is saas-only. - Payg.tsx: trim the stale comment that justified the relative CSS import by referencing the now-removed proprietary demo cross-import.
First visual chunk of the PAYG plan/upgrade flow. Builds on top of Reece's Payg.tsx (cherry-picked from #6503 into this branch as part of the bundle). Components: - EditorPlanPromo: the page free users see in the Plan slot. Hero anchors the "Editing is free, forever" promise; two-card layout shows what they get on Editor (current) vs Processor (upgrade); Processor card has a gradient border + accent CTA. Reinforcement strip at the bottom restates the promise. - UpgradeModal: two-step modal inside one frame. Step 1 (cap selection) has $10/$25/$50/$100 presets + custom input + no-cap toggle + helper card explaining what counts as billable. Step 2 will host Stripe Embedded Checkout — currently a placeholder explaining what env vars + backend endpoint will activate it. Step indicator transitions 1-active → 1-done/2-active with colour change. Cap value held in modal state, only posted to backend on Checkout success → no side-effects on cancel. Dev preview route (saas/routes/DevPaygPreview.tsx): - Gated by import.meta.env.DEV in saas/App.tsx so production tree-shakes it out - Lets me iterate visually at /dev/payg-preview without going through auth + nav config - Four slots: free user promo, upgrade modal, subscribed LEADER, subscribed MEMBER. The two subscribed slots render Reece's existing Payg.tsx (PaygLeader / PaygMember) with mock data so the full surface is visible - DELETE before PR ships — flagged in the file's javadoc Screenshots dropped during iteration: payg-flow-002-editor-promo-v1.png (first cut) payg-flow-003-editor-promo-v2.png (polished) payg-flow-004-upgrade-modal-step1.png payg-flow-005-upgrade-modal-step2.png payg-flow-006-modal-step2-polished.png payg-flow-007-full-promo-with-footer.png payg-flow-008-leader-dashboard.png Next commits (same branch, same PR): - useWallet() hook wiring Payg.tsx to real /api/v1/payg/wallet endpoint - PlanSection.tsx that branches free → EditorPlanPromo / active → PaygLeader/Member - Backend Java: wallet endpoint + checkout endpoint + cap endpoint - Stripe Embedded Checkout wire-in (placeholder → real iframe) - i18n keys for all new copy - Remove DevPaygPreview before marking PR ready No backend changes in this commit — pure visual iteration with mock data.
Drops the side-by-side card promo (EditorPlanPromo) in favour of two views
that mirror the subscribed PaygLeader/PaygMember dashboards visually. The
free view now feels like an extension of the paid view rather than a
different page, which makes turning on Processor feel like enabling a
switch rather than buying a product.
New billable framing (product call set 2026-06):
Free, always — anything clicked directly in the Stirling UI:
viewing, editing, merging, splitting, signing, watermarking,
compression, conversion, OCR via the interactive UI.
Counts toward 500/month free tier — and metered above that:
automation pipelines, AI tools, API calls.
Note: backend design doc + pricing schema don't reflect this yet — FE is
the source of truth in the meantime; backend will catch up.
Components:
PaygFreeLeader
- Mirrors PaygLeader: subtitle + role pill header, hero usage card
showing 62 / 500 free operations with progress bar + "Plenty left"
status, identical visual language to the subscribed view
- Below the hero: a gradient-bordered CTA card ("Turn on the Processor
plan") with three benefit bullets (Automation / AI / API access)
and footer row containing the CTA button + reassurance copy
- Bottom: side-by-side explainer cards — "Always free" vs "Counts
toward 500/month" — using the same colour tokens as the hero
- CTA opens the existing UpgradeModal
PaygFreeMember
- Same header + hero pattern but with team-member pill
- Replaces the CTA with a lock-icon note explaining the owner can
enable Processor
- Same bottom explainer cards but worded for the member viewpoint
("Always free for you" / "Shared with the team")
UpgradeModal copy rewrite:
- Promise card now reads "Everything in the Stirling UI stays free.
You only pay for what comes from automation pipelines, AI tools, and
API calls — the things that don't need a person clicking through
the UI."
- "Set your monthly spend ceiling" section text updated to
"Your first 500 automation / AI / API operations every month are free"
- "What we count toward billing" card now a bulleted list of the three
billable categories plus an italic line explicitly listing UI tools
that stay free
Dev preview slots reworked:
- Was: free / upgrade-modal / active-leader / active-member
- Now: free-leader / free-member / subscribed-leader / subscribed-member
- "Upgrade modal" slot removed — the modal now opens from inside
free-leader, which is the only real entry point
Deleted: EditorPlanPromo.tsx + .css (superseded by PaygFreeLeader).
Screenshots:
payg-flow-009-free-leader-v1.png (first cut — stacked header)
payg-flow-010-free-leader-v2.png (Mantine Group header, polished CTA)
payg-flow-011-free-member.png (member view, lock note instead of CTA)
payg-flow-012-modal-new-copy.png (modal with billable framing reworked)
Automation and AI features also have UI surfaces, so the "UI free / not-UI
paid" framing was misleading. Reframed everywhere to be about the type of
work:
- "Manual tools are always free" (viewing, editing, signing, merging,
splitting, conversion, watermarks, compression, manual OCR)
- "Automation, AI, and API count toward billing" (regardless of where
they're triggered from)
Also drops the "From $5/month" reassurance because users can set a $0 cap
and pay nothing while still being subscribed; the cap input now accepts
0 and the help text mentions it explicitly.
Touched:
- PaygFree.tsx — module javadoc, header subtitle, CTA reassurance,
leader + member explainer text, member ask-the-owner note
- UpgradeModal.tsx — promise card, section help, cap input (min={0}),
billable-list italic footer
Replaces the legacy multi-tier (Pro / Business / Enterprise + API credit packs) plan page with the new PAYG plan UI. The Plan tab now internally branches on (subscription state × team role) and renders one of four views: - free + leader → PaygFreeLeader (upgrade CTA) - free + member → PaygFreeMember (ask-the-owner note) - subscribed + L/M → PaygLeader / PaygMember (Reece's dashboard) Notable: - New useWallet() hook (saas/hooks/useWallet.ts). Mock-first: subscription state lives in localStorage so the UpgradeModal's onComplete can flip the rendered view without a real Stripe round-trip. Role is sourced from appConfig.isAdmin (same proxy saasConfigNavSections used before). Swap to GET /api/v1/payg/wallet when the endpoint lands. - PaygFreeLeader gains an optional onUpgraded prop. PlanSection wires it to markSubscribed(). Standalone dev-preview keeps the demo alert fallback. - Drops the duplicate "Pay-as-you-go" nav tab — there is one billing surface now, and it's the Plan tab. Removes paygEnabled / isLeader nav options (and the related plumbing in AppConfigModal). - Deletes frontend/editor/src/saas/.../configSections/plan/ (the old PlanCard / AvailablePlansSection / ActivePlanSection / ApiPackagesSection components). usePlans hook stays — still used by ManageBillingButton + TrialStatusBanner. Verified visually via /dev/payg-preview (screenshots 013–015 in notes/payg-flow-screenshots/) — all four views render cleanly with the v2.1 manual-tools framing.
Adds the three PAYG REST endpoints the new Plan-page UI needs: GET /api/v1/payg/wallet — subscription state + usage + cap + role POST /api/v1/payg/checkout — creates a Stripe Checkout Session PATCH /api/v1/payg/cap — updates monthly spend cap Plus a DEV-ONLY POST /api/v1/payg/dev/mark-subscribed that simulates the Stripe webhook so the UI can be exercised end-to-end before the real webhook handler ships. All endpoints are saas-profile-gated. The service layer is an in-memory mock keyed by a synthetic team identifier — the real backing tables (payg_team_extensions.payg_subscription_id + free_tier, payg_meter_event_log) land in SaaS PR #300 and the Stripe Java SDK isn't on this branch yet. Each mock returns realistic shapes with a JavaDoc swap-out plan calling out exactly what changes when the unmerged surfaces land. Why mock here instead of waiting for PR #300: - Frontend can iterate against real HTTP round-trips instead of localStorage - Shapes get exercised before any of it hits prod - Removing the mock is a service-layer edit, not a controller rewrite Verified `./gradlew :saas:compileJava` clean.
… step End-to-end UI for the new PAYG flow against the backend mock controllers landed in the previous commit: - useWallet — calls GET /api/v1/payg/wallet via the shared apiClient (which injects the Supabase JWT). Dev preview route synthesises a snapshot instead so design iteration stays available. Stable-reference reuse optimisation deep-equals each new fetch with the prior snapshot so consumers don't re-render on no-op refreshes. - StripeCheckoutPanel — own module, lazy-imported via React.lazy so the Stripe SDK chunk is only fetched when the user reaches step 2. The Stripe SDK itself is already pulled into the main bundle by the existing TrialExpiredBootstrap / TrialStatusBanner — that chunking is out of scope here, but the new modal panel doesn't add extra weight. Detects mock-mode (cs_mock_* client_secret OR no VITE_STRIPE_PUBLISHABLE_KEY) and renders a friendly placeholder + "Continue with mock subscription" button instead of mounting a real iframe with a bad secret. - UpgradeModal — three sequential panels: cap → checkout (lazy Stripe) → confirmation (Welcome to Processor). Cap is held in modal state only; nothing reaches the backend until Stripe completes. Backdrop click + close button reset the step so reopening starts at cap selection. - Plan — loading + error states. Passes useWallet's markSubscribed + updateCap down to PaygFreeLeader / PaygLeader so completion + cap-save trigger a wallet refetch. - Payg.tsx CapEditor — Save button gains an onSaveCap prop threaded through PaygLeader. Currency conversion stays USD-only for V1 (the backend's PATCH /cap takes capUsd); non-USD currencies are a follow-up. - useRenderCount — dev-only render counter. Writes to window.__renderCounts so Playwright + manual devtools can assert components don't re-render excessively. - DevPaygPreview — wires its PaygFreeLeader's onUpgraded prop to flip the localStorage subscription flag so the dev preview also demos the full upgrade cycle (free → modal → subscribed). Verified: full 3-step modal flow (cap → mock checkout → confirmation) renders cleanly via the dev preview route. ./gradlew :saas:compileJava and `vite build --mode saas` both succeed.
Backend (PaygApiController + PaygApiService): - POST /api/v1/payg/dev/mark-subscribed now requires BOTH payg.dev-endpoints.enabled=true (default false) AND ROLE_ADMIN. Returns 404 when disabled so probes can't enumerate. Marked @hidden so it stays out of the public OpenAPI surface. Closes the "any authenticated user can flip their own team to subscribed" hole that would have shipped in the prior commit. - updateCap uses AtomicReference.updateAndGet (CAS) instead of get → set so a concurrent markSubscribed can't lose the subscription id. Frontend (useWallet + StripeCheckoutPanel + Plan + UpgradeModal): - useWallet: monotonic requestId ref so a slow refetch from tick=N can't overwrite a faster one from tick=N+1. Mutations now await the post- refetch settle via an in-flight promise ref, so save-button loading states drop only once the new state is actually visible. - useWallet: dev-preview detection requires import.meta.env.DEV AND the /dev/ path, so a production tenant with a /dev/ URL prefix can't trigger the synthesised fallback. - StripeCheckoutPanel: same DEV+path gate. onError moved to a ref so it doesn't have to live in the effect deps. The single-flight fetchedRef now correctly resets per-cap because UpgradeModal keys the panel on `co:${effectiveCap}` — editing the cap mid-flow forces a fresh Checkout Session for the new amount. - useRenderCount: hook body fully gated on a module-level IS_DEV const so Vite's dead-code-elimination drops the entire counter logic from production bundles (only the useRef ref allocation remains, required by rules-of-hooks). - PaygFreeLeader + PaygFreeMember wrapped in React.memo. Plan's onUpgraded callback hoisted to useCallback so the prop identity stays stable across Plan re-renders (loading/error toggles no longer cascade down to the leaves). Deferred to follow-up commits (noted in PR description): - isAdmin → real team_memberships.role lookup (currently uses ROLE_ADMIN as proxy; correct for now but means most users will land in the MEMBER view even if they're a team owner). - CapEditor's "no cap" toggle (currently only a numeric save). Verified: ./gradlew :saas:compileJava clean, vite build --mode saas clean.
You'd flagged that calling Stripe from Java duplicates the edge function in PR #300. Fixed. Java was doing both wallet reads AND creating Stripe Checkout Sessions. The latter overlaps with `create-payg-team-subscription` (already on SaaS PR #300, 79 tests, uses Stripe Sync Engine + shared SDK plumbing). Routing through Java meant: - Two Stripe integrations to keep in sync (Java + Deno) - Two places for the Stripe secret key to live - Useless proxy hop: FE → Java → edge fn → Stripe - Doesn't match the existing pattern — usePlans already calls `supabase.functions.invoke("stripe-price-lookup", ...)` directly Now: FE → supabase.functions.invoke("create-payg-team-subscription") → Stripe Java keeps: - GET /api/v1/payg/wallet — pure read, no Stripe touch - PATCH /api/v1/payg/cap — temporary stub. Cap updates also touch Stripe (subscription_item.update with new billing_thresholds), so this should move to a new `update-payg-cap` edge function. Marked with a clear TODO in the controller javadoc. - POST /api/v1/payg/dev/mark-subscribed — dev-only mock side-channel, disappears once the real Stripe webhook handler (PR #300) is deployed. Concretely: - Removed PaygApiController#createCheckoutSession + CheckoutSessionRequest/Response records - Removed PaygApiService#createCheckoutSession + CheckoutSessionResult + createMockClientSecret helper - StripeCheckoutPanel now calls supabase.functions.invoke() instead of apiClient.post() - Documented the new boundary in both controller + service class javadocs Verified: dev preview end-to-end flow still works (modal step 1 → step 2 mock placeholder → completion). ./gradlew :saas:compileJava + vite build both clean.
…o Stripe
You'd asked: does cap update really need to touch Stripe? It doesn't.
I was wrong in the prior arch commit calling it a 'stub.' The cap is
permanent in Java.
Stripe has no native hard cap. billing_thresholds.amount_gte is an
early-invoice trigger, not a cut-off. The way you actually cap a
metered subscription is to stop sending meter events — Stripe only
sees what you push. That's an application-layer concern:
- Cap value lives in wallet_policy.cap_units (Postgres)
- Before each billable op: gate the meter-payg-units push on
current_period_spend ≤ cap
- Hit cap → refuse op, return 402 / DEGRADED state
- Stripe never knows the cap exists
So PATCH /api/v1/payg/cap is a single SQL UPDATE — no edge function,
no Stripe round-trip. The 'wants to move to update-payg-cap edge fn'
TODO from the prior commit was wrong; removed.
Updated:
- PaygApiController class javadoc — split rationale is about who owns
the DATA, not who touches Stripe. Java owns app-rule writes
(wallet_policy, cap, sub-caps); edge fns own Stripe mutations.
- PaygApiController#updateCap section comment — points at the real
one-line repository write that lands when the mock comes out.
- PaygApiService#updateCap javadoc — same correction.
- PaygApiService class swap-out plan — cap step no longer references
a non-existent update-payg-cap edge function.
Best practice general rule (recorded in javadoc):
- User-set limits (cap, sub-caps, warn-at %): your DB, your enforcement
- Subscription state, prices, payment methods: Stripe is source of
truth, mirror via Sync Engine for read perf
- Usage events: push to Stripe, gated by your internal cap check
…rt 1)
First chunk of Bucket 5 (cap evaluator + entitlement guard + wallet
controller + cap-setting endpoints). Pure compute layer only — no DB, no
caches, no Spring. The entitlement service (next commit) supplies the
inputs from wallet_policy + payg_shadow_charge sums; this just does the
math.
Ships:
- @RequiresFeature method-level annotation declaring required FeatureGate
values. Per the design doc the annotation is NOT required on every
@AutoJobPostMapping — the guard's default rule is "no annotation +
AutoJob → OFFSITE_PROCESSING". Only AUTOMATION (pipeline) and AI_SUPPORT
(AI proxies) need the explicit annotation.
- CapEvaluator with three static helpers:
- evaluate(spend, cap, warn%, degrade%, degradedFeatureSet)
→ Evaluation(state, featureSet, enabledGates). Single integer compare
on the hot path; defensive against null/zero/misconfigured inputs.
- combineTeamAndMember(teamEval, memberEval) → effective eval,
stricter state, intersected gates. Handles the per-member sub-cap
case from §3.6.
- allEnabled(required, enabled) → predicate the guard calls per request.
- 22 unit tests covering: null/zero cap, warn boundary, degrade boundary,
over-threshold, misconfigured thresholds, member-team combination,
state ordering, gate intersection, mapping FeatureSet → gates,
guard predicate edge cases.
Next commits (same PR):
- EntitlementService — snapshot computation with 30s Caffeine cache
- EntitlementGuard — HandlerInterceptor reading @RequiresFeature
- PaygWalletController — GET /api/v1/payg/wallet (PaygSnapshot DTO matching FE)
- CapAdminController — admin direct cap-setting endpoints
Tracked in notes/PAYG_DESIGN.md §3.6 + §3.7 + §7.6 (PR-C1) and
notes/payg-launch-checklist.html Bucket 5.
… wiring V16 adds wallet_ledger.billing_category, payg_shadow_charge.billing_category + job_source, and pricing_policy_stripe_price.stripe_product_id (operator populates manually). All adds are nullable so pre-V16 rows stay NULL — the interceptor stamps the category for new rows. PAYG stays on a single flat-priced Stripe meter forever; this axis is purely for in-app breakdowns and analytics. The wallet_category_summary view pre-groups debits by team / month / category for the dashboard widget. BillingCategory enum lists BYPASSED first as the default sentinel; no downstream relies on ordinal(). JPA mapping uses STRING so renames stay backward-compatible.
Categorise every billable tool call before the charge pipeline runs. Manual UI tools (BYPASSED) now short-circuit ahead of multipart materialisation and openProcess — no temp files, no shadow row, no DB writes. API-key, AI, and automation calls thread their category through ChargeContext onto the shadow row alongside the originating JobSource so analytics survive job pruning. Precedence (interceptor): X-Stirling-Automation header or @RequiresFeature(AUTOMATION) -> AUTOMATION; @RequiresFeature(AI_SUPPORT) -> AI; ApiKeyAuthenticationToken -> API; else BYPASSED. - PaygChargeInterceptor: determineCategory() resolves category from header + @RequiresFeature + auth principal. Bypass fast-path runs before doPreHandle. New Micrometer counter payg.filter.bypassed counts skipped manual calls. - ChargeContext: now carries BillingCategory as a non-null record component; any context that reaches openProcess is API / AI / AUTOMATION. - JobChargeService.recordShadowRow: copies billingCategory + jobSource onto the shadow row so post-prune analytics joins stand alone. - Tests: extended PaygChargeInterceptorTest with bypass + category resolution (header / annotation / API-key / AI-with-header precedence); existing billable-path tests switched from JWT to API-key auth since plain JWT now short-circuits as BYPASSED. JobChargeServiceTest verifies the shadow row carries billingCategory + jobSource end-to-end. Stripe stays on a single flat-priced meter — category is metadata only.
…E_PROCESSING Build the missing pieces from PR-C1's original plan: - EntitlementService: Caffeine-cached (30s TTL, 10k entries) per-team snapshot of spend vs cap, with invalidate(teamId) for explicit eviction after subscription changes / cap edits. Reads from wallet_policy when present, falls back to a 500-unit free-tier default until PR #6532 lands payg_subscription_id + pricing_policy.free_tier_units_per_cycle. - EntitlementGuard: HandlerInterceptor on @AutoJobPostMapping routes only. Anonymous + billable gate (AUTOMATION / AI_SUPPORT) -> 401 SIGNUP_REQUIRED. Authenticated + missing gate -> 402 FEATURE_DEGRADED with state, cap, spend, periodEnd, missingGates. Fails open on any internal error. - MINIMAL semantics fix: CapEvaluator.gatesFor(MINIMAL) now returns {OFFSITE_PROCESSING, CLIENT_SIDE}. DEGRADED state still blocks AUTOMATION + AI_SUPPORT, but manual server-side tools stay available -- which is the v2.1 PAYG framing (manual tools are free). - RequiresFeature: extend @target to TYPE so class-level annotation is legal (the charge interceptor was already checking bean type). - Register guard in PaygWebMvcConfig at order 1100, after the charge interceptor (1000). 402 short-circuits handler but charge interceptor's afterCompletion still cleans up temp files. Tests: 50 new test cases across EntitlementServiceTest (12), EntitlementGuardTest (13), updated CapEvaluatorTest (25, MINIMAL assertions). All 17 payg test suites green.
GET /api/v1/payg/wallet returns the snapshot the FE useWallet hook consumes — derived from EntitlementService (spend / cap / period), PaygTeamExtensions (subscription state), and the wallet_category_summary view (per-category breakdown). Leader callers get the team roster + per-member sub-caps; member callers see an empty roster. PATCH /api/v1/payg/cap updates wallet_policy.cap_units (no Stripe call — cap is application-layer, enforced via the entitlement guard) and invalidates the team's snapshot cache so the next read reflects the change immediately. Leader-only — authorised inside the method since the team is derived from the caller, not a path variable. Adds findPrimaryMembership(userId) to TeamMembershipRepository so the wallet endpoint can resolve the caller's team + role in a single query. Tests: 11-case pure-Mockito slice covering free vs subscribed, leader vs member, no-cap, anonymous → 401, teamless → empty free snapshot, plus the cap update endpoint's leader-only enforcement and cache invalidation. Known dependency on PR #6532: stripeSubscriptionId stays null until the payg_subscription_id column lands; "subscribed" derived from stripe_customer_id presence as a stand-in. billableLimit fallback of 500 units mirrors EntitlementService.DEFAULT_FREE_TIER_UNITS until pricing_policy.free_tier_units_per_cycle ships.
…Commit hook
Push billable usage to Stripe via Supabase meter-payg-units edge function after the
ledger DEBIT durably commits. Closes the "we tracked it" → "Stripe knows" loop.
PaygMeterReportingService POSTs {team_id, stripe_customer_id, units, idempotency_key,
metadata.category} to a config-driven endpoint. Both payg.meter.endpoint and
payg.meter.service-role-token default empty → no-op in local dev / tests. Never
throws; non-2xx and exceptions bump payg.meter.errors counter and log WARN.
JobChargeService.close() delegates to JobService.close() and registers an
afterCommit synchronization that fires the meter event for paid teams. Skips
when: shadow row is REFUNDED, category is BYPASSED, team has no stripeCustomerId
(free tier — ledger entry suffices), or no synchronization is active (defensive).
Idempotency key: "process:<jobId>:close" — deterministic across pod retries and
the reconciliation backfill so Stripe never bills twice for one close.
Tests:
- PaygMeterReportingServiceTest: happy path body+headers, 5xx, ConnectException,
RuntimeException, blank/null endpoint, zero units, blank service-role-token.
- JobChargeServiceTest extended: subscribed posts; free-tier skips; refunded skips;
bypassed skips; no shadow row skips; no team-ext row skips; meter exception is
swallowed inside afterCommit; close() without an active transaction skips the
meter post but still closes the job.
Free-tier detection uses stripeCustomerId == null as the established pre-#6532
stand-in (see PaygWalletController for the same pattern). Tightens to
paygSubscriptionId != null when PR #6532's payg_team_extensions column lands.
The PaygApiController/PaygApiService mock on this branch is being superseded by the real backend on payg-c1-cap-entitlement (PR #6574 — PaygWalletController, served by the wallet_category_summary view + team_memberships join). Once #6574 lands in main, the mock files would clash, so they're removed here. The useWallet Wallet type gains three fields to match the real BE's WalletSnapshot response: categoryBreakdown (api/ai/automation buckets), members (per-member sub-cap rows for the leader's dashboard), and recent (activity feed, V1 = []). Dev preview wallet synthesis populates the new fields with tier-distinguishable mock values so the design-time preview still exercises both the free and subscribed visuals. markSubscribed now swallows 404s from /api/v1/payg/dev/mark-subscribed since that endpoint disappears with the BE mock. In real-BE mode the Stripe customer.subscription.created webhook flips the team to subscribed, so an info-level log + continuing to refetch is the right move for the FE — no broken modal completion when the dev hook isn't there.
…s AUTOMATION The saas PaygChargeInterceptor classifies a request as AI / AUTOMATION / API / BYPASSED using a fixed precedence: X-Stirling-Automation header > @RequiresFeature(AUTOMATION) > @RequiresFeature(AI_SUPPORT) > API-key auth > BYPASSED (manual UI). Plug the AI surface and the automation sub-step path: AI controllers (saas → easy import): - AiCreateController, AiCreateInternalController, AiProxyController each get class-level @RequiresFeature(AI_SUPPORT). The interceptor already falls back to the bean type via AnnotationUtils.findAnnotation when no method-level annotation is present. Automation sub-step header (InternalApiClient in common): - Every caller of InternalApiClient.post() is a parent automation flow (PipelineProcessor, AiWorkflowService, PolicyExecutor) running a child tool via loopback HTTP. Tag every dispatch with X-Stirling-Automation: true so the child step bills as AUTOMATION regardless of its own annotation — i.e. an AI-OCR step inside a policy run is correctly billed AUTOMATION rather than AI. Deviation (documented in test javadoc): PipelineController lives in core and PolicyController lives in proprietary. Neither module can import @RequiresFeature from saas without a forbidden upward dependency (both build under STIRLING_FLAVOR=core/proprietary where saas is absent). Promoting RequiresFeature + FeatureGate to common is a non-trivial refactor (touches 18 files across multiple C-bucket commits) and out of scope for C4. The X-Stirling-Automation header covers sub-step dispatch via InternalApiClient; direct user-facing calls to /api/v1/pipeline/handleData therefore bill as BYPASSED (WEB) or API (api-key auth) rather than AUTOMATION. Annotating those controllers is left to a future refactor that promotes the cap types to common. Tests: - RequiresFeatureAnnotationRolloutTest pins the AI controller classifications so a refactor can't silently regress to BYPASSED. - InternalApiClientTest.postTagsRequestAsAutomation captures the HttpHeaders submitted to RestTemplate and asserts the marker.
useWallet gains updateSubCap (PATCH /api/v1/payg/sub-caps/{userId},
returns effective+clamped so the UI can surface server-side clamping)
and openPortal (POST /api/v1/payg/portal-session, opens the Stripe-
hosted URL in a new tab).
MemberSubCaps now reads from wallet.members[] via a new members prop —
the synthesised 3-row mock only applies when no prop is supplied (dev
preview). Each row has an inline NumberInput editor (Save / Cancel /
Remove cap) with success / clamped / error toasts.
StripePortalLink switches from a static <a href> to a Button that calls
openPortal() with a loading state. On 404 / 503 it shows a friendly
"portal isn't available right now" toast — local dev without the
Supabase edge function configured no longer errors out.
…tor were short-circuiting before reading @RequiresFeature C4 review surfaced two interlocking bugs: both interceptors gated on `@AutoJobPostMapping` BEFORE consulting `@RequiresFeature`, so the AI controllers' class-level `@RequiresFeature(AI_SUPPORT)` had no effect (AI controllers carry no `@AutoJobPostMapping` — they are JSON-bodied proxies, not multipart tool POSTs). EntitlementGuard (major #2 — functional entitlement gap): - A team without AI entitlement could hit `/api/v1/ai/*` freely because the guard's scope check returned early on routes lacking `@AutoJobPostMapping`. - Fix: in-scope == (`@AutoJobPostMapping` present) OR (`@RequiresFeature` present, method- or class-level). Routes with neither still skip (admin / info / config endpoints). PaygChargeInterceptor (major #1 — billing classification gap): - Same short-circuit, same root cause. `determineCategory` was never reached for AI controllers, so the AI billing category would never be set even if those routes did carry multipart payloads. - Fix: same gate widening. Routes without multipart inputs still short-circuit inside `doPreHandle` without touching the charge service — the multipart materialisation logic only runs when actual file parts are present. Tests: - `EntitlementGuardTest`: two new tests covering (a) method-level `@RequiresFeature` without `@AutoJobPostMapping` returns 402 when entitlement is missing, and (b) class-level `@RequiresFeature` (AiCreateController shape) returns 401 for anonymous users. - `PaygChargeInterceptorTest`: three new tests — `@RequiresFeature`-only endpoint with no multipart body short-circuits without counting as BYPASSED; same shape with multipart body lands in `BillingCategory.AI`; class-level `@RequiresFeature` is resolved via beanType lookup. Build + verification: - `STIRLING_FLAVOR=saas :saas:compileJava :saas:compileTestJava` — clean. - `:saas:test --tests stirling.software.saas.payg.*` — PASS, JaCoCo thresholds met (line 25.78%, instruction 27.14%, branch 24.40%).
…ions
F2 review fixes on useWallet.ts:
- openPortal: switched from window.open(url, "_blank", ...) after an
await to window.location.assign(url). Browsers treat window.open
after an awaited promise as non-user-gesture and silently popup-block
it; the sibling ManageBillingButton already uses same-tab redirect.
Stripe's customer portal is a full-page experience and returns the
user to returnUrl on close, so a popup buys us nothing.
- openPortal + updateSubCap: now pass { suppressErrorToast: true } as
the third axios option. The global response interceptor in
saas/services/apiClient.ts otherwise shows a generic toast that
stacks on top of the component-level friendly toast (PORTAL_NOT_
CONFIGURED on Stripe portal, clamp/validation errors on sub-caps).
Matches the established pattern in saas/services/userManagementService.ts.
Adds the sub-cap mutation endpoint on PaygWalletController. Leaders set
team_memberships.cap_units for a member of their team; values above the
team's wallet_policy.cap_units are silently clamped (returning
clamped=true) rather than rejected, so the leader's intent ("don't let
this member spend more than X") is honoured by the effective ceiling.
A null capUnits clears the sub-cap entirely.
Authorisation: caller must be LEADER (else 403); target userId must be
a member of the same team (else 404). No team-id on the path — derived
from the caller — to keep cross-team probing impossible.
Member capUnits is already exposed in GET /wallet's members[] field
(WalletSnapshotResponse.MemberRow.capUnits from C6); no projection
change needed.
Tests: 8 new MockitoExtension cases covering below-cap, above-cap clamp,
null-clear, no-team-cap-no-clamp, member-403, different-team-404,
no-team-403, anonymous-401. All 19 PaygWalletControllerTest cases pass.
Classifies the EntitlementGuard's PAYG sentinels in the apiClient response interceptor before the existing 401/refresh logic and surfaces appropriate UI: - 402 FEATURE_DEGRADED → persistent warning toast "You've hit your free monthly limit" with a "Go to billing" CTA that opens the Plan tab via the existing appConfig:open + appConfig:navigate event bus. - 401 SIGNUP_REQUIRED → dispatches payg:signupRequired CustomEvent carrying the category from the response body. A new top-level SignupRequiredBootstrap (mirroring TrialExpiredBootstrap's pattern) listens for the event and opens a Mantine modal explaining the free 500-op/month allowance with a "Sign up free" CTA. Both paths short-circuit the existing session-refresh / redirect-to- login logic so anonymous users hitting billable endpoints don't get bounced to /login when /signup is what they need. Tests: 12 new unit tests on the classifier + handler (parsing edge cases, button callback wiring, custom-event dispatch). Existing apiClient.test.ts still passes — the new path is gated on body.error sentinels so plain 401s flow through the unchanged refresh path.
…rtal-session
Adds the third PAYG wallet endpoint that mints a Stripe-hosted billing-portal
session via the Supabase create-customer-portal-session edge fn. The FE "Manage
billing" button calls this and redirects to the returned Stripe URL.
- PaygWalletController gains POST /api/v1/payg/portal-session
- Status map: 200 + {url}, 401 anonymous, 403 no-team, 404 TEAM_NOT_SUBSCRIBED,
502 PORTAL_UNAVAILABLE (edge fn 5xx / success=false / unreachable), 503
PORTAL_NOT_CONFIGURED (blank endpoint config — local dev / unit tests).
- Reuses the saasRestTemplate + Bearer service-role token pattern from
PaygMeterReportingService (C10) so portal config stays alongside the rest of
the Supabase-fronted billing pipeline.
- isSubscribed() helper now shared between GET /wallet and POST /portal-session
so "team has Stripe customer" is sourced from one place.
- application-saas.properties: payg.portal.endpoint + service-role-token,
both default empty so local dev returns 503 gracefully.
- 6 new tests in PaygWalletControllerTest covering happy path (verifies
Bearer header + team_id + return_url forwarded), team-without-customer 404,
edge fn success=false → 502, RestTemplate throws → 502, blank endpoint
→ 503, and anonymous → 401.
…returnUrl, swap FE to Java endpoint - PaygWalletController.createPortalSession no longer holds a JPA connection during the outbound RestTemplate.exchange. DB work (membership + payg_team_extensions) moves into a small loadPortalContext helper; the HTTP call runs after the helper returns. JOIN FETCH on findPrimaryMembership means no lazy access happens outside the implicit per- repo tx, so a slow Supabase edge fn no longer pins HikariCP for 30s. - Add payg.portal.allowed-return-hosts allowlist (CSV). Caller-supplied return_url is parsed (URI) and its host is matched case-insensitively against the allowlist before forwarding to the edge fn; scheme is restricted to http/https so javascript:/data: URLs can't slip past. Empty allowlist rejects any caller-supplied return_url with 400 INVALID_RETURN_URL — operator must opt-in explicitly. Defense in depth: doesn't rely on the edge fn to enforce its own allowlist. - @RequestBody is now required = false so the FE may POST with no body and let the edge fn fall back to its configured default. Drops the dead req != null guard. - ManageBillingButton.tsx now POSTs /api/v1/payg/portal-session via apiClient instead of calling supabase.functions.invoke('manage-billing') directly, so the new endpoint is actually on the live path. Maps the controller error codes (TEAM_NOT_SUBSCRIBED, INVALID_RETURN_URL, PORTAL_NOT_CONFIGURED, PORTAL_UNAVAILABLE) to user-facing copy. - Tests: add INVALID_RETURN_URL coverage (off-allowlist + javascript: scheme), empty allowlist with null returnUrl bypass, and null-body acceptance. All 16 PaygWalletController tests pass; coverage targets met.
PaygFree leader + member, UpgradeModal step 1-3, StripeCheckoutPanel loading/error/mock states, and the FREE_TIER_EXHAUSTED toast are now all routed through i18n with English fallbacks. New [payg.*] sections added to translation.toml: checkout, confirm, error, exhausted, free.*, signupRequired, stripe.toast, subcaps.editor/toast, upgrade.*. Existing SignupRequiredBootstrap copy was already wrapped — keys backfilled into TOML so en-GB loads them at runtime instead of falling back. paygErrorInterceptor.ts lives outside the React tree so uses i18n.t() directly (same pattern as backendHealthMonitor). StripeCheckoutPanel stashes t in a ref so the single-flight effect deps don't have to include it. No new TS errors vs baseline (153 pre-existing saas-resolver hits remain unchanged).
The dev preview was a scaffolding route for iterating on the PAYG plan UI before real wallet endpoints existed. Now that useWallet calls the real GET /api/v1/payg/wallet and the upgrade modal is wired end-to-end, the preview route + component are dead code. Kept (graceful-degradation paths): - useWallet.ts buildDevPreviewWallet + localStorage fallback — still useful when the /wallet endpoint is unreachable locally - StripeCheckoutPanel mock-mode placeholder — still used when VITE_STRIPE_PUBLISHABLE_KEY is unset
PAYG (PaygChargeInterceptor + JobChargeService) replaces the legacy credit pipeline entirely. Until now both ran side-by-side in saas mode, producing CreditSuccessAdvice noise + occasionally blocking new users with "Team credits exhausted" because they have no team_credits row. Gate the five legacy credit beans behind the `legacy-credits` profile so they only load when explicitly activated: - CreditInterceptorConfig - CreditController - CreditErrorAdvice - CreditSuccessAdvice - UnifiedCreditInterceptor Normal saas runs (--spring.profiles.active=saas,dev) skip them. If something downstream needs the legacy path back, activate explicitly with `--spring.profiles.active=saas,dev,legacy-credits`. The cucumber suite in testing/cucumber/ that exercises legacy credit behaviour will need this profile added to its harness too — separate fix when we restore that CI line.
…et to PaygFree
Three connected fixes:
1. **400 from upgrade button**: StripeCheckoutPanel was calling
create-payg-team-subscription with {capUsd, noCap, returnUrl} and
expecting back a Stripe Embedded Checkout client_secret. That edge
function creates a subscription directly and returns
{customer_id, subscription_id} — no client_secret to mount the
Embedded Checkout iframe with. Plus it required team_id which the
FE wasn't sending.
Switch to create-checkout-session, which IS the Embedded Checkout
session factory. Request body becomes
{team_id, currency, success_url, cancel_url}. Cap is no longer set
at checkout time — it's application-layer state applied via PATCH
/payg/cap after the subscription lands.
2. **Stale "62 of 500"**: PaygFreeLeader/PaygFreeMember called a local
useFreeMock() that hardcoded billableUsed=62, billableLimit=500.
Replaced with useFreeSnapshot() backed by the real useWallet().
Falls back to a zeroed view (0 / 500) for the brief moment before
the first snapshot arrives.
3. **team_id plumbing**: To make (1) work the FE has to know its own
team_id. The wallet endpoint already had it on the BE — added it
to WalletSnapshotResponse, populated from membership.getTeam().getId().
FE Wallet interface gets a teamId field, PaygFreeLeader pulls it
off the wallet and threads it through UpgradeModal -> CheckoutStep
-> StripeCheckoutPanel. UpgradeModal is gated until teamId is
loaded — no point opening if we can't create a session.
BE compile + FE saas tsc both clean.
Companion to the previous "gate credit interceptors behind legacy-credits profile" commit. With the BE controller disabled GET /api/v1/credits 404s, so the FE was firing failed network requests on every login, every config- modal open, and every API Keys page visit. Two call sites: - useSession.fetchCredits — fired in auth bootstrap + on session refresh - apiKeys/hooks/useCredits — fired when the API Keys page mounts Both now early-return unless VITE_LEGACY_CREDITS_ENABLED=true is set at build time. Symbols (creditBalance, refreshCredits, useCredits) stay in place so existing consumers don't crash — they just always see null/empty. PAYG-aware components read from useWallet() instead.
Companion to the BE "gate credit interceptors behind legacy-credits profile"
commit. With the BE controller dark, the FE callers were still scaffolded
to fire (gated behind VITE_LEGACY_CREDITS_ENABLED). Drop the gate
entirely — the legacy path isn't coming back, and PAYG covers all the
flows that previously called it.
Three sites stripped:
* useCreditCheck.ts — was a pre-flight balance check before billable
tool calls. PAYG's 402 FEATURE_DEGRADED interceptor (paygErrorInterceptor)
is reactive and more accurate (no FE/BE balance race). Hook signature
preserved as a no-op so useToolOperation compiles unchanged;
checkCredits always resolves to null.
* UseSession.tsx — fetchCredits + refreshCredits are now empty no-ops.
creditBalance / creditSummary / subscription state stays null forever.
Consumers that destructure useAuth() still compile; values just stay
null. The four fetchCredits(newSession) call sites in the auth-state
machine are left in place — no-op invocations are zero cost and
removing them would touch unrelated session-refresh logic.
* apiKeys/hooks/useCredits — same shape, `data` always null, downstream
UsageSection renders its empty state.
* apiClient interceptor — drop the dead /api/v1/credits debug-log branch.
No call sites need to change downstream. Anyone reading creditBalance
sees null and the existing nullable handling kicks in. Will revisit a
broader removal (delete the symbols entirely + the credits.ts types)
once nothing in the desktop / smoke tests reads them.
Old width forced internal scrolling once the Stripe Embedded Checkout iframe mounted at step 2 — Stripe's recommended content area is 750px, the old modal gave it about 480px and the iframe responded by scrolling. 800px frame minus the 22px body padding each side = 756px content area, just past Stripe's recommended minimum. Cap step + confirm step look fine at this width — their internal text widths are already capped (upm-confirm__body / upm-section-help) so they don't stretch awkwardly.
…auth_id
Fixes split-brain between Java and Supabase. Background:
* `supabase_auth_id` is the canonical column on stirling_pdf.users —
came in with the initial Supabase schema migration (Sep 2025) and
is referenced by every RLS policy on the Supabase side plus the
new public.payg_* SECURITY DEFINER RPCs (notably
payg_get_checkout_context which gates PAYG checkout).
* PR #6384 ("SaaS Consolidation") added a parallel `supabase_id`
column via Flyway V2 and pointed the User JPA entity at it.
* Post-#6384 users had supabase_id populated, supabase_auth_id NULL,
and every membership check in PAYG (and in V14's RLS policies)
failed for them because those checks look at supabase_auth_id.
Changes:
- User.java: @column(name = "supabase_id") → "supabase_auth_id".
Field name kept as supabaseId to avoid a wide caller refactor.
- UserCreditRepository.java: five native UPDATE/SELECT queries that
JOIN through users on supabase_id → renamed to supabase_auth_id.
- V17 Flyway migration: backfills supabase_auth_id from supabase_id
where NULL, then drops supabase_id + uk_users_supabase_id.
The Supabase twin migration lives on the SaaS repo (commit bf08a14fc).
Both repos should be deployed together — once the Java JAR is up with
the new entity mapping AND the migration has applied, no further user
will be split.
Existing dev rows that already have BOTH columns populated end up with
the same value in supabase_auth_id (backfill is `... WHERE
supabase_auth_id IS NULL`). Rows with only supabase_id populated get
their value lifted. Rows with only supabase_auth_id stay as-is.
Verified: ./gradlew :stirling-pdf-saas:compileJava clean.
…ng to Stripe…"
React 18 strict-mode dev mounts effects twice. The previous version used
a ref-guarded single-flight to avoid two network calls per mount:
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
// ... fetch + setLoading(false) in finally guarded by !cancelled
});
That interacted catastrophically with the cancellation pattern:
1. Mount 1: fetchedRef → true, fetch starts.
2. Strict-mode unmount: cleanup sets cancelled = true.
3. Strict-mode remount: fetchedRef is already true → early return,
no fetch fires.
4. Mount 1's fetch resolves: `if (cancelled) return` skips
setClientSecret, and `if (!cancelled) setLoading(false)` skips
too — loading stays true forever.
Net result: "Connecting to Stripe…" placeholder never goes away even
though the edge fn returned the client_secret successfully.
Fix: drop fetchedRef entirely. Each mount runs its own fetch; the
cancelled mount's response is discarded by the `cancelled` check
before setClientSecret. Two network calls in dev is an acceptable
cost — prod has no strict mode so single fetch.
Also moved the cancelled check after the error checks (`!data?.client_secret`,
`invokeError`) — those throws should always reach the catch even if the
mount unmounted, since they signal a real backend bug worth surfacing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch combines the in-flight PAYG backend and frontend PRs so the full end-to-end flow can be exercised against one deployment. Each underlying PR ships independently to main; do not squash-merge this one.
payg-c1-cap-entitlement) — V14/V15/V16 migrations,BillingCategory,EntitlementService+EntitlementGuard, realPaygWalletControllerwith category breakdown,PaygMeterReportingService,/portal-sessionproxy, sub-cap PATCH, AI/Pipeline/Policy annotations.payg-plans-and-upgrade) — PAYG plan page, lazy-loaded Stripe Embedded Checkout,useWalletagainst real BE, sub-caps editor, Stripe portal button, 402/401 interceptor toast, en-GB i18n.payg-stripe-wireup(Stirling-PDF-SaaS PR Improve the OCR instructions #300) —create-payg-team-subscription,meter-payg-units,create-customer-portal-session,create-checkout-session,payg-subscription-webhook(Deno edge functions, all DB-driven price lookup — noSTRIPE_PAYG_PRICE_ID_*env vars).What you need to test
Database side (Supabase test project):
Supabase edge fns: deploy from `payg-stripe-wireup` to your test project. Required secrets: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`. Point your Stripe webhook at `/functions/v1/payg-subscription-webhook` listening on `customer.subscription.` + `invoice.`.
App: `./gradlew :stirling-pdf-saas:bootRun --args='--spring.profiles.active=saas'` and `cd frontend && npm run dev`. Or just use the stirlingbot V2 deploy that lands on this PR.
Journey to exercise
Known follow-ups (not blocking this branch)