Conversation
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>
…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
|
Need to be done before Merge:
|
Automated PR ReviewPrimary Task: CU-86ext1gpf — Make subscription tiers Stripe-metadata-driven + adaptive pricing Task alignment#86exr424h — Text-only live conversation
CU-86ext1gpf — Stripe-metadata-driven subscription (S1–S17)
Additional scope addressed (noted in PR body): global upgrade modal on 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 messagesAll 26 commits use correct Conventional Commits types. Highlights:
✅ No issues found. Prior review follow-upNo previous automated or manual reviews. Nothing to resolve. Convention check✅ New collection Minor observation — Minor observation — frontend hardcodes model string: Pre-merge operational requirement (from PR author's comment):
These are deployment steps, not code issues — the VerdictAPPROVE 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 + Generated by Claude Code |
…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>
Automated PR Review (updated)
Primary Task: CU-86ext1gpf — Make subscription tiers Stripe-metadata-driven + adaptive pricing New commit since last review
Two files changed (+11 / −52):
Commit type: Prior review follow-upThe previous review noted two minor observations:
Neither was a blocking issue; no change required. Pre-merge operational checklist (from PR author):
Still outstanding — these are deployment steps, not code issues. VerdictAPPROVE 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 |
|
🎉 This PR is included in version 1.0.0-dev.1 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
📋 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
📜 Commit List