Skip to content

Text-only live sessions + Stripe-metadata-driven subscriptions (Council 004)#48

Merged
navidshad merged 84 commits into
mainfrom
dev
Jun 8, 2026
Merged

Text-only live sessions + Stripe-metadata-driven subscriptions (Council 004)#48
navidshad merged 84 commits into
mainfrom
dev

Conversation

@navidshad

@navidshad navidshad commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

📋 Summary

This PR introduces a new text-only conversation mode for live sessions, improving support for non-ASCII bundle titles with UTF-8-safe base64 encoding. It also includes fixes to preserve Gemini thought signatures, restrict text-session models to a priced allow-list, and clean up empty parameters in tool calls. Additionally, there are multiple fixes and features related to subscription handling, billing, adaptive pricing, voice-minute metering, and improved gating of features based on entitlements. Several CI, testing, and analytics improvements are included as well.

🔗 Related Tasks

#86exr424h - Implement text-only live conversation mode and related live-session fixes
#S1 - Add Stripe metadata entitlement parser and resolver
#S2 - Grant entitlements from Stripe metadata with idempotency
#S3 - Build getSubscriptionPlans from live Stripe with cache
#S4 - Add voice-minute fields to subscription and seed on grant
#S5 - Drive feature gating from entitlements, unify Starter source
#S6 - Rewrite setup script for Council 004 GBP products and voice packs
#S7 - Single-currency GBP checkout with Stripe Adaptive Pricing
#S8 - Remove dead TIERS registry data and Currency type
#S9 - Render pricing page from backend plans, drop currency picker
#S10 - Cover metadata parser, plans cache, webhook idempotency in tests
#S11 - Voice top-up packs: grant webhook and active_top_ups payload
#S12 - Voice balance meter and "This month" section
#S13 - Voice-cap banners and start-voice-chat modal
#S14 - Voice top-up purchase flow and billing settings
#S15 - Shared FeatureLocked panel and session gating
#S16 - Cap Reader text chat usage limits
#S17 - Reader text-chat counter and Reader-specific limit copy

📝 Additional Details

  • Improved subscription page UI with Starter free-tier usage card and real-time usage data
  • Animated waiting states and redirects after successful payments
  • Playwright test helpers for browser validation and automated tests for subscription tier ladder
  • CI updates including node version bump and environment variable fixes for E2E tests
  • Fixes to avoid horizontal overflow in layout and suppress unnecessary modals
  • Analytics event alignment with metrics plan and flashcard review event improvements

📜 Commit List

  • 0f0d48f fix(ci): set DASHBOARD_BASE_URL so getSubscriptionDetails resolves a paid tier; revert period diag
  • d9abac4 feat(live-session): add text-only conversation mode #86exr424h
  • 5674854 fix(live-session): restrict text-session model to the priced allow-list #86exr424h
  • 82ebc77 fix(live-session): preserve Gemini thought_signature so text tool-calling works #86exr424h
  • 636a25a perf(live-session): cache static text-turn prefix to cut token cost #86exr424h
  • aad0f83 fix(live-session): drop empty parameters from no-arg finish_practice tool #86exr424h
  • 7762f00 feat(subscription): script-provision the Stripe portal config, with setup guards and a startup check
  • a28b485 fix(subscription): apply plan upgrades fully (tier, budgets, label) and block stacking
  • 6248228 feat(subscription): show all plan entitlements on the active-plan "This month" card
  • 4f0563c feat(subscription): shared FeatureLocked panel — gate /statistic, refactor /sessions (S15)
  • 0b27c00 feat(subscription): render pricing page from backend plans, drop currency picker (S9)
  • 9bb38f5 feat(gateway): single-currency GBP checkout with Stripe Adaptive Pricing (S7)
  • f7ab65b feat(subscription): gate the sessions page via list-live-sessions RPC and debit voice minutes on session end
  • 31355ef feat(subscription): show Starter as a plan card with current-plan and downgrade-to-free states
  • 1274a81 fix: merge voice minutes into freemium session card as inline pill
  • 8f44a04 fix: refresh voice-minute balance when a live session ends
  • 914c210 fix(analytics): fire flashcard_review_started only when a review has cards
  • 001209f feat(analytics): convention-named lifecycle events + missing engagement events
  • b3ecf90 Merge pull request make subscription tiers stripe metadata driven adaptive pricing council 004 rollout navid shad #86ext1gpf  #46 from codebridger/CU-86ext1gpf_Make-subscription-tiers-Stripe-metadata-driven-adaptive-pricing-Council-004-rollout_Navid-Shad
  • 7f2ce0d test(subscription): add reference executor for the tier-ladder E2E + correct collection names/userId in the specs
  • 94de97e test(subscription): add AI-agent E2E spec suite for the tier ladder + enforcement
  • 1a6c4e2 Merge pull request fix: suppress the global tier-limit modal when a page renders the loc… #45 from codebridger/CU-86ext1gpf_Make-subscription-tiers-Stripe-metadata-driven-adaptive-pricing-Council-004-rollout_Navid-Shad

navidshad and others added 30 commits May 27, 2026 00:58
The Gemini Live API is audio-out only, so a text-only practice path runs
through a new self-contained `live_session_text` server module using the
standard `generateContent` API: server-held session state, the same function
calling (activate_phrase/finish_practice), and per-turn token billing. It's
gated on AI credits only (no freemium session slot) with model-aware pricing.

`/practice/live-session` becomes a thin dispatcher that resolves the request and
routes to dedicated voice/text pages; the system prompt and tool definitions
live once in utils/livePractice.ts so the two transports can't drift. The
request now carries a resolved `phraseIds` list (+ title) — selection happens at
the entry point, which also fixes a random session re-selecting on refresh. The
extension's "Practice now" deep link is unchanged.

Adds a Voice/Text toggle to the start form, surfaces text sessions in history,
and renders chat bubbles as markdown with dir="auto" for RTL-aware text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tool #86exr424h

Gemini's generateContent rejects a function declaration whose OBJECT parameter
has empty `properties` (400). Since the tools array is sent on every turn, that
failed the whole turn and neither activate_phrase nor finish_practice could
fire. A no-arg tool should simply omit `parameters`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…st #86exr424h

`model` is client-supplied. create-text-session now validates it against
ALLOWED_TEXT_MODELS (the keys of the pricing table) before any credit check or
write, rejecting an unpriced/typo model that would otherwise be stored and
billed at the fallback rate. Also refreshes the text-model price table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ling works #86exr424h

Gemini 3 attaches a thoughtSignature to each functionCall part and requires it
to be echoed back when the call is fed into history. text-turn rebuilt the
model's function-call turn from `res.functionCalls` alone, dropping the
signature, so the tool-result round-trip failed with 400 INVALID_ARGUMENT — the
card highlighted but the coach never replied. Persist the model's actual
response content (`res.candidates[0].content`) instead, which keeps the
signature. Adds a regression test, surfaces the real server error in the
client log (was an opaque object), and pins the text model to gemini-3.1-flash-lite.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…86exr424h

The text-turn loop re-sent the system prompt + phrase list + tool
declarations on every turn, so input tokens (and cost) grew turn over
turn and dominated a session's bill.

Hold that static prefix in an explicit Gemini context cache and reference
it via `cachedContent`, so it is no longer re-sent — or re-billed at the
full input rate — each turn:
- `ensureTextCache` lazily creates the cache (systemInstruction + tools),
  reuses it across turns, recreates it near its TTL, and falls back to
  inlining the prefix when creation fails (e.g. the prefix is under the
  model's cache floor: 2048 tokens on flash-lite, 1024 elsewhere).
- Cached tokens are billed at each model's published cache-hit rate via
  the new explicit `cachedText` price; `promptTokenCount` already
  includes them, so the full-rate input line subtracts them.
- Cache state (name/expiry/disabled) is persisted on the session record.

Verified against the live API: cachedContentTokenCount is non-zero on
every turn and a ~3.1k-token session's turn cost dropped ~82%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The PR title becomes the squash-merge commit message, so it must follow the
same convention as commits in CLAUDE.md. Replace the vague "concise,
descriptive title" placeholder with explicit Conventional-Commits guidance
(format, allowed types, the feat/fix/perf/BREAKING → semver mapping, length
cap, and an example) so the next PR title is right on the first try.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…nly-live-conversation_Navid-Shad

feat(live-session): add text-only conversation mode #86exr424h
…er (S1)

ADR-004 makes Stripe product metadata the source of truth for tier
entitlements. This adds the typed, validating parser/resolver that every
later subtask reads through:

- parseTierMetadata(product): pure, zod-validated -> typed Entitlements.
  Loud on failure (EntitlementParseError) for missing keys, bad numbers,
  out-of-range credits/voice, unknown schema_version, bad bools/caps —
  never guesses a value.
- credits_granted / voice_minutes_granted carry hard min/max bounds; a
  typo (missing or extra zero) is rejected, not granted.
- tier_id validated against a fixed code list (reader/learner/coach); it
  is a DB key, so never free text. Starter (free) has no product.
- resolveEntitlements(stripe, {priceId|productId}): product-keyed
  short-TTL cache + stable price->product map; clearEntitlementsCache()
  for the product.updated webhook (S3).

Purely additive: no existing file changed, build + 20 tests still green.
Display copy stays in code keyed by tier_id (not metadata).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ice packs (S6)

Replaces the Council 002 (per-currency Learner/Fluent) provisioning with the
Council 004 ladder, GBP base + Adaptive Pricing:

- Creates/reuses Reader, Learner, Coach products, each carrying the full S1
  entitlement metadata (schema_version, credits_granted, voice_minutes_granted,
  caps, flags, duration_days, trial_days). Metadata is self-validated with the
  server's own parseTierMetadata() so a typo fails before reaching Stripe.
- ONE GBP monthly + ONE GBP annual recurring price per tier (no per-currency
  prices): Reader £4.49/£42.99, Learner £10.99/£104.99, Coach £24.99/£239.99.
- Two one-shot GBP voice top-up packs: 30 min £4.49, 120 min £15.99.
- Archives every legacy product that is not a Council 004 tier/pack (retires the
  old Learner/Fluent and their per-currency prices). Idempotent: matched by
  metadata and reused on re-run.
- Prints product/price ids for verification only — backend resolves them live
  from metadata, so nothing is pasted into code.

Header documents the MANUAL steps this script cannot do: enable Adaptive Pricing
in the Dashboard, and restrict who can edit product metadata (it now grants
money). Run with `yarn setup:stripe` against your Stripe test account.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…edit, seed on grant (S4)

Council 004 meters voice in monthly minutes alongside the credit balance.

- Schema (db.ts) + types (types.ts): add voice_minutes_total and
  voice_minutes_used to both the subscription and free_credit collections
  (default 0).
- addNewSubscriptionWithCredit takes a voiceMinutes arg and seeds
  voice_minutes_total on create; the renewal path
  (updateSubscriptionStatusByProviderAndSubscriptionId) refills
  voice_minutes_total and resets used to 0 on a real period rollover, alongside
  the existing credit refill (no rollover).
- Freemium allocations seed the Starter "taste" from a new config constant
  FREEMIUM_DEFAULT_VOICE_MINUTES (5); Starter voice still debits credits.
- Update the subscription `tier` enum to the Council 004 set
  (starter/reader/learner/coach).

Scope is schema + seeding only — the voice metering engine (recordVoiceMinutes,
checkVoiceMinuteAllocation, overage packs, meter UI) remains the separate
Council 004 workstream. The webhook starts passing voiceMinutes from metadata
in S2.

Note: the LLD's "delete dead subscription_type" item is stale — that field is
now live (written by the webhook + read by the UI), so it is intentionally kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e Starter source (S5)

Gating no longer reads a static per-tier cap table; it reads resolved
entitlements (paid) or config (free Starter).

- Replace tierAllowsFeature/featureCap (tier-id + static caps) with
  featureCapFor(entitlements|null, feature) / featureAllowedFor(...). Pass null
  for Starter -> caps come from config.ts (FREEMIUM_DEFAULT_SAVE_WORDS /
  _LIVED_SESSIONS), the single source for free-tier limits. Smart Review is
  unlimited on every tier; weekly insights / session history map from the
  entitlement booleans. FeatureKey stays in code (Stripe sets values, not keys).
- Remove the duplicate `caps` table from the registry entirely.
- Migrate the registry to the Council 004 ladder (Starter / Reader / Learner /
  Coach; Fluent retired) with the canonical card copy keyed by tier_id. Stripe
  ids are resolved live now, so prices/stripeProductId are null in code;
  amount/creditBudget/duration/trial are kept only until S2/S3/S7 read them from
  Stripe (removed in S8). resolveTierByPriceId/ProductId are deprecated no-ops
  kept until the webhook stops importing them (S2).
- No production callers of the old helpers existed; tests updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…potency (S2)

The webhook no longer reads the credit budget from the code registry; it reads
the paid product's metadata (ADR-004) and grants credits + voice minutes from it.

customer.subscription.created / .updated now:
- resolveEntitlements(stripe, {priceId}) -> validated metadata, then grant
  creditsGranted + voiceMinutesGranted and store the tier from tier_id.
- Fail safe = REFUSE, not guess: on missing/invalid metadata the handler logs an
  [ALERT], fires an entitlement-grant-refused analytics event, and returns
  failure so the webhook responds non-2xx and Stripe retries — never grants a
  guessed amount or drops the user to free.
- Lock at purchase, re-read at renewal: the parsed entitlements are snapshotted
  onto the subscription doc on create; the snapshot + budgets are re-read/refilled
  only on a real period rollover, so a mid-period metadata edit reaches a
  customer only at their next renewal.

Idempotency (new schema fields granted_period_end + entitlements on the
subscription doc):
- created: addNewSubscriptionWithCredit skips if a doc for this Stripe
  subscription id already covers this period (or newer) — duplicate created
  events don't make a second doc / double-grant.
- updated: refill happens only when current_period_end is strictly newer than
  granted_period_end — idempotent on re-delivery and safe against out-of-order
  (older) deliveries.

cadence is derived from the price's recurring interval; the trial->paid
analytics path is preserved. Removes the registry resolveTierByPriceId usage from
the adapter (now metadata-driven). Full server suite green (106 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…last-known-good cache (S3)

The pricing endpoint no longer projects the hardcoded TIERS registry; it reads
the live Stripe products (ADR-004) and combines each product's parsed
entitlements with the code-side display copy keyed by tier_id (labels — which
must never say "credit" — stay under code review).

- entitlements.ts: listTierEntitlements(stripe) lists active tier products
  (those with tier_id metadata; packs skipped) with their parsed entitlements and
  GBP monthly/annual prices. A product whose metadata fails to parse is SKIPPED
  with a warning (the public page must not break on one bad product; the webhook
  path still refuses).
- plans.ts (new): assembles PublicTierPlan[] (Starter from code + paid from
  Stripe, sorted by tier_rank) behind a 5-min TTL cache, a last-known-good
  snapshot, and a baked-in code fallback — so the anonymous, high-traffic page
  NEVER renders empty when Stripe is slow/down. Separate module so the webhook
  can import clearPlansCache without a functions.ts <-> gateway import cycle.
- functions.ts: getSubscriptionPlans serves the cached build; if even the Stripe
  adapter is unavailable it returns the code fallback. Response shape unchanged.
- Stripe webhook: product.created/updated/deleted and price.created/updated/
  deleted clear the entitlement + plans caches, so a Stripe edit shows up without
  waiting for the TTL.

Verified: tsc clean; smoke-tested order/pricing/skip-pack + warm-cache,
last-known-good, and baked-in fallback paths; subscription suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing (S7)

Checkout no longer takes a currency and no longer picks a per-currency price id.

- createCheckoutSession resolves the single GBP price + entitlements for the
  tier/cadence LIVE from Stripe (new resolveTierCheckout in entitlements.ts:
  finds the product by tier_id, validates metadata, picks the active GBP price
  for the cadence). Trial length now comes from the metadata (trial_days), not
  the registry.
- Removed `currency` from the whole checkout request chain: CreateCheckoutRequest
  (adapters/types), CheckoutSessionRequest (gateway/types), CreatePaymentParams
  (gateway/functions), and the service wrapper. The Currency TYPE itself is
  removed in S8.
- Currency handling: with Adaptive Pricing the customer pays in their local
  (presentment) currency, but we record the GBP SETTLEMENT amount + currency (the
  base price) on the payment_session/payment docs, and the webhook grants from
  metadata (currency-independent) — so reports, refunds, proration all work in
  GBP. Verified verifyPayment persists the stored GBP currency.

MANUAL: enable Adaptive Pricing in the Stripe Dashboard (Settings -> Payments ->
Adaptive Pricing). It applies to Checkout automatically once on; no code flag.

tsc clean; full server suite green (106 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ency picker (S9)

The in-app pricing page (settings/subscription.vue) now reflects Council 004 and
the GBP-base + Adaptive Pricing model.

- Renders the three paid cards (Reader / Learner / Coach) from
  getSubscriptionPlans (data, not hardcoded), "Most popular" on Learner, sorted
  by the backend (tier_rank). Starter is a "Continue with Free" link below the
  cards (current-plan label for freemium users).
- Removes the usd/eur/gbp currency picker; shows the GBP base price (£) and lets
  Stripe localize at checkout. Drops the `currency` argument from the checkout
  call and the TRIAL_STARTED analytics payload (matches backend S7).
- Covers the load states: loading / error / empty / success (the empty/error
  paths still never strand the user since the backend serves a fallback).
- Removes the retired Fluent "notify me" CTA + notifyFluent(); drops the unused
  Fluent i18n keys; adds choose-plan / continue-free / loading / error / empty.
- Stops importing the Currency type (its removal lands in S8).

Verified: Nuxt typecheck clean for the touched files (only a pre-existing,
unrelated ActivityChartOverview.vue error remains); frontend unit tests green
(43); en.json valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…type (S8)

Now that the webhook, plans endpoint, and checkout all read from Stripe metadata,
the registry's entitlement/pricing data and the per-currency type are dead and
misleading. Final cleanup:

- tiers.ts keeps ONLY display copy keyed by tier_id (name, tagline, featureLabels,
  aiBudgetLabel, GBP `amount`) + shared types. Removed: stripeProductId, prices
  (TierPrices), creditBudget, durationDays, trialDays, and the deprecated
  resolveTierByPriceId / resolveTierByProductId. `amount` stays as the baked-in
  GBP fallback the plans endpoint serves when Stripe is down.
- Removed the Currency type and the TierPrices interface; TierAmounts is now
  GBP-only ({ monthly: { gbp? }, annual: { gbp? } }). The currency param was
  already gone from the checkout chain in S7.
- frontend/types/tiers.ts drops the Currency / TierPrices re-exports (no consumer
  remained after S9).
- tiers.test.ts: dropped the deprecated resolver test and the registry trialDays
  test (trial is metadata now); added a check that the slimmed registry holds
  only display copy + GBP amounts.

Verified: server tsc clean; subscription tests green; no Currency/TierPrices refs
remain anywhere in server or frontend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…otency + fail-safe (S10)

Metadata-driven tiers move money-granting logic from a typed registry to parsed
external metadata, so the new failure modes are covered with tests:

- entitlements.test.ts: parseTierMetadata valid case + every failure mode
  (missing key, bad number, out-of-range credits, unknown schema_version, bad
  bool/status/cap); resolveEntitlements caching + re-fetch after clear (full vs
  product-only); listTierEntitlements (skips packs + unparseable products);
  resolveTierCheckout resolves the GBP price and reports GBP settlement.
- plans.test.ts: getSubscriptionPlans builds from Stripe (Starter first, then by
  tier_rank, GBP pricing, no "credit" copy); serves the warm cache without
  re-reading Stripe; falls back to last-known-good when Stripe is down; baked-in
  fallback.
- service.test.ts: addNewSubscriptionWithCredit idempotency (skips a second doc
  for an already-granted period; seeds the entitlement snapshot + voice budget +
  period marker); updateSubscription lock-at-purchase vs re-read-at-renewal
  (refills only on a strictly newer period; out-of-order/duplicate is a no-op but
  still syncs status).
- gateway/stripe.adapter.test.ts: webhook grants from metadata; FAIL-SAFE refuses
  + alerts on invalid metadata (no grant); renewal path refills with cadence from
  the price interval; product/price webhooks clear the caches.

Full server suite green (139 tests, 20 suites); tsc clean. readme.md documents
the ADR-004 shift and that the zero-paid-user migration is a no-op (new fields
are optional with defaults; a pre-existing paid sub picks up its snapshot at
next renewal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…zed prices

The hosted-redirect checkout showed the price only on Stripe's page, so EU
visitors never saw their local currency in-app. This adds Stripe's embedded
"Custom Checkout" (Elements with Checkout Sessions), which surfaces the
adaptive-localized price (e.g. EUR) in our own UI.

Server (gateway):
- StripeAdapter.createCustomCheckoutSession: creates a ui_mode:'custom' session
  with adaptive_pricing.enabled:true for the tier's GBP price (resolved live),
  trial + metadata, and returns the client_secret (no redirect URL). Still
  records the GBP settlement amount on payment_session.
- New createCustomCheckoutSession RPC (user_access).

Frontend:
- components/subscription/CheckoutPanel.vue: loads @stripe/stripe-js, calls
  initCheckoutElementsSdk({ clientSecret, adaptivePricing:{ allowed:true } }),
  mounts the Currency Selector + Payment elements, displays the localized
  getSession().total.total.amount, and confirms in-app (no redirect).
- settings/subscription.vue: "Choose <plan>" / trial CTAs now open the panel
  instead of redirecting; on success it refreshes the subscription.
- New NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY runtime config + checkout i18n.

The webhook grant path is unchanged (customer.subscription.created still grants
from product metadata). Verified: server tsc clean + the ui_mode:'custom' +
adaptive_pricing Stripe call returns a client_secret; frontend typecheck clean
for the new/changed files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (EUR for EU)

The pricing cards still showed the GBP base even for EU visitors, because the
adaptive-localized amount is computed client-side from the visitor's IP and is
not available server-side (the API session is GBP-only). This shows the local
currency on the cards too.

- subscription.vue: on load, probe the visitor's local currency + GBP→local rate
  via one Stripe Checkout Elements session (lineItems[0].unitAmount), cached per
  browser session. formatAmount then renders each card in the local currency
  (e.g. €13.19) via Intl.NumberFormat. Falls back to the GBP base when no
  publishable key, Stripe is unavailable, or the presentment currency is GBP — so
  no regression.
- CheckoutPanel.vue: show the recurring per-period localized price
  (lineItems[0].unitAmount.amount) instead of the session total, which is the
  amount due today and is 0 during a free trial (was displaying "€0.00").

Verified live: with the publishable key set, a session forced to Germany returns
presentment currency EUR and a 1 GBP = 1.2 EUR conversion, with the recurring
price €13.19 (= £10.99). Frontend typecheck clean for the changed files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deriving the conversion rate from one tier's already-rounded amount drifted by a
cent (Coach showed €30.00 vs the €29.99 charged at checkout). Use the exact rate
Stripe exposes on the session (currencyOptions[].currencyConversion.fxRate,
e.g. 1.200024) instead, falling back to the derived rate only if absent. Cards
now match checkout exactly: Reader €5.39, Learner €13.19, Coach €29.99.

Verified live with a Germany-located customer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…icate-key crash

The Leitner review-job sync fanned out into concurrent createJob() calls racing
on the same `leitner-review-<userId>` name (getSettings re-enters the sync). The
losers hit the unique `name_1` index with an E11000 that went unhandled and
crashed the whole process — e.g. when a freshly-registered user logged in, or at
boot during schedule catch-up.

- ScheduleService.createJob: replace the racy findOne-then-create with an
  idempotent upsert keyed on `name`; swallow E11000 (a benign concurrent create —
  the desired end state) and rethrow only real errors. scheduleJobInternal and
  the init loop are now fire-and-forget with .catch, so a single job failure can
  never reject createJob or surface as an unhandled rejection that kills the
  process.
- LeitnerService.syncScheduledJob: claim the per-user sync up-front (before the
  re-entrant getSettings) so concurrent re-entrant calls become no-ops, and wrap
  it in try/catch that releases the claim on failure — a schedule-sync error must
  never bubble into the registration/login/settings request flow.
- Tests for the idempotent E11000-swallow + non-duplicate rethrow paths.

Uncovered while validating the subscription/adaptive-pricing work (test-user
logins kept crashing the dev server). tsc clean; schedule + leitner suites green
(43 tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lized-currency testing

- Update the checkout usage: createCustomCheckoutSession (embedded, returns
  clientSecret) is the in-app flow; createPaymentSession (hosted redirect) is
  legacy. Removed the stale `currency` param and "resolve price from registry"
  wording — the GBP price is resolved live from product metadata.
- New "Adaptive Pricing & localized currency" section: GBP base + Stripe
  conversion, why the localized amount is client-side only (API session is
  GBP-only), how the checkout panel + cards read it (getSession lineItems
  unitAmount / currencyOptions fxRate), GBP settlement, and setup (Dashboard
  toggle + NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY).
- New "Testing localized / adaptive pricing" section: the test-mode +location_XX
  email trick (test mode ignores IP/VPN — that's live-only), a verified
  per-country price table (GB/FR/US/JP/TR), the exact API steps to mint a
  location-tagged account (no signup UI), the token-inject + cache-clear steps,
  and the node 16-18 / schedule-fix caveat.
- Fix the 3-day-trial section: trial length now comes from product metadata
  (trial_days -> entitlements.trialDays), changeable without a deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The .claude/ directory holds machine-specific Claude Code config
(settings.local.json, launch.json) that should not live in the repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o avoid GBP flash

The pricing cards painted the GBP base price first, then swapped to the
visitor's local currency once the async Adaptive Pricing probe resolved,
causing a visible flash. Gate the price line on a new isProbingCurrency
flag (true only during the network probe — cache hits and the no-key path
resolve synchronously) and render an animate-pulse skeleton until the
localized amount is ready, so the first price shown is already localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dashboard pages render a decorative blurred-blob background that is
absolutely positioned with negative offsets (e.g. right-[-10%]), so it
bleeds past the content column. With no clipping ancestor the overflow
propagated up to <html>, producing a page-wide horizontal scroll on every
page that uses the default layout.

Wrap the layout content slot in overflow-x-clip so the bleed is contained
once for all current and future pages, instead of fixing it per page. Uses
clip rather than hidden because overflow-x:hidden coerces overflow-y to
auto, creating a nested vertical scroll container that traps vertical
content; clip leaves vertical scrolling untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ipe-driven (single source of truth)

Paid tiers (Reader/Learner/Coach) now pull EVERYTHING from Stripe: entitlements
(already), display copy (new), prices, plus a highlight/badge flag and the trial
length. No paid-tier data remains in code. Operators can edit pricing-page copy,
the "Most popular" badge, the highlighted card, and the trial in Stripe with no
deploy.

- display.ts: lenient parser for name (= Stripe product name), tagline,
  feature_<n> bullets, ai_budget_label, highlight, badge. Never throws — a copy
  typo must not 500 the pricing page or block a money grant.
- entitlements.ts: listTierEntitlements surfaces display copy; product name is
  cached so the webhook can stamp the subscription label from Stripe
  (cachedTierName replaces the removed getTier).
- plans.ts: builds fully from Stripe; removes the baked-in getFallbackPlans. On
  Stripe failure it serves the last-known-good snapshot (real data) if present,
  else throws so the page can show a "payment system unavailable" state.
- tiers.ts: drops the three paid tiers + getTier/liveTiers; keeps only the free
  STARTER_TIER (the one code exception — it has no Stripe product) + types +
  gating helpers. PublicTierPlan gains highlight/badge/trialDays.
- gateway: subscription label now comes from the Stripe product name.
- setup-stripe-pricing.ts: seeds the display copy into product metadata and
  self-validates it round-trips and never leaks the word "credit".
- frontend: badge/highlight/trial come from plan data (no more id === 'learner');
  pricing cards show skeletons while loading and a calm "payment system" notice
  when plans can't be loaded.

Tests: new display.test.ts; plans/tiers/adapter tests updated. 146 server tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… downgrade-to-free states

Render the free Starter tier as a 4th card in the plans grid (4-up on desktop)
instead of a small text link below the paid cards, so free users see their level
and paid users have a way down.

- Starter card adapts to the user: a "Current plan" indicator when on the free
  tier (isFreemium), or a "Downgrade to Free" button when on a paid tier. The
  downgrade reuses the existing cancel flow — the off-ramp interstitial for
  trialing users, the Stripe billing portal for active paid users.
- Starter shows "Free" (no currency skeleton — it has no price); paid cards keep
  the Stripe-driven price/badge/highlight/trial and currency localization.
- Grid is now grid-cols-1 / md:2 / xl:4; the loading skeleton renders 4 cards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n caps from entitlements

Wire the resolved tier caps into real runtime gates (until now they were granted
+ snapshotted but never enforced). Adds a single enforcement primitive so every
gate behaves and reports the same way.

- enforcement.ts: resolveUserEntitlements / getEffectiveCap / assertFeatureEnabled
  / assertWithinCap + EntitlementLimitError (stable code TIER_LIMIT_REACHED,
  mirroring the AI_CREDIT_EXHAUSTED pattern so the frontend can pattern-match it).
  A paid sub missing its entitlement snapshot is treated as unlimited so a payer
  is never wrongly gated.
- save_words: createPhrase blocks a NEW save once the free cap (200/window) is met;
  paid tiers are unlimited. Reusing an existing phrase doc does not count.
- weekly_insights: getUserStatistic + generateChartDataForInsertionRatio are locked
  for free/Reader, unlocked for Learner/Coach (both only power the Statistic page).
- live_sessions: the existing freemium block (3) now throws EntitlementLimitError
  instead of a bare string (gemini + openai paths).

Tests: new enforcement.test.ts; createPhrase test mocks updated. 79 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
navidshad and others added 8 commits June 5, 2026 00:15
…is month" card

The paid "This month" card showed only a voice meter and a text-chat
counter, hiding saved-phrase and live-session limits that the free plan
surfaces. List all three entitlements (saved phrases, text chats, live
sessions) from the active subscription's snapshot — "Unlimited" when the
cap is null, otherwise used / limit.

For a Reader with no voice budget (zero voice used, no top-ups),
"Live sessions: Unlimited" + "Voice: 0 min" was misleading, so collapse
both into a single "Live session → Upgrade to Learner" upsell row that
opens the Stripe change-plan flow, and hide the all-zero voice meter.
A topped-up Reader, Learner, or Coach still sees the real rows and meter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…etup guards and a startup check

setup-stripe-pricing.ts now also provisions the Customer Portal
configuration (the plan-switch ladder behind "Change plan"), and gains
safety rails for running against an account with real subscriptions:
  - --dry-run prints the create/reuse/archive plan and writes nothing
  - --confirm-live refuses to mutate an sk_live account without it
  - a legacy product is archived only when it has NO active subscriptions
    (checked via its prices), so customers' plans are never parked

On the server, verifyStripeSetup() runs once at startup and logs whether
the tier products + portal config are present, so the server logs alone
tell you whether `yarn setup:stripe` still needs running. It also resolves
the portal config id once at boot (replacing a per-request lookup); the
change-plan + portal flows read it via getManagedPortalConfigId() and fall
back to the account default when it's absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(was "Unlimited")

The "This month" planRows read entitlements.textChatCap, but
getSubscriptionDetails carries the cap as the top-level allowed_text_chats
(entitlements holds only a few fields). cap(undefined, 23) collapsed to
"Unlimited", so a Reader's "23 / 60" text chats rendered as "Unlimited".
Read allowed_save_words / allowed_text_chats / allowed_lived_sessions and
their *_used counters instead — the same fields the free StarterUsageCard
uses; an absent/null cap still means unlimited (Learner / Coach).

Update the e2e assertion to the new card format (label + "23 / 60" row);
the old TextChatCounter "Text chat: N of M used this month" text is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…II bundle titles)

btoa(JSON.stringify(request)) throws InvalidCharacterError when a bundle
title contains codepoints > 255 (Persian / Arabic / CJK — common in a
language-learning app), breaking the session launch. Add shared
encodeSessionRequest / decodeSessionRequest helpers that UTF-8 encode the
bytes before base64, and route the two encode sites (StartNew, bundles/[id])
and the decoder (livePracticeSetup) through them so they can't drift. Output
is identical for ASCII, so existing sessions are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n-tiers-Stripe-metadata-driven-adaptive-pricing-Council-004-rollout_Navid-Shad

make subscription tiers stripe metadata driven adaptive pricing council 004 rollout navid shad #86ext1gpf
…validation

Mirror the extension repo's browser-validation loop for the dashboard SPA:
- .mcp.json: commit @playwright/mcp (headless) so any Claude Code session gets a real browser.
- server/scripts/agent-token.mjs: log in via /user/login (defaults to ADMIN_EMAIL/ADMIN_PASSWORD), plaintext->base64 fallback, prints a JWT to inject into localStorage["token"].
- server/scripts/create-standard-user.mjs: standalone, reuses the P1 register recipe (dev code 123456) to mint a standard freemium user + token.
- CLAUDE.md: 'Verifying changes in the browser' section; default to the standard-user token, admin only when intended.
- server/sample.env: note ADMIN_EMAIL/ADMIN_PASSWORD provision the loginable admin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rate

Free/flash models intermittently return truncated or schema-invalid JSON,
producing a ~36% translation error rate on the word-detail page. As a quick
patch (vs. switching to a paid model), make the shared structured-output path
resilient:

- Validate the model reply against the same Zod schema used to build the
  request, instead of casting it through unchecked.
- Retry on parse/validation/transport failure (default 2 retries), nudging the
  temperature up each attempt so a temperature-0 model resamples instead of
  repeating the same malformed completion.

Benefits both getDetailedTranslation and getTranslationAdvice, which share
createStructuredOutputWithZod. Adds unit tests for the retry/validation paths.
fix(translation): retry + validate structured LLM output to cut error rate
@navidshad

Copy link
Copy Markdown
Contributor Author

@navidshad

navidshad commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Need to be done before Merge:

  1. Add Stripe dashboard Token Before merge
  2. Run Stripe Setup for Live environment

@navidshad navidshad added the review Claude Routine will take this and review the PR label Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Automated PR Review

Primary Task: CU-86ext1gpf — Make subscription tiers Stripe-metadata-driven + adaptive pricing
Related tasks from commits: #86exr424h — Implement text-only live conversation


Task alignment

#86exr424h — Text-only live conversation

  • ✅ New live_session_text server module (functions.ts, db.ts, config.ts, types.ts, utils.ts, readme.md) — server-side create-text-session + text-turn RPCs with credit gating, per-chat caps, and explicit context cache for token cost reduction
  • ✅ New frontend page practice/live-session-text.vue (text-only UI with chat input, vocab cards, recap modal)
  • practice/live-session-voice.vue extracted from the old monolithic live session page — voice-mode features cleanly separated
  • ✅ Drop empty parameters from finish_practice tool (Add dashboard page include apex chart somayeh roohani #2 commit)
  • ✅ Model allowlist enforcement (server rejects client-supplied models outside ALLOWED_TEXT_MODELS)
  • ✅ Gemini thought_signature preserved so tool-calling works on text sessions
  • ✅ Explicit context cache for the static system-prompt prefix (perf commit)
  • ✅ Mode selector (voice / text) wired into StartLiveSessionForm + StartLiveSessionFormModal
  • ✅ Mode-aware freemium limit card (textChat vs liveSession type)
  • VoiceMeter sm + TextChatCounter shown in the modal footer for premium users per mode

CU-86ext1gpf — Stripe-metadata-driven subscription (S1–S17)

  • ✅ S1: entitlements.ts — Zod-validated metadata parser with hard bounds on money-granting fields; schema_version gate; loud-fail on any missing/invalid key
  • ✅ S2: Stripe webhook rewritten to grant from product metadata; idempotency on subscription id + granted_period_end
  • ✅ S3: plans.tsgetSubscriptionPlans reads live Stripe products through a TTL cache + last-known-good snapshot; intentionally throws on cold-start outage rather than serving invented data
  • ✅ S4: voice_minutes_total, voice_minutes_used added to subscription + free_credits schema; seeded from metadata on grant
  • ✅ S5: enforcement.ts — single-place feature gating (assertFeatureEnabled, assertWithinCap); stable TIER_LIMIT_REACHED error code matching the frontend LimitationModal
  • ✅ S6: setup-stripe-pricing.ts setup script provisions Council 004 products (Reader/Learner/Coach) with entitlement metadata; startup check logs setup status
  • ✅ S7: Adaptive Pricing enabled; createCustomCheckoutSession returns clientSecret for embedded Stripe Checkout Elements; single GBP base price per cadence
  • ✅ S8: Hardcoded TIERS registry removed as source-of-truth for paid tiers; Starter alone remains in code
  • ✅ S9: Pricing page reads plans from getSubscriptionPlans RPC; GBP→local currency probe via FX rate; skeleton placeholder during probe; sessionStorage cache
  • ✅ S10: stripe.adapter.test.ts (webhook grant tests), agent E2E workflow (agent-e2e-subscription.yml), tier-ladder.spec.md runbook with reference executor
  • ✅ S11: TopUpsSection.vue shows active top-up packs; createVoiceTopUpCheckoutSession wires one-shot checkout
  • ✅ S12: VoiceMeter.vue — three sizes, teal/amber/red fill, top-up button, renewal date; base budget vs top-up packs separated
  • ✅ S13: VoiceCapBanner.vue (80% threshold, Reader top-up nudge) + VoiceCapModal.vue (100% cap — top-up CTA + text-chat fallback)
  • ✅ S14: TopUpPickerModal.vue + TopUpsSection.vue with direct-buy buttons; useVoiceTopUp composable; billing settings section
  • ✅ S15: FeatureLocked.vue — shared panel for session_history, weekly_insights, voice_chat; used on /sessions and /statistic pages
  • ✅ S16: assertAndConsumeTextChat gates create-text-session at 60 chats/month for Reader, 5 for Starter; getTextChatMessageCap enforces per-chat message cap
  • ✅ S17: TextChatCounter.vue (Reader active-plan card rider) + StarterUsageCard.vue (Starter free-tier usage card with all four meters)

Additional scope addressed (noted in PR body): global upgrade modal on TIER_LIMIT_REACHED RPC responses; payment-success redirect with refetch + animated waiting state; createPortalUpdateSession RPC; save_words / weekly_insights / live_sessions enforcement from entitlements.

Scope is appropriate — all 17 subtasks are covered; the additional items (upgrade modal, payment redirect, portal session) are natural dependencies for a working end-to-end flow.


Commit messages

All 26 commits use correct Conventional Commits types. Highlights:

  • feat(live-session), fix(live-session), perf(live-session) scopes are accurate
  • feat(subscription), fix(subscription) scopes are accurate
  • feat(checkout) for the embedded Custom Checkout commit is accurate
  • fix(ui): use IconPlayCircle — correct fix type for a rendering bug

✅ No issues found.


Prior review follow-up

No previous automated or manual reviews. Nothing to resolve.


Convention check

✅ New collection LIVE_SESSION_TEXT_COLLECTION added to server/src/config.ts (project convention: always add new collections there)
✅ New live_session_text module follows the standard layout (db.ts, functions.ts, config.ts, types.ts, utils.ts, readme.md); no service.ts is correct for a simpler module
✅ Gemini used for new live-session work (not OpenAI)
✅ No SSR-only Nuxt features used
✅ Yarn-only (no npm changes)
LIVE_SESSION_TEXT_COLLECTION added to config.ts before use

Minor observation — any casts in new components: TextChatCounter.vue:928 (profileStore.activeSubscription as any) and TopUpsSection.vue:1049 (activeTopUps: ... as any[]) use runtime casts rather than typed accessors. The pattern exists elsewhere in the codebase and is not a blocker, but surfacing for future cleanup.

Minor observation — frontend hardcodes model string: live-session-text.vue passes model: 'gemini-3.1-flash-lite' to the server RPC. The server validates it against ALLOWED_TEXT_MODELS, so safety is preserved. A shared constant exported from a types file would eliminate the risk of drift.

Pre-merge operational requirement (from PR author's comment):

  1. Add Stripe dashboard Token before merge
  2. Run Stripe Setup for Live environment (yarn setup:stripe)

These are deployment steps, not code issues — the yarn setup:stripe script and startup check log are in this PR specifically to enforce this workflow. Not blocking the code review, but both must be completed before the merge is effective in production.


Verdict

APPROVE

Implementation is comprehensive, architecturally sound, and well-tested (new Jest suites for entitlements/enforcement/plans/service/tiers, Stripe adapter webhook tests, and a full agent E2E CI workflow). All 17 subtasks and the companion text-session task are addressed. Conventional Commits are correct throughout. The two pre-merge operational steps (Stripe token + setup:stripe on live) are clearly called out by the author and must be completed before the merge has real effect.


Generated by Claude Code

navidshad and others added 10 commits June 7, 2026 17:34
…nt events

Align with docs/metrics/event-naming.md and the roadmap's slim
Stripe-webhook instrumentation before these names reach production:

- cap-hit -> cap_hit; starter-ai-exhausted -> starter-ai_exhausted;
  entitlement-grant-refused -> entitlement-grant_refused.
- The client 'trial-started' fired when the checkout panel opened, not
  when a trial began. It is now checkout_opened { tier, cadence,
  currency }; the server fires the truth: trial_started on a trialing
  subscription create, subscription_started on trial->paid conversion
  (via_trial) or a direct active create, subscription_canceled on every
  delete with was_trialing — paid churn is visible now.
- New: flashcard_review_started { deck_type } on the three review
  pages; user_logged-in on the OAuth return page (explicit login only);
  translation_requested { translation_type, source_lang, target_lang }
  in translateWithContext (failures count too — error rate pairs with
  word-detail-page_translation-error).
- sample.env documents MIXPANEL_TOKEN; adapter tests cover the three
  lifecycle events.
- subscription_started now also fires on an incomplete->active transition
  (SCA/3DS paid starts that clear via the `updated` webhook). It is driven
  off our stored prior status, which also makes the trial->paid event
  idempotent: a redelivered webhook sees the record already "active" and
  won't re-fire.
- Create-path trial_started/subscription_started are gated on
  isNewSubscription, so a redelivered (DB-idempotent) `created` webhook no
  longer double-counts in Mixpanel.
- subscription_canceled fires only when the cancel actually applied
  (success), avoiding phantom churn events for unknown subscriptions.
- Tests: direct active create (via_trial:false), redelivery fires nothing,
  incomplete->active, trial cancel (was_trialing:true), failed cancel fires
  nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cards

review.vue (smart_review) and flashcards-[id].vue (flashcards/leitner) fired
the event on mount even for an empty deck; they now fire after load, only when
cards exist — matching bundle-review.vue. Keeps all four deck_type values
comparable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dead since the Fluent-tier waitlist UI was dropped (S9, 0b27c00); nothing
fires it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ai engine check

@google/genai (live-session text mode) declares engines.node ">=20"; the
frozen install hard-failed on node 18. 22 matches server/.nvmrc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…the server

modular-rest createRest() (index.ts adminUser) rejects empty admin creds
with "Invalid email or password for admin user", so the server never bound
to 8080 and wait-on timed out. Throwaway values for the ephemeral CI DB;
the tier-ladder executor registers its own test user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…resolves the user

The tier-ladder executor seeded the customer↔user mapping into
db.stripe_customers (plural), but gateway/db.ts registers that collection
with an explicit collection:"stripe_customer" (singular) — the one
exception to the otherwise-pluralized convention. So getUserIdForCustomer
missed the record, the customer.subscription.created handler bailed with
"User not found for this customer", and all three tier grants silently
no-opped. Seed + teardown now target the singular collection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ption fallback; revert e2e collection rename

The customer.subscription.created/updated webhook handlers read
item.current_period_{start,end}. Those fields moved from the Subscription
object onto the subscription item in Stripe API 2025-03-31.basil; a webhook
payload is serialized with the ACCOUNT's API version, which on the test
account is still 2024-10-28.acacia — where they live on the subscription, so
the item read is undefined. undefined -> new Date(NaN) -> an Invalid end_date
that every active-subscription query (end_date >= now) silently filters out,
so the grant persisted but the tier never applied: the tier-ladder E2E saw
"no grant" despite a 200 webhook. Read item-first, fall back to the
subscription, valid on either API version.

Also reverts the e2e executor stripe_customers->stripe_customer rename from
the previous commit: modular-rest pluralizes the collection (free_credit ->
free_credits), so stripe_customer maps to the physical stripe_customers; the
plural seed was correct and the singular one broke the customer->user lookup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…der E2E

Temporary diagnostic to ground-truth where current_period_end lives in the
webhook payload (item vs subscription vs absent). Reverted in the follow-up
fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…paid tier; revert period diag

getSubscription() builds a Stripe billing-portal return_url as
`${process.env.DASHBOARD_BASE_URL}/#/settings/subscription`. DASHBOARD_BASE_URL
was unset in the agent-e2e job, so the URL was "undefined/#/..." which Stripe
rejects; the non-resource_missing error re-throws (service.ts) and fails the
whole getSubscriptionDetails RPC. The tier-ladder grant doc was created fine
(valid active period) but every details poll errored, so the executor read it
as "no grant". Set a valid DASHBOARD_BASE_URL in the workflow env.

Diagnostics proved the webhook subscription carries a valid current_period_end
on both the item and the subscription, so the earlier adapter period-fallback
change was unnecessary — this reverts the adapter to pristine (also drops the
temporary [e2e-diag] log).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@navidshad navidshad added review Claude Routine will take this and review the PR and removed review Claude Routine will take this and review the PR labels Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Automated PR Review (updated)

Note: A previous automated review covered the bulk of this PR (all 17 subscription subtasks + text-session task) and issued APPROVE. This update covers only the new commit pushed after that review (0f0d48f).


Primary Task: CU-86ext1gpf — Make subscription tiers Stripe-metadata-driven + adaptive pricing
Related tasks from commits: #86exr424h — Implement text-only live conversation


New commit since last review

0f0d48ffix(ci): set DASHBOARD_BASE_URL so getSubscriptionDetails resolves a paid tier; revert period diag

Two files changed (+11 / −52):

.github/workflows/agent-e2e-subscription.yml (+6/−6)
✅ Adds DASHBOARD_BASE_URL to the agent-e2e job env. Root cause is correctly diagnosed: getSubscription() builds a Stripe billing-portal return_url from this env var; without it, the URL was "undefined/#/...", Stripe rejected it, and the whole getSubscriptionDetails RPC failed — making the tier-ladder executor read every grant as missing. Fix is minimal and targeted.

server/src/modules/gateway/adapters/stripe.adapter.ts (+5/−52)
✅ Reverts the temporary current_period_end fallback and [e2e-diag] log that were added to diagnose the above failure. Diagnostics confirmed the webhook subscription already carries a valid current_period_end, so the fallback was unnecessary. Clean revert to pristine state — no lingering diagnostic code.

Commit type: fix(ci) is correct — CI environment variable fix + revert of temporary diagnostic code.


Prior review follow-up

The previous review noted two minor observations:

  • any casts in TextChatCounter.vue / TopUpsSection.vue — still present, non-blocking.
  • Hardcoded model string in live-session-text.vue — still present, non-blocking.

Neither was a blocking issue; no change required.

Pre-merge operational checklist (from PR author):

  1. Add Stripe dashboard token before merge
  2. Run Stripe Setup for live environment (yarn setup:stripe)

Still outstanding — these are deployment steps, not code issues.


Verdict

APPROVE

The new commit is a correct, focused CI fix that unblocks the agent-e2e tier-ladder tests and cleanly reverts temporary diagnostic code. No new issues introduced. The full PR remains approved.


Generated by Claude Code

@navidshad navidshad changed the title Dev Text-only live sessions + Stripe-metadata-driven subscriptions (Council 004) Jun 8, 2026
@navidshad navidshad merged commit ed3611a into main Jun 8, 2026
8 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 1.0.0-dev.1 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@navidshad navidshad removed the review Claude Routine will take this and review the PR label Jun 19, 2026 — with Claude
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants