Skip to content

PAYG E2E test bundle — combined BE (#6574) + FE (#6579)#6589

Draft
ConnorYoh wants to merge 37 commits into
mainfrom
payg-e2e-test
Draft

PAYG E2E test bundle — combined BE (#6574) + FE (#6579)#6589
ConnorYoh wants to merge 37 commits into
mainfrom
payg-e2e-test

Conversation

@ConnorYoh

Copy link
Copy Markdown
Member

⚠️ Not for merge — testing only

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.

  • BE: PR-C1: Cap evaluator + entitlement guard + wallet controller (Bucket 5) #6574 (payg-c1-cap-entitlement) — V14/V15/V16 migrations, BillingCategory, EntitlementService + EntitlementGuard, real PaygWalletController with category breakdown, PaygMeterReportingService, /portal-session proxy, sub-cap PATCH, AI/Pipeline/Policy annotations.
  • FE: PAYG Plan page + upgrade flow (mock-backed) #6579 (payg-plans-and-upgrade) — PAYG plan page, lazy-loaded Stripe Embedded Checkout, useWallet against real BE, sub-caps editor, Stripe portal button, 402/401 interceptor toast, en-GB i18n.
  • SaaS (separate repo): 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 — no STRIPE_PAYG_PRICE_ID_* env vars).

What you need to test

Database side (Supabase test project):

  1. `UPDATE stirling_pdf.pricing_policy SET free_tier_units_per_cycle = 500 WHERE is_default = TRUE;`
  2. `INSERT INTO stirling_pdf.pricing_policy_stripe_price (policy_id, stripe_product_id, stripe_price_id) VALUES (, 'prod_XXX', 'price_XXX');` — use the PAYG product/price you set up in Stripe.

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

  1. Anonymous → tries an AI/API tool → 401 SIGNUP_REQUIRED toast → redirect to signup.
  2. New team leader → manual tools work freely (BYPASSED) → AI tool consumes free tier → at 500 units the FEATURE_DEGRADED toast appears and Plan tab prompts for a card.
  3. Add card → Stripe Embedded Checkout opens in-modal → `customer.subscription.created` webhook links subscription → meter events start posting on subsequent billable calls.
  4. Sub-caps → leader sets a per-member cap → member's wallet view shows their cap → exceeding it returns 402.
  5. Manage billing → portal button opens Stripe portal in same tab → return URL lands back on Plan tab.

Known follow-ups (not blocking this branch)

  • Manual cucumber test for the Stripe webhook → DB link path (planned post-merge).
  • Customer-portal UI surfacing of cancellation state (planned).

reecebrowne and others added 30 commits June 8, 2026 21:23
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
Combines BE (#6574 payg-c1-cap-entitlement) + FE (#6579 payg-plans-and-upgrade)
for end-to-end testing. Not for merge to main — each PR ships independently.
@stirlingbot stirlingbot Bot added Java Pull requests that update Java code Front End Issues or pull requests related to front-end development Translation Test Testing-related issues or pull requests labels Jun 9, 2026
ConnorYoh added 6 commits June 9, 2026 19:06
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.
@stirlingbot stirlingbot Bot added the Security Security-related issues or pull requests label Jun 9, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Front End Issues or pull requests related to front-end development Java Pull requests that update Java code Security Security-related issues or pull requests Test Testing-related issues or pull requests Translation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants