From 5b37c61dc9e30298ba5204d6f90fcf7be210a4ba Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 14 May 2026 21:12:23 +0300 Subject: [PATCH 1/6] feat: add 3-tier pricing backend with 3-day credit-card trial #86exkhcza Replace the single Premium plan with a Starter/Learner/Fluent tier ladder driven by a code-based tier registry (tiers.ts). Adds Stripe 3-tier products and prices via an idempotent setup script, annual billing, a 3-day card-required trial, webhook signature verification, soft-cap usage tracking, the Fluent waitlist, and fire-and-forget server-side analytics. Also folds in fixes from live testing: derive credit balances from raw fields (Mongoose virtuals are undefined on findOne results) and self-heal references to deleted Stripe customers. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/package.json | 4 +- server/scripts/setup-stripe-pricing.ts | 237 ++++++++ server/src/config.ts | 3 +- server/src/index.ts | 4 + server/src/modules/gateway/README.md | 47 +- .../gateway/adapters/stripe.adapter.ts | 230 +++++--- server/src/modules/gateway/adapters/types.ts | 9 +- server/src/modules/gateway/functions.ts | 15 +- server/src/modules/gateway/router.ts | 51 +- server/src/modules/gateway/service.ts | 4 +- server/src/modules/gateway/types.ts | 5 +- .../modules/live_session/gemini/functions.ts | 8 +- .../modules/live_session/openai/functions.ts | 8 +- .../subscription/__tests__/tiers.test.ts | 127 +++++ server/src/modules/subscription/config.ts | 11 + server/src/modules/subscription/db.ts | 87 +++ server/src/modules/subscription/events.ts | 18 + server/src/modules/subscription/functions.ts | 154 ++--- server/src/modules/subscription/readme.md | 8 +- server/src/modules/subscription/service.ts | 259 ++++++--- server/src/modules/subscription/tiers.ts | 253 ++++++++ server/src/modules/subscription/types.ts | 22 +- server/src/utils/analytics.ts | 40 ++ server/yarn.lock | 538 +++++++++--------- 24 files changed, 1580 insertions(+), 562 deletions(-) create mode 100644 server/scripts/setup-stripe-pricing.ts create mode 100644 server/src/modules/subscription/__tests__/tiers.test.ts create mode 100644 server/src/modules/subscription/tiers.ts create mode 100644 server/src/utils/analytics.ts diff --git a/server/package.json b/server/package.json index dcf3e0a..8110313 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,8 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:verbose": "jest --verbose", - "download:docs": "node scripts/download_docs.js" + "download:docs": "node scripts/download_docs.js", + "setup:stripe": "ts-node --transpile-only scripts/setup-stripe-pricing.ts" }, "author": "Navid Shad ", "license": "MIT", @@ -25,6 +26,7 @@ "dotenv": "^16.4.5", "googleapis": "^129.0.0", "koa-router": "^13.0.1", + "mixpanel": "^0.22.0", "node-fetch": "2", "node-schedule": "^2.1.1", "stripe": "^18.0.0", diff --git a/server/scripts/setup-stripe-pricing.ts b/server/scripts/setup-stripe-pricing.ts new file mode 100644 index 0000000..2f23cf1 --- /dev/null +++ b/server/scripts/setup-stripe-pricing.ts @@ -0,0 +1,237 @@ +/** + * Idempotent Stripe pricing setup for the 3-tier ladder (Council 002). + * + * Run: yarn setup:stripe (uses STRIPE_SECRET_KEY from server/.env) + * Re-run safely — existing products/prices are matched by metadata and reused. + * + * What it does: + * 1. Archives any active legacy product (the retired Pro/Premium plan) that + * does not carry one of our tier IDs. + * 2. Creates/reuses the Learner and Fluent products. + * 3. Creates/reuses 12 prices — each tier x {monthly, annual} x {usd, eur, gbp}. + * 4. Prints a ready-to-paste block of IDs for `tiers.ts`. + * + * Stripe prices are immutable: if a price with matching metadata exists but its + * amount/interval differs, a fresh price is created and the stale one archived. + */ +import * as path from "path"; +require("dotenv").config({ path: path.resolve(__dirname, "../.env") }); +import Stripe from "stripe"; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || ""); + +type Cadence = "monthly" | "annual"; +type Currency = "usd" | "eur" | "gbp"; + +interface TierSpec { + tierId: string; + name: string; + creditsAmount: string; // internal credit budget per 30-day window + subscriptionDays: string; + /** Amounts in minor units (cents / pence). */ + prices: Record>; +} + +const TIER_SPECS: TierSpec[] = [ + { + tierId: "learner", + name: "Learner", + creditsAmount: "300000000", + subscriptionDays: "30", + prices: { + monthly: { usd: 999, eur: 999, gbp: 899 }, + annual: { usd: 9599, eur: 9599, gbp: 8599 }, + }, + }, + { + tierId: "fluent", + name: "Fluent", + creditsAmount: "600000000", + subscriptionDays: "30", + prices: { + monthly: { usd: 1699, eur: 1699, gbp: 1499 }, + annual: { usd: 15999, eur: 15999, gbp: 14399 }, + }, + }, +]; + +const KNOWN_TIER_IDS = TIER_SPECS.map((t) => t.tierId); +const CADENCES: Cadence[] = ["monthly", "annual"]; +const CURRENCIES: Currency[] = ["usd", "eur", "gbp"]; +const CADENCE_TO_INTERVAL: Record = { + monthly: "month", + annual: "year", +}; + +async function listAll( + lister: (params: any) => Promise>, + params: Record = {} +): Promise { + const out: T[] = []; + let starting_after: string | undefined; + while (true) { + const page = await lister({ limit: 100, ...params, starting_after }); + out.push(...page.data); + if (!page.has_more) break; + starting_after = page.data[page.data.length - 1].id; + } + return out; +} + +async function archiveLegacyProducts(): Promise { + const products = await listAll( + (p) => stripe.products.list(p), + { active: true } + ); + for (const product of products) { + const tierId = product.metadata?.tierId; + if (tierId && KNOWN_TIER_IDS.includes(tierId)) continue; + console.log(` archiving legacy product ${product.id} ("${product.name}")`); + // Archiving the product retires it — its prices become unusable for new + // checkouts. We deliberately do NOT archive the prices individually: + // Stripe forbids archiving a product's default_price, and there's no need. + await stripe.products.update(product.id, { active: false }); + } +} + +async function upsertProduct(spec: TierSpec): Promise { + const existing = await listAll((p) => + stripe.products.list(p) + ); + const metadata = { + tierId: spec.tierId, + creditsAmount: spec.creditsAmount, + subscriptionDays: spec.subscriptionDays, + }; + const match = existing.find((p) => p.metadata?.tierId === spec.tierId); + if (match) { + const updated = await stripe.products.update(match.id, { + name: spec.name, + active: true, + metadata, + }); + console.log(` reused product ${updated.id} (${spec.name})`); + return updated; + } + const created = await stripe.products.create({ name: spec.name, metadata }); + console.log(` created product ${created.id} (${spec.name})`); + return created; +} + +async function upsertPrice( + productId: string, + spec: TierSpec, + cadence: Cadence, + currency: Currency +): Promise { + const amount = spec.prices[cadence][currency]; + const interval = CADENCE_TO_INTERVAL[cadence]; + const existing = await listAll((p) => stripe.prices.list(p), { + product: productId, + }); + + const exactMatch = existing.find( + (p) => + p.active && + p.metadata?.tierId === spec.tierId && + p.metadata?.cadence === cadence && + p.metadata?.currency === currency && + p.unit_amount === amount && + p.recurring?.interval === interval + ); + if (exactMatch) { + console.log(` reused ${cadence}/${currency} -> ${exactMatch.id}`); + return exactMatch.id; + } + + // Stripe prices are immutable — archive any stale price in the same slot. + for (const stale of existing) { + if ( + stale.active && + stale.metadata?.tierId === spec.tierId && + stale.metadata?.cadence === cadence && + stale.metadata?.currency === currency + ) { + await stripe.prices.update(stale.id, { active: false }); + console.log(` archived stale ${cadence}/${currency} ${stale.id}`); + } + } + + const created = await stripe.prices.create({ + product: productId, + unit_amount: amount, + currency, + recurring: { interval }, + nickname: `${spec.name} ${cadence} ${currency.toUpperCase()}`, + metadata: { tierId: spec.tierId, cadence, currency }, + }); + console.log(` created ${cadence}/${currency} -> ${created.id}`); + return created.id; +} + +async function main(): Promise { + if (!process.env.STRIPE_SECRET_KEY) { + throw new Error("STRIPE_SECRET_KEY is not set in server/.env"); + } + const mode = process.env.STRIPE_SECRET_KEY.startsWith("sk_live") + ? "LIVE" + : "TEST"; + console.log(`Stripe pricing setup - ${mode} mode\n`); + + console.log("Archiving legacy products..."); + await archiveLegacyProducts(); + + const result: Record< + string, + { productId: string; prices: Record> } + > = {}; + + for (const spec of TIER_SPECS) { + console.log(`\n${spec.name}:`); + const product = await upsertProduct(spec); + const prices: Record> = { + monthly: {} as Record, + annual: {} as Record, + }; + for (const cadence of CADENCES) { + for (const currency of CURRENCIES) { + prices[cadence][currency] = await upsertPrice( + product.id, + spec, + cadence, + currency + ); + } + } + result[spec.tierId] = { productId: product.id, prices }; + } + + console.log( + "\n\n=== Paste these IDs into server/src/modules/subscription/tiers.ts ===\n" + ); + for (const tierId of KNOWN_TIER_IDS) { + const r = result[tierId]; + const m = r.prices.monthly; + const a = r.prices.annual; + console.log(`${tierId}:`); + console.log(` stripeProductId: "${r.productId}",`); + console.log(` prices: {`); + console.log( + ` monthly: { usd: "${m.usd}", eur: "${m.eur}", gbp: "${m.gbp}" },` + ); + console.log( + ` annual: { usd: "${a.usd}", eur: "${a.eur}", gbp: "${a.gbp}" },` + ); + console.log(` },\n`); + } +} + +main() + .then(() => { + console.log("Done."); + process.exit(0); + }) + .catch((err) => { + console.error("Stripe setup failed:", err); + process.exit(1); + }); diff --git a/server/src/config.ts b/server/src/config.ts index b9ebdc8..19a34ab 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -10,6 +10,7 @@ export const LIVE_SESSION_COLLECTION = "live_session"; export const SUBSCRIPTION_COLLECTION = "subscription"; export const USAGE_COLLECTION = "usage"; export const FREE_CREDIT_COLLECTION = "free_credit"; +export const FLUENT_WAITLIST_COLLECTION = "fluent_waitlist"; // Payment gateway collections export const PAYMENT_COLLECTION = "payment"; @@ -17,7 +18,7 @@ export const PAYMENT_SESSION_COLLECTION = "payment_session"; // Freemium default values export const FREEMIUM_DEFAULT_CREDITS = 5000000; // 5M credits -export const FREEMIUM_DEFAULT_SAVE_WORDS = 50; // 50 words can be saved +export const FREEMIUM_DEFAULT_SAVE_WORDS = 200; // 200 words / 30-day window (Starter tier) export const FREEMIUM_DEFAULT_LIVED_SESSIONS = 3; // 3 lived sessions can be created export const FREEMIUM_DURATION_DAYS = 30; // 1 month diff --git a/server/src/index.ts b/server/src/index.ts index ae9f90a..207566b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -84,6 +84,10 @@ const app = createRest({ return false; }, }, + // Expose the raw request body so the Stripe webhook can verify signatures. + koaBodyOptions: { + includeUnparsed: true, + }, mongo: { mongoBaseAddress: process.env.MONGO_BASE_ADDRESS || "mongodb://localhost:27017", diff --git a/server/src/modules/gateway/README.md b/server/src/modules/gateway/README.md index 0c4b8c9..ddacd38 100644 --- a/server/src/modules/gateway/README.md +++ b/server/src/modules/gateway/README.md @@ -87,11 +87,17 @@ This pattern allows for: ### Creating a Payment Session +The session is created from a tier + billing cadence + currency — the frontend +never holds raw Stripe price IDs. The adapter resolves the price from the tier +registry (`subscription/tiers.ts`). + ```typescript // Client-side code const result = await functionProvider.run("createPaymentSession", { - productId: "stripe_product_id", - provider: "stripe", // Optional, defaults to Stripe + tierId: "learner", // a paid, live tier + cadence: "monthly", // "monthly" | "annual" + currency: "usd", // "usd" | "eur" | "gbp" + userId: currentUserId, successUrl: "https://example.com/success", cancelUrl: "https://example.com/cancel" }); @@ -124,13 +130,44 @@ https://your-api-domain.com/gateway/webhook/stripe ## Environment Variables - `STRIPE_SECRET_KEY`: Stripe API secret key +- `STRIPE_WEBHOOK_SECRET`: Stripe webhook signing secret. When set, the + `/webhook/stripe` handler verifies the signature; when unset it accepts the + parsed payload unverified and logs a warning (local dev only). ## Integration with Other Modules - **Subscription Module**: Triggers add credits to user subscriptions upon successful payments - **Auth Module**: User authentication for payment sessions -## Stripe CLI +## Local development — Stripe webhooks + +Stripe can't reach `localhost`, so subscription creation (which happens in the +`customer.subscription.created` webhook) won't run locally without the Stripe +CLI forwarding events: + ```bash -stripe listen --forward-to localhost:8080/gateway/webhook/stripe -``` \ No newline at end of file +stripe login # one-time +stripe listen --forward-to localhost:8080/gateway/webhook/stripe # keep running +``` + +`stripe listen` prints a `whsec_...` signing secret — put it in `server/.env` +as `STRIPE_WEBHOOK_SECRET` and restart the server. It only forwards events that +fire **while it is running**; events from an earlier checkout are missed — run a +fresh checkout, or resend the event from the Stripe dashboard. + +The webhook route is mounted at `/gateway/webhook/stripe` (modular-rest mounts +each module's router under `/`). + +## 3-day trial + +The Learner tier's 3-day, credit-card-required trial is **not** a Stripe +dashboard / product / price setting — it is applied in code, per checkout +session. `createCheckoutSession` (`adapters/stripe.adapter.ts`) sets +`subscription_data.trial_period_days` from `tier.trialDays`, and +`payment_method_collection: "always"` forces the card up front. The trial +length lives in the registry — `subscription/tiers.ts` (`learner.trialDays`) — +so changing it needs no Stripe change. + +To inspect a trial in Stripe, look at the resulting **Subscription** (Customers +→ the customer, or Billing → Subscriptions) — it shows status `Trialing` with a +trial-end date. Nothing trial-related appears on the Product or Price. \ No newline at end of file diff --git a/server/src/modules/gateway/adapters/stripe.adapter.ts b/server/src/modules/gateway/adapters/stripe.adapter.ts index 82e9ed5..ba84d81 100644 --- a/server/src/modules/gateway/adapters/stripe.adapter.ts +++ b/server/src/modules/gateway/adapters/stripe.adapter.ts @@ -18,6 +18,11 @@ import { PaymentVerificationResult, } from "./types"; import { PaymentSession } from "../types"; +import { getTier, resolveTierByPriceId } from "../../subscription/tiers"; +import { + trackServerEvent, + SERVER_ANALYTICS_EVENTS, +} from "../../../utils/analytics"; /** * Stripe payment adapter implementation @@ -46,10 +51,23 @@ export class StripeAdapter implements PaymentAdapter { private async getOrCreateStripeCustomer(userId: string): Promise { // Get the stripe_customer collection const stripeCustomerCollection = getCollection(DATABASE, "stripe_customer"); - // Try to find existing mapping - let record = await stripeCustomerCollection.findOne({ user_id: userId }); - if (record && record.get("customer_id")) { - return record.get("customer_id"); + // Try to find an existing mapping + const record = await stripeCustomerCollection.findOne({ user_id: userId }); + const storedCustomerId = record?.get("customer_id"); + + if (storedCustomerId) { + // Verify the stored customer still exists in Stripe — it may have been + // deleted out-of-band. If so, fall through and create a fresh one so the + // mapping self-heals instead of failing every checkout/portal call. + try { + const existing = await this.stripe.customers.retrieve(storedCustomerId); + if (!(existing as any).deleted) { + return storedCustomerId; + } + } catch (err: any) { + if (err?.code !== "resource_missing") throw err; + // resource_missing => the customer was deleted; recreate below. + } } const user = await userManager.getUserById(userId); @@ -74,70 +92,77 @@ export class StripeAdapter implements PaymentAdapter { return customer.id; } + /** + * Look up our internal userId for a Stripe customer ID, or null if unknown. + */ + private async getUserIdForCustomer( + customerId: string + ): Promise { + const stripeCustomerCollection = getCollection( + DATABASE, + "stripe_customer" + ); + const record = await stripeCustomerCollection.findOne({ + customer_id: customerId, + }); + return record?.user_id || null; + } + /** * Create a checkout session for Stripe */ async createCheckoutSession( request: CreateCheckoutRequest ): Promise { - const { userId, productId, successUrl, cancelUrl } = request; - - // Ensure Stripe customer exists for this user - const customerId = await this.getOrCreateStripeCustomer(userId); - - // Fetch product details from Stripe - const product = await this.stripe.products.retrieve(productId); + const { userId, tierId, cadence, currency, successUrl, cancelUrl } = + request; - if (!product) { - throw new Error(`Invalid product ID: ${productId}`); + // Resolve the tier + Stripe price ID from the registry (the source of truth). + const tier = getTier(tierId); + if (!tier.isPaid || tier.status !== "live") { + throw new Error(`Tier "${tierId}" is not available for checkout`); } - - // Get metadata from the product - const creditsAmount = parseInt(product.metadata.creditsAmount || "0", 10); - const subscriptionDays = parseInt( - product.metadata.subscriptionDays || "0", - 10 - ); - - if (!creditsAmount || !subscriptionDays) { + const priceId = tier.prices?.[cadence]?.[currency]; + if (!priceId) { throw new Error( - "Product is missing required metadata: creditsAmount or subscriptionDays" + `No Stripe price configured for tier "${tierId}" ${cadence}/${currency}` ); } - // Get price data for this product - const prices = await this.stripe.prices.list({ - product: productId, - active: true, - limit: 1, - }); + // Ensure Stripe customer exists for this user + const customerId = await this.getOrCreateStripeCustomer(userId); - if (!prices.data.length) { - throw new Error("No active price found for this product"); - } + // Fetch the price so the payment_session record mirrors its amount/currency. + const price = await this.stripe.prices.retrieve(priceId); + + const sessionMetadata: Record = { + userId, + tierId, + cadence, + currency, + priceId, + creditsAmount: tier.creditBudget.toString(), + subscriptionDays: tier.durationDays.toString(), + }; - const price = prices.data[0]; + // The trial is credit-card-required: `payment_method_collection: "always"` + // forces card collection even though the subscription starts in a trial. + const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData = + { metadata: sessionMetadata }; + if (tier.trialDays) { + subscriptionData.trial_period_days = tier.trialDays; + } - // Create a Stripe checkout session const session = await this.stripe.checkout.sessions.create({ payment_method_types: ["card"], - line_items: [ - { - price: price.id, - quantity: 1, - }, - ], + line_items: [{ price: priceId, quantity: 1 }], mode: "subscription", customer: customerId, success_url: `${successUrl}?session_id={CHECKOUT_SESSION_ID}`, cancel_url: cancelUrl, - metadata: { - ...product.metadata, - userId, - productId, - creditsAmount: creditsAmount.toString(), - subscriptionDays: subscriptionDays.toString(), - }, + payment_method_collection: "always", + subscription_data: subscriptionData, + metadata: sessionMetadata, }); // Save session in database @@ -157,8 +182,8 @@ export class StripeAdapter implements PaymentAdapter { status: "created", provider_data: { session_id: session.id, - price_id: price.id, - product_id: productId, + price_id: priceId, + product_id: tier.stripeProductId, expires_at: new Date(Date.now() + 30 * 60 * 1000), // 30 minutes expiry success_url: successUrl, cancel_url: cancelUrl, @@ -296,10 +321,8 @@ export class StripeAdapter implements PaymentAdapter { case "customer.subscription.created": { const subscription = event.data.object as Stripe.Subscription; - // 1. Get the Stripe Customer ID from the subscription + // 1. Resolve our userId from the Stripe customer. const stripeCustomerId = subscription.customer as string; - - // 2. Look up your userId from your database const stripeCustomerCollection = getCollection( DATABASE, "stripe_customer" @@ -315,39 +338,37 @@ export class StripeAdapter implements PaymentAdapter { }; } - // 3. Get the invoice id from Stripe via the latest invoice - let invoice_id: string | undefined = undefined; - if (subscription.latest_invoice) { - const invoice = await this.stripe.invoices.retrieve( - subscription.latest_invoice as string - ); - invoice_id = (invoice.id as string) || undefined; + // 2. Resolve the tier from the price ID via the registry — the + // registry, not Stripe product metadata, is the source of truth + // for the credit budget. + const item = subscription.items.data[0]; + const priceId = item.price.id; + const resolved = resolveTierByPriceId(priceId); + if (!resolved) { + return { + success: false, + message: `No tier matches Stripe price ${priceId}`, + }; } + const { tier, cadence } = resolved; - // 4. Get product metadata - const subscriptionItem = subscription.items.data[0]; - const priceId = subscriptionItem.price.id; - const price = await this.stripe.prices.retrieve(priceId); - const productId = price.product as string; - const product = await this.stripe.products.retrieve(productId); - const creditsAmount = product.metadata.creditsAmount; - - // 5. Get the current period start and end - const currentPeriodStart = - subscription.items.data[0].current_period_start; - const currentPeriodEnd = - subscription.items.data[0].current_period_end; - - // 6. Add credits to user's subscription + // 3. Create the subscription with the tier's credit budget. A + // trialing subscription still gets the full budget so the trial + // actually unlocks the tier. await addNewSubscriptionWithCredit({ userId, - creditAmount: parseInt(creditsAmount, 10), - startDateUnixTimestamp: currentPeriodStart, - endDateUnixTimestamp: currentPeriodEnd, + creditAmount: tier.creditBudget, + startDateUnixTimestamp: item.current_period_start, + endDateUnixTimestamp: item.current_period_end, + tier: tier.id, + subscriptionType: cadence, + priceId, + status: subscription.status, + trialEndUnixTimestamp: subscription.trial_end ?? undefined, paymentMetaData: { provider: this.provider, stripe: { - label: product.name, + label: tier.userFacingName, subscription_id: subscription.id, }, }, @@ -363,13 +384,27 @@ export class StripeAdapter implements PaymentAdapter { const subscription = event.data.object as Stripe.Subscription; try { - const { success, message } = + const { success, message, wasTrialing } = await cancelSubscriptionByProviderAndSubscriptionId({ provider: this.provider, subscriptionId: subscription.id, status: subscription.status, }); + // A cancel that happened while still trialing is a trial cancel — + // fire the server-truth analytics event. + if (wasTrialing) { + const userId = await this.getUserIdForCustomer( + subscription.customer as string + ); + if (userId) { + trackServerEvent( + SERVER_ANALYTICS_EVENTS.TRIAL_CANCELED, + userId + ); + } + } + return { success, message, @@ -384,21 +419,46 @@ export class StripeAdapter implements PaymentAdapter { case "customer.subscription.updated": { const subscription = event.data.object as Stripe.Subscription; + const item = subscription.items.data[0]; + const priceId = item.price.id; + const previousAttributes = (event.data as any).previous_attributes; - const currentPeriodStart = - subscription.items.data[0].current_period_start; - const currentPeriodEnd = - subscription.items.data[0].current_period_end; + // Resolve the tier so a period rollover (renewal, or the trial->paid + // transition) can refill the correct credit budget. + const resolved = resolveTierByPriceId(priceId); const { success, message } = await updateSubscriptionStatusByProviderAndSubscriptionId({ provider: this.provider, subscriptionId: subscription.id, status: subscription.status, - startDateUnixTimestamp: currentPeriodStart, - endDateUnixTimestamp: currentPeriodEnd, + startDateUnixTimestamp: item.current_period_start, + endDateUnixTimestamp: item.current_period_end, + tier: resolved?.tier.id, + subscriptionType: resolved?.cadence, + priceId, + creditAmount: resolved?.tier.creditBudget, + trialEndUnixTimestamp: subscription.trial_end ?? undefined, + cancelAtPeriodEnd: subscription.cancel_at_period_end, }); + // trial -> paid conversion: fire the server-truth analytics event. + if ( + previousAttributes?.status === "trialing" && + subscription.status === "active" + ) { + const userId = await this.getUserIdForCustomer( + subscription.customer as string + ); + if (userId) { + trackServerEvent( + SERVER_ANALYTICS_EVENTS.TRIAL_CONVERTED, + userId, + { cadence: resolved?.cadence, tier: resolved?.tier.id } + ); + } + } + return { success, message, diff --git a/server/src/modules/gateway/adapters/types.ts b/server/src/modules/gateway/adapters/types.ts index 1561c08..a6ebe40 100644 --- a/server/src/modules/gateway/adapters/types.ts +++ b/server/src/modules/gateway/adapters/types.ts @@ -1,4 +1,5 @@ import { Types } from "mongoose"; +import { TierId, Cadence, Currency } from "../../subscription/tiers"; /** * Supported payment providers @@ -9,11 +10,15 @@ export enum PaymentProvider { } /** - * Common request interface for creating a checkout session + * Common request interface for creating a checkout session. + * The caller passes tier + cadence + currency; the adapter resolves the Stripe + * price ID from the tier registry, so the frontend never holds raw price IDs. */ export interface CreateCheckoutRequest { userId: string; - productId: string; + tierId: TierId; + cadence: Cadence; + currency: Currency; successUrl?: string; cancelUrl?: string; } diff --git a/server/src/modules/gateway/functions.ts b/server/src/modules/gateway/functions.ts index 231375f..80a2e11 100644 --- a/server/src/modules/gateway/functions.ts +++ b/server/src/modules/gateway/functions.ts @@ -6,6 +6,7 @@ import { } from "./service"; import { CheckoutSessionRequest } from "./types"; import { PaymentProvider } from "./adapters"; +import { TierId, Cadence, Currency } from "../subscription/tiers"; /** * Array of exported functions for the gateway module @@ -13,20 +14,24 @@ import { PaymentProvider } from "./adapters"; */ interface CreatePaymentParams { - productId: string; + tierId: TierId; + cadence: Cadence; + currency: Currency; provider?: PaymentProvider; successUrl?: string; cancelUrl?: string; userId?: string; } -// Create a payment session for a product +// Create a payment session for a tier const createPaymentSession = defineFunction({ name: "createPaymentSession", permissionTypes: ["user_access"], callback: async function (params: CreatePaymentParams) { const { - productId, + tierId, + cadence, + currency, provider = PaymentProvider.STRIPE, successUrl, cancelUrl, @@ -38,7 +43,9 @@ const createPaymentSession = defineFunction({ } const request: CheckoutSessionRequest = { - productId, + tierId, + cadence, + currency, provider, successUrl, cancelUrl, diff --git a/server/src/modules/gateway/router.ts b/server/src/modules/gateway/router.ts index 2b3853d..704de98 100644 --- a/server/src/modules/gateway/router.ts +++ b/server/src/modules/gateway/router.ts @@ -1,38 +1,47 @@ import Router from "koa-router"; import { handleWebhookEvent } from "./service"; import Stripe from "stripe"; -import { PaymentProvider } from "./adapters"; +import { PaymentProvider, PaymentAdapterFactory } from "./adapters"; const name = "gateway"; const getway = new Router(); +// koa-body exposes the raw (unparsed) request body under this symbol when +// `koaBodyOptions.includeUnparsed` is enabled (see server/src/index.ts). +const UNPARSED_BODY = Symbol.for("unparsedBody"); + // Handle Stripe webhook events getway.post("/webhook/stripe", async (ctx: any) => { let event: Stripe.Event; try { - // // Buffer the raw body for Stripe signature verification - // const rawBody = await getRawBody(ctx.req, { - // length: ctx.request.length, - // limit: "1mb", - // encoding: ctx.request.charset || "utf-8", - // }); - // const signature = ctx.headers["stripe-signature"] as string; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + const signature = ctx.headers["stripe-signature"] as string | undefined; - // if (webhookSecret) { - // event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret); - // } else { - // event = JSON.parse(rawBody.toString()); - // console.warn( - // "Webhook signature verification skipped - webhook secret not configured" - // ); - // } + if (webhookSecret && signature) { + // Stripe signature verification needs the exact raw request bytes. + const rawBody = ctx.request.body?.[UNPARSED_BODY]; + if (!rawBody) { + throw new Error( + "Raw request body unavailable - cannot verify Stripe signature" + ); + } + const stripe = PaymentAdapterFactory.getStripeAdapter().stripe; + event = stripe.webhooks.constructEvent( + rawBody, + signature, + webhookSecret + ); + } else { + // No secret configured (local dev without `stripe listen`) - accept the + // already-parsed body unverified, but warn loudly. + console.warn( + "[gateway] STRIPE_WEBHOOK_SECRET not set - webhook signature verification skipped" + ); + event = ctx.request.body as Stripe.Event; + } - // Process the event - const result = await handleWebhookEvent( - ctx.request.body, - PaymentProvider.STRIPE - ); + const result = await handleWebhookEvent(event, PaymentProvider.STRIPE); if (result.success) { ctx.body = { received: true, message: result.message }; diff --git a/server/src/modules/gateway/service.ts b/server/src/modules/gateway/service.ts index 3a98b6e..b9d3401 100644 --- a/server/src/modules/gateway/service.ts +++ b/server/src/modules/gateway/service.ts @@ -32,7 +32,9 @@ export async function createCheckoutSession( try { const result = await adapter.createCheckoutSession({ userId, - productId: request.productId, + tierId: request.tierId, + cadence: request.cadence, + currency: request.currency, successUrl: request.successUrl, cancelUrl: request.cancelUrl, }); diff --git a/server/src/modules/gateway/types.ts b/server/src/modules/gateway/types.ts index 163fb48..af28b73 100644 --- a/server/src/modules/gateway/types.ts +++ b/server/src/modules/gateway/types.ts @@ -1,5 +1,6 @@ import { Types } from "mongoose"; import { PaymentProvider } from "./adapters/types"; +import { TierId, Cadence, Currency } from "../subscription/tiers"; export type PaymentStatus = "pending" | "succeeded" | "failed" | "canceled"; export type SessionStatus = "created" | "completed" | "expired" | "failed"; @@ -29,7 +30,9 @@ export interface PaymentSession { } export interface CheckoutSessionRequest { - productId: string; + tierId: TierId; + cadence: Cadence; + currency: Currency; successUrl?: string; cancelUrl?: string; provider?: PaymentProvider; // Optional - defaults to STRIPE if not specified diff --git a/server/src/modules/live_session/gemini/functions.ts b/server/src/modules/live_session/gemini/functions.ts index 1487c4c..3cb32a0 100644 --- a/server/src/modules/live_session/gemini/functions.ts +++ b/server/src/modules/live_session/gemini/functions.ts @@ -23,6 +23,7 @@ import { GEMINI_TOKEN_TTL_MS, GEMINI_NEW_SESSION_TTL_MS, } from "./config"; +import { AI_CREDIT_EXHAUSTED_CODE } from "../../subscription/config"; import { GeminiLiveSessionType } from "./types"; interface GeminiPracticeSetup { @@ -56,14 +57,17 @@ export const requestGeminiEphemeralToken = defineFunction({ ): Promise { const { userId, instructions, voice, tools, isResume } = setup; + // AI features are the only thing the credit budget gates. `minCredits: 1` + // means this blocks only at true exhaustion (the 100% hard cap) — saves + // and Smart Review are never credit-gated and keep working. const { allowedToProceed } = await checkCreditAllocation({ userId, - minCredits: 500000, + minCredits: 1, }); if (!allowedToProceed) { throw new Error( - "User does not have enough credit or does not have an active subscription" + `${AI_CREDIT_EXHAUSTED_CODE}: AI features are paused — this month's AI budget is used up.` ); } diff --git a/server/src/modules/live_session/openai/functions.ts b/server/src/modules/live_session/openai/functions.ts index 7d98a78..9154260 100644 --- a/server/src/modules/live_session/openai/functions.ts +++ b/server/src/modules/live_session/openai/functions.ts @@ -18,6 +18,7 @@ import { updateFreemiumAllocation, } from "../../subscription/service"; import { LIVE_SESSION_MODEL } from "./config"; +import { AI_CREDIT_EXHAUSTED_CODE } from "../../subscription/config"; const fetch = require("node-fetch"); interface PracticeSetup { @@ -60,14 +61,17 @@ export const requestEphemeralToken = defineFunction({ } } + // AI features are the only thing the credit budget gates. `minCredits: 1` + // means this blocks only at true exhaustion (the 100% hard cap) — saves + // and Smart Review are never credit-gated and keep working. const { allowedToProceed } = await checkCreditAllocation({ userId: setup.userId, - minCredits: 500000, + minCredits: 1, }); if (!allowedToProceed) { throw new Error( - "User does not have enough credit or does not have an active subscription" + `${AI_CREDIT_EXHAUSTED_CODE}: AI features are paused — this month's AI budget is used up.` ); } diff --git a/server/src/modules/subscription/__tests__/tiers.test.ts b/server/src/modules/subscription/__tests__/tiers.test.ts new file mode 100644 index 0000000..a17d2af --- /dev/null +++ b/server/src/modules/subscription/__tests__/tiers.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "@jest/globals"; +import { + TIERS, + getTier, + liveTiers, + resolveTierByPriceId, + resolveTierByProductId, + tierAllowsFeature, + featureCap, +} from "../tiers"; + +describe("tier registry", () => { + describe("getTier", () => { + it("returns the requested tier definition", () => { + expect(getTier("starter").userFacingName).toBe("Starter"); + expect(getTier("learner").userFacingName).toBe("Learner"); + expect(getTier("fluent").userFacingName).toBe("Fluent"); + }); + }); + + describe("liveTiers", () => { + it("includes live tiers and excludes dark ones", () => { + const ids = liveTiers().map((t) => t.id); + expect(ids).toContain("starter"); + expect(ids).toContain("learner"); + expect(ids).not.toContain("fluent"); // Fluent ships dark + }); + }); + + describe("resolveTierByPriceId", () => { + it("round-trips a known price ID to tier/cadence/currency", () => { + const priceId = TIERS.learner.prices!.annual.gbp!; + const resolved = resolveTierByPriceId(priceId); + expect(resolved).not.toBeNull(); + expect(resolved!.tier.id).toBe("learner"); + expect(resolved!.cadence).toBe("annual"); + expect(resolved!.currency).toBe("gbp"); + }); + + it("resolves a dark tier's price ID", () => { + const priceId = TIERS.fluent.prices!.monthly.usd!; + const resolved = resolveTierByPriceId(priceId); + expect(resolved!.tier.id).toBe("fluent"); + expect(resolved!.cadence).toBe("monthly"); + expect(resolved!.currency).toBe("usd"); + }); + + it("returns null for an unknown price ID", () => { + expect(resolveTierByPriceId("price_does_not_exist")).toBeNull(); + }); + }); + + describe("resolveTierByProductId", () => { + it("resolves known product IDs", () => { + expect(resolveTierByProductId(TIERS.learner.stripeProductId!)!.id).toBe( + "learner" + ); + expect(resolveTierByProductId(TIERS.fluent.stripeProductId!)!.id).toBe( + "fluent" + ); + }); + + it("returns null for an unknown product ID", () => { + expect(resolveTierByProductId("prod_does_not_exist")).toBeNull(); + }); + + it("has no Stripe product for the free Starter tier", () => { + expect(TIERS.starter.stripeProductId).toBeNull(); + }); + }); + + describe("tierAllowsFeature", () => { + it("locks weekly insights and session history on Starter", () => { + expect(tierAllowsFeature("starter", "weekly_insights")).toBe(false); + expect(tierAllowsFeature("starter", "session_history")).toBe(false); + }); + + it("allows capped-but-available features on Starter", () => { + expect(tierAllowsFeature("starter", "save_words")).toBe(true); + expect(tierAllowsFeature("starter", "ai_credits")).toBe(true); + expect(tierAllowsFeature("starter", "smart_review")).toBe(true); + expect(tierAllowsFeature("starter", "live_conversations")).toBe(true); + }); + + it("unlocks the gated features on Learner", () => { + expect(tierAllowsFeature("learner", "weekly_insights")).toBe(true); + expect(tierAllowsFeature("learner", "session_history")).toBe(true); + }); + }); + + describe("featureCap", () => { + it("returns the hard cap, 0 for locked, null for unlimited", () => { + expect(featureCap("starter", "save_words")).toBe(200); + expect(featureCap("starter", "weekly_insights")).toBe(0); + expect(featureCap("learner", "save_words")).toBeNull(); + }); + + it("exposes each tier's AI credit budget", () => { + expect(featureCap("starter", "ai_credits")).toBe(5_000_000); + expect(featureCap("learner", "ai_credits")).toBe(300_000_000); + expect(featureCap("fluent", "ai_credits")).toBe(600_000_000); + }); + }); + + describe("registry invariants", () => { + it("keeps caps.ai_credits in sync with creditBudget", () => { + for (const tier of Object.values(TIERS)) { + expect(tier.caps.ai_credits).toBe(tier.creditBudget); + } + }); + + it("never exposes the word 'credit' in user-facing copy", () => { + for (const tier of Object.values(TIERS)) { + const copy = [tier.tagline, tier.aiBudgetLabel, ...tier.featureLabels] + .join(" ") + .toLowerCase(); + expect(copy).not.toContain("credit"); + } + }); + + it("only the Learner tier offers a trial at launch", () => { + expect(getTier("starter").trialDays).toBe(0); + expect(getTier("learner").trialDays).toBe(3); + expect(getTier("fluent").trialDays).toBe(0); + }); + }); +}); diff --git a/server/src/modules/subscription/config.ts b/server/src/modules/subscription/config.ts index 61b0543..17fa739 100644 --- a/server/src/modules/subscription/config.ts +++ b/server/src/modules/subscription/config.ts @@ -18,3 +18,14 @@ export const TOKEN_M_UNIT = 1_000_000; // Threshold for triggering low credits warning (in credits) export const LOW_CREDITS_THRESHOLD = 500000; + +// Percentage of the AI budget used at which the "running low" soft-cap banner +// appears. Hard cap (AI features pause) is always at 100%. +export const SOFT_CAP_PERCENT = 80; + +/** + * Stable error code thrown when an AI operation is blocked because the user's + * AI budget is exhausted (100% used). The frontend pattern-matches this code + * to show the upgrade modal instead of a generic error toast. + */ +export const AI_CREDIT_EXHAUSTED_CODE = "AI_CREDIT_EXHAUSTED"; diff --git a/server/src/modules/subscription/db.ts b/server/src/modules/subscription/db.ts index 8b2f1bc..90ac101 100644 --- a/server/src/modules/subscription/db.ts +++ b/server/src/modules/subscription/db.ts @@ -3,6 +3,7 @@ import { Types } from "mongoose"; import { DATABASE, FREE_CREDIT_COLLECTION, + FLUENT_WAITLIST_COLLECTION, FREEMIUM_DURATION_DAYS, SUBSCRIPTION_COLLECTION, USAGE_COLLECTION, @@ -43,6 +44,7 @@ const subscriptionCollection = defineCollection({ enum: [ "active", "canceled", + "expired", "incomplete", "incomplete_expired", "past_due", @@ -53,6 +55,36 @@ const subscriptionCollection = defineCollection({ required: true, default: "active", }, + // Pricing-tier ladder (Council 002). Optional: pre-rollout subscriptions + // and freemium-derived records may not carry a tier. + tier: { + type: String, + enum: ["starter", "learner", "fluent"], + required: false, + }, + // Billing cadence of the paid subscription. + subscription_type: { + type: String, + enum: ["monthly", "annual"], + required: false, + }, + // Stripe price ID that created this subscription — lets the webhook and UI + // resolve tier/cadence/currency without re-hitting Stripe. + price_id: { + type: String, + required: false, + }, + // End of the credit-card-required free trial, when the subscription is trialing. + trial_end: { + type: Date, + required: false, + }, + // True when the subscription is scheduled to cancel at the end of the + // current period — set when the user cancels via the Stripe portal. + cancel_at_period_end: { + type: Boolean, + default: false, + }, payment_meta_data: { type: Object, required: false, @@ -236,6 +268,12 @@ const freeCreaditCollection = defineCollection({ required: true, default: 0, }, + // One-shot guard: set true after the `starter-ai-exhausted` analytics + // event fires, so it fires at most once per 30-day allocation window. + ai_exhausted_flagged: { + type: Boolean, + default: false, + }, }, { timestamps: true, @@ -267,8 +305,57 @@ freeCreaditCollection.schema return this.total_credits - this.credits_used; }); +// Add virtual property for usage percentage (mirrors the subscription virtual) +freeCreaditCollection.schema + .virtual("usage_percentage") + .get(function (this: any) { + if (this.total_credits <= 0) return 0; + const percentage = Math.round( + (this.credits_used / this.total_credits) * 100 + ); + return Math.min(percentage, 100); // Cap at 100% + }); + +// Latent-demand waitlist for the Fluent tier while it ships "dark". +// One row per user (upserted) — the captured emails get migrated into a +// complimentary Fluent unlock when PRFAQ-003 (Mini Lectures) ships. +const fluentWaitlistCollection = defineCollection({ + database: DATABASE, + collection: FLUENT_WAITLIST_COLLECTION, + schema: new Schema( + { + user_id: { + type: Types.ObjectId, + required: true, + ref: `${DATABASE}.users`, + unique: true, + }, + email: { + type: String, + required: true, + }, + }, + { timestamps: true } + ), + permissions: [ + new Permission({ + accessType: "user_access", + read: true, + write: true, + onlyOwnData: true, + ownerIdField: "user_id", + }), + new Permission({ + accessType: "admin", + read: true, + write: true, + }), + ], +}); + module.exports = [ subscriptionCollection, usageCollection, freeCreaditCollection, + fluentWaitlistCollection, ]; diff --git a/server/src/modules/subscription/events.ts b/server/src/modules/subscription/events.ts index 4141cf5..c28ed54 100644 --- a/server/src/modules/subscription/events.ts +++ b/server/src/modules/subscription/events.ts @@ -7,6 +7,7 @@ export const subscriptionEvents = new EventEmitter(); // Define event types export const EVENT_TYPES = { LOW_CREDITS: "low_credits", + SOFT_CAP_REACHED: "soft_cap_reached", SUBSCRIPTION_CHANGE: "subscription_change", USAGE_SPIKE: "usage_spike", SUBSCRIPTION_EXPIRED: "subscription_expired", @@ -29,6 +30,23 @@ export function emitLowCreditsEvent( }); } +/** + * Emit soft-cap reached event — the user has used at least SOFT_CAP_PERCENT of + * their AI budget for the current window, but is not yet fully exhausted. + * @param userId - The user ID + * @param usagePercentage - Percentage of the AI budget used (80-99) + */ +export function emitSoftCapEvent( + userId: string, + usagePercentage: number +): void { + subscriptionEvents.emit(EVENT_TYPES.SOFT_CAP_REACHED, { + userId, + usagePercentage, + timestamp: new Date(), + }); +} + /** * Emit subscription change event * @param userId - The user ID diff --git a/server/src/modules/subscription/functions.ts b/server/src/modules/subscription/functions.ts index 543d4df..ec5ac4c 100644 --- a/server/src/modules/subscription/functions.ts +++ b/server/src/modules/subscription/functions.ts @@ -1,10 +1,13 @@ -import { defineFunction } from "@modular-rest/server"; - -import { SubscriptionPlan } from "./types"; +import { + defineFunction, + getCollection, + userManager, +} from "@modular-rest/server"; +import { Types } from "mongoose"; import { getSubscription, getOrCreateFreemiumAllocation } from "./service"; -import { paymentAdapterFactory, PaymentProvider } from "../gateway/adapters"; -import { StripeAdapter } from "../gateway/adapters/stripe.adapter"; +import { TIERS, PublicTierPlan } from "./tiers"; +import { DATABASE, FLUENT_WAITLIST_COLLECTION } from "../../config"; /** * Get subscription details for a user @@ -26,9 +29,11 @@ const getSubscriptionDetails = defineFunction({ const freemiumAllocation = await getOrCreateFreemiumAllocation(userId); return { ...freemiumAllocation, + tier: "starter", is_freemium: true, }; } else { + // Paid subscriptions carry `tier` on the document (post-Council-002). return { ...subscription, is_freemium: false, @@ -45,99 +50,19 @@ const getSubscriptionDetails = defineFunction({ const getSubscriptionPlans = defineFunction({ name: "getSubscriptionPlans", permissionTypes: ["anonymous_access"], - callback: async (_params) => { - const plans: SubscriptionPlan[] = [ - { - name: "Freemium Plan", - price: "0", - currency: "$", - description: "Great for casual learners", - features: [ - "Translate captions in real time", - "Save up to 30 words or phrases", - "Review vocabulary on your dashboard", - "Flashcards (limited to 10/month)", - "Al Coach (up to 3 sessions/month)", - ], - product_id: "freemium", - is_freemium: true, - }, - // { - // name: "premium", - // price: "7.60", - // currency: "£", - // description: "Ideal for focused learners", - // features: [ - // "Unlimited saves & bundle creation", - // "Unlimited flashcard reviews", - // "Unlimited Al speaking practice", - // "Weekly progress insights", - // "Early access to new platforms & features", - // ], - // product_id: "prod_S4nM68SkuYEHxm", - // is_freemium: false, - // }, - // { - // name: "pro", - // price: "10", - // currency: "£", - // description: "Built for power users and perfectionists", - // features: [ - // "Priority support and feedback response", - // "Custom Al coach tuning based on learning goals", - // "Progress export and personalized learning reports", - // "Early access to grammar insights & learning tools", - // ], - // product_id: "pro", - // is_freemium: false, - // }, - ]; - - const getCurrency = (currency: string) => { - switch (currency) { - case "usd": - return "$"; - case "gbp": - return "£"; - default: - return currency; - } - }; - - // Ensure payment adapter is initialized before using it - try { - await paymentAdapterFactory.initialize(); - } catch (error) { - console.warn("Failed to initialize payment adapter:", error); - // Return only freemium plan if adapter initialization fails - return plans; - } - - const adapter = paymentAdapterFactory.getAdapter( - PaymentProvider.STRIPE - ) as StripeAdapter; - const products = await adapter.stripe.products.list(); - - for (const product of products.data) { - const prices = await adapter.stripe.prices.list({ - product: product.id, - }); - - const p1 = prices.data[0]; - - plans.push({ - name: product.name, - description: product.description || "", - price: ((p1.unit_amount || 0) / 100).toString() || "0", - currency: getCurrency(p1.currency), - product_id: product.id, - features: - product.marketing_features.map((feature) => feature.name || "") || [], - is_freemium: false, - }); - } - - return plans; + callback: async (_params): Promise => { + // Registry-driven — no live Stripe product listing. Dark tiers (Fluent) + // are included so the pricing page can render them as "Coming soon". + return Object.values(TIERS).map((tier) => ({ + id: tier.id, + status: tier.status, + name: tier.userFacingName, + tagline: tier.tagline, + isPaid: tier.isPaid, + featureLabels: tier.featureLabels, + aiBudgetLabel: tier.aiBudgetLabel, + pricing: tier.amount, + })); }, }); @@ -163,8 +88,41 @@ const getFreemiumAllowance = defineFunction({ }, }); +/** + * Add the current user to the Fluent waitlist — latent-demand capture while + * the Fluent tier ships "dark". Idempotent: upserted by user_id. + */ +const notifyFluentWaitlist = defineFunction({ + name: "notifyFluentWaitlist", + permissionTypes: ["user_access"], + callback: async (params) => { + const { userId } = params; + if (!userId) { + throw new Error("User ID is required"); + } + + const user = await userManager.getUserById(userId); + if (!user) { + throw new Error("User not found"); + } + + const waitlistCollection = getCollection( + DATABASE, + FLUENT_WAITLIST_COLLECTION + ); + await waitlistCollection.updateOne( + { user_id: Types.ObjectId(userId) }, + { $set: { user_id: Types.ObjectId(userId), email: user.email } }, + { upsert: true } + ); + + return { success: true }; + }, +}); + module.exports.functions = [ getSubscriptionDetails, getSubscriptionPlans, getFreemiumAllowance, + notifyFluentWaitlist, ]; diff --git a/server/src/modules/subscription/readme.md b/server/src/modules/subscription/readme.md index a1169dd..5b00059 100644 --- a/server/src/modules/subscription/readme.md +++ b/server/src/modules/subscription/readme.md @@ -76,13 +76,17 @@ The module exposes these API functions for external access: ```plain _id: ObjectId (PK) user_id: ObjectId (FK) -subscription_type: ENUM (monthly, quarterly, annual) +tier: ENUM (starter, learner, fluent) +subscription_type: ENUM (monthly, annual) +price_id: String (Stripe price ID that created the subscription) +trial_end: Date (set while the subscription is trialing) start_date: Date end_date: Date total_credits: Number credits_used: Number -status: ENUM (active, expired, canceled) +status: ENUM (active, canceled, expired, incomplete, incomplete_expired, past_due, paused, trialing, unpaid) available_credit: Number (virtual/calculated) +usage_percentage: Number (virtual/calculated) ``` #### Usage Table diff --git a/server/src/modules/subscription/service.ts b/server/src/modules/subscription/service.ts index 7dd068a..011bfd2 100644 --- a/server/src/modules/subscription/service.ts +++ b/server/src/modules/subscription/service.ts @@ -10,17 +10,48 @@ import { FREEMIUM_DURATION_DAYS, FREEMIUM_DEFAULT_LIVED_SESSIONS, } from "../../config"; -import { LOW_CREDITS_THRESHOLD } from "./config"; +import { LOW_CREDITS_THRESHOLD, SOFT_CAP_PERCENT } from "./config"; import { emitLowCreditsEvent, + emitSoftCapEvent, emitSubscriptionChangeEvent, emitSubscriptionExpiredEvent, } from "./events"; import { Subscription, FreeCredit } from "./types"; +import { TierId, Cadence } from "./tiers"; import { CostCalculationInput, calculatorService } from "./calculator"; import { PaymentAdapterFactory, PaymentProvider } from "../gateway/adapters"; import Stripe from "stripe"; +import { + trackServerEvent, + SERVER_ANALYTICS_EVENTS, +} from "../../utils/analytics"; + +/** + * Compute derived credit values from the raw schema fields. + * + * The subscription / free_credit schemas define `available_credit` and + * `usage_percentage` as Mongoose virtuals, but those virtuals are NOT reliably + * present on the objects `getCollection().findOne()` returns here — only + * `.toObject({ virtuals: true })` exposes them. Always derive from the raw + * `total_credits` / `credits_used` fields for in-server credit math. + */ +function computeAvailableCredits(doc: { + total_credits?: number; + credits_used?: number; +}): number { + return (doc.total_credits || 0) - (doc.credits_used || 0); +} + +function computeUsagePercentage(doc: { + total_credits?: number; + credits_used?: number; +}): number { + const total = doc.total_credits || 0; + if (total <= 0) return 0; + return Math.min(Math.round(((doc.credits_used || 0) / total) * 100), 100); +} /** * Get or create freemium allocation for a user @@ -148,9 +179,13 @@ export async function checkCreditAllocation(props: { DATABASE, SUBSCRIPTION_COLLECTION ); + // Match the statuses getSubscription/isUserOnFreemium accept — anything not + // canceled/incomplete_expired counts as the user's subscription. An exact + // status:"active" filter here silently dropped trialing/past_due/paused + // subscriptions back to the freemium pool. const activeSubscription = (await subscriptionsCollection.findOne({ user_id: Types.ObjectId(userId), - status: "active", + status: { $nin: ["canceled", "incomplete_expired"] }, end_date: { $gte: new Date() }, })) as Subscription | null; @@ -159,8 +194,8 @@ export async function checkCreditAllocation(props: { let isFreemium = false; if (activeSubscription) { - // User has active paid subscription - availableCredits = activeSubscription.available_credit || 0; + // User has an active paid subscription + availableCredits = computeAvailableCredits(activeSubscription); subscriptionEndsAt = activeSubscription.end_date; } else { // No active subscription, check freemium allocation @@ -198,6 +233,11 @@ export async function addNewSubscriptionWithCredit(props: { startDateUnixTimestamp: number; endDateUnixTimestamp: number; paymentMetaData: any; + tier?: TierId; + subscriptionType?: Cadence; + priceId?: string; + status?: Subscription["status"]; + trialEndUnixTimestamp?: number; }) { const { userId, @@ -206,6 +246,11 @@ export async function addNewSubscriptionWithCredit(props: { startDateUnixTimestamp, endDateUnixTimestamp, paymentMetaData, + tier, + subscriptionType, + priceId, + status = "active", + trialEndUnixTimestamp, } = props; const subscriptionsCollection = getCollection( DATABASE, @@ -214,13 +259,16 @@ export async function addNewSubscriptionWithCredit(props: { if ((startDateUnixTimestamp || endDateUnixTimestamp) && totalDays) { throw new Error( - "Cannot provide both startDateUnixTimestamp and endDateUnix Timestamp and totalDays" + "Cannot provide both startDateUnixTimestamp and endDateUnixTimestamp and totalDays" ); } - // Deactivate all previous subscriptions for the user + // Deactivate any previous active/trialing subscriptions for the user await subscriptionsCollection.updateMany( - { user_id: Types.ObjectId(userId), status: "active" }, + { + user_id: Types.ObjectId(userId), + status: { $in: ["active", "trialing"] }, + }, { $set: { status: "expired" } } ); @@ -236,14 +284,20 @@ export async function addNewSubscriptionWithCredit(props: { endDate = new Date(endDateUnixTimestamp * 1000); // Convert Unix timestamp to Date } - const newSubscription = { + const newSubscription: Partial = { user_id: Types.ObjectId(userId), start_date: startDate, end_date: endDate, total_credits: creditAmount, credits_used: 0, - status: "active", + status, payment_meta_data: paymentMetaData, + ...(tier && { tier }), + ...(subscriptionType && { subscription_type: subscriptionType }), + ...(priceId && { price_id: priceId }), + ...(trialEndUnixTimestamp && { + trial_end: new Date(trialEndUnixTimestamp * 1000), + }), }; const createdSubscription = await subscriptionsCollection.create( @@ -288,6 +342,11 @@ export async function cancelSubscriptionByProviderAndSubscriptionId(props: { } try { + // Capture the pre-cancel status so the webhook can tell a trial cancel + // apart from a paid cancel (for the trial-canceled analytics event). + const existing = await subscriptionsCollection.findOne(filter); + const wasTrialing = existing?.status === "trialing"; + const updateResult = await subscriptionsCollection.updateOne(filter, { $set: { status, @@ -301,11 +360,13 @@ export async function cancelSubscriptionByProviderAndSubscriptionId(props: { return { success: true, message: "Subscription canceled successfully", + wasTrialing, }; } catch (error: any) { return { success: false, message: error.message || "Failed to cancel subscription", + wasTrialing: false, }; } } @@ -313,9 +374,15 @@ export async function cancelSubscriptionByProviderAndSubscriptionId(props: { export async function updateSubscriptionStatusByProviderAndSubscriptionId(props: { provider: PaymentProvider; subscriptionId: string; - status: Stripe.Subscription.Status; + status: Subscription["status"]; startDateUnixTimestamp: number; endDateUnixTimestamp: number; + tier?: TierId; + subscriptionType?: Cadence; + priceId?: string; + creditAmount?: number; + trialEndUnixTimestamp?: number; + cancelAtPeriodEnd?: boolean; }) { const { provider, @@ -323,6 +390,12 @@ export async function updateSubscriptionStatusByProviderAndSubscriptionId(props: status, startDateUnixTimestamp, endDateUnixTimestamp, + tier, + subscriptionType, + priceId, + creditAmount, + trialEndUnixTimestamp, + cancelAtPeriodEnd, } = props; const subscriptionsCollection = getCollection( @@ -330,9 +403,10 @@ export async function updateSubscriptionStatusByProviderAndSubscriptionId(props: SUBSCRIPTION_COLLECTION ); + // Match by provider + subscription id only. A trialing OR active subscription + // can receive an `updated` event — notably the trial->paid transition. const filter: any = { "payment_meta_data.provider": provider, - status: "active", }; if (provider == PaymentProvider.STRIPE) { @@ -341,46 +415,47 @@ export async function updateSubscriptionStatusByProviderAndSubscriptionId(props: const currentSubscription = await subscriptionsCollection.findOne(filter); - if (currentSubscription) { - const currentStartTimeUnixTimestamp = - currentSubscription.start_date.getTime() / 1000; - const currentEndTimeUnixTimestamp = - currentSubscription.end_date.getTime() / 1000; - - let isSamePeriod = false; - - if (startDateUnixTimestamp && endDateUnixTimestamp) { - isSamePeriod = - startDateUnixTimestamp === currentStartTimeUnixTimestamp && - endDateUnixTimestamp === currentEndTimeUnixTimestamp; - } - - if (isSamePeriod) { - await subscriptionsCollection.updateOne(filter, { - $set: { - status, - }, - }); - } else { - await subscriptionsCollection.updateOne(filter, { - $set: { - status, - start_date: new Date(startDateUnixTimestamp * 1000), - end_date: new Date(endDateUnixTimestamp * 1000), - }, - }); - } - - return { - success: true, - message: "Subscription updated successfully", - }; - } else { + if (!currentSubscription) { return { success: false, message: "Subscription not found", }; } + + const currentStart = currentSubscription.start_date.getTime() / 1000; + const currentEnd = currentSubscription.end_date.getTime() / 1000; + const isSamePeriod = + !!startDateUnixTimestamp && + !!endDateUnixTimestamp && + startDateUnixTimestamp === currentStart && + endDateUnixTimestamp === currentEnd; + + const update: any = { status }; + if (tier) update.tier = tier; + if (subscriptionType) update.subscription_type = subscriptionType; + if (priceId) update.price_id = priceId; + update.cancel_at_period_end = !!cancelAtPeriodEnd; + update.trial_end = trialEndUnixTimestamp + ? new Date(trialEndUnixTimestamp * 1000) + : null; + + if (!isSamePeriod) { + // Billing period rolled over (renewal, or trial -> first paid period): + // move the window and refill the credit budget for the new period. + update.start_date = new Date(startDateUnixTimestamp * 1000); + update.end_date = new Date(endDateUnixTimestamp * 1000); + if (creditAmount !== undefined) { + update.total_credits = creditAmount; + update.credits_used = 0; + } + } + + await subscriptionsCollection.updateOne(filter, { $set: update }); + + return { + success: true, + message: "Subscription updated successfully", + }; } /** @@ -411,9 +486,13 @@ export async function recordUsage(props: { DATABASE, SUBSCRIPTION_COLLECTION ); + // Match the statuses getSubscription/isUserOnFreemium accept — anything not + // canceled/incomplete_expired counts as the user's subscription. An exact + // status:"active" filter here silently dropped trialing/past_due/paused + // subscriptions back to the freemium pool. const activeSubscription = (await subscriptionsCollection.findOne({ user_id: Types.ObjectId(userId), - status: "active", + status: { $nin: ["canceled", "incomplete_expired"] }, end_date: { $gte: new Date() }, })) as Subscription | null; @@ -454,6 +533,7 @@ export async function recordUsage(props: { const usageRecord = await usageCollection.create(newUsage); let remainingCredits = 0; + let usagePercentage = 0; if (isFreemium) { // Update freemium allocation's credits_used @@ -476,10 +556,33 @@ export async function recordUsage(props: { end_date: { $gte: new Date() }, })) as FreeCredit | null; - remainingCredits = updatedFreemiumAllocation - ? (updatedFreemiumAllocation.total_credits || 0) - - (updatedFreemiumAllocation.credits_used || 0) - : 0; + if (updatedFreemiumAllocation) { + remainingCredits = computeAvailableCredits(updatedFreemiumAllocation); + usagePercentage = computeUsagePercentage(updatedFreemiumAllocation); + + // Starter AI budget just hit exhaustion — fire the one-shot server-truth + // analytics event and flag the doc so it fires at most once per window. + if ( + remainingCredits <= 0 && + !updatedFreemiumAllocation.ai_exhausted_flagged + ) { + const startDate = updatedFreemiumAllocation.start_date + ? new Date(updatedFreemiumAllocation.start_date) + : new Date(); + const daysSinceAllocation = Math.floor( + (Date.now() - startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + trackServerEvent( + SERVER_ANALYTICS_EVENTS.STARTER_AI_EXHAUSTED, + userId, + { daysSinceAllocation } + ); + await freeCreditCollection.updateOne( + { _id: updatedFreemiumAllocation._id }, + { $set: { ai_exhausted_flagged: true } } + ); + } + } } else { // Update subscription's credits_used await subscriptionsCollection.updateOne( @@ -492,18 +595,24 @@ export async function recordUsage(props: { _id: activeSubscription!._id, })) as Subscription | null; - remainingCredits = updatedSubscription - ? updatedSubscription.available_credit || 0 - : 0; + if (updatedSubscription) { + remainingCredits = computeAvailableCredits(updatedSubscription); + usagePercentage = computeUsagePercentage(updatedSubscription); + } } - // Check if credits are low and emit event if needed + // Check if credits are low and emit events if needed if (remainingCredits < LOW_CREDITS_THRESHOLD) { emitLowCreditsEvent(userId, remainingCredits); } + // Soft-cap: between SOFT_CAP_PERCENT and full exhaustion (hard cap at 100%). + if (usagePercentage >= SOFT_CAP_PERCENT && usagePercentage < 100) { + emitSoftCapEvent(userId, usagePercentage); + } return { remainingCredits, + usagePercentage, usageId: usageRecord._id, status: availableCredits < creditAmount ? "overdraft" : "paid", costResult, @@ -550,18 +659,36 @@ export async function getSubscription(userId: string) { jsonSubscription["label"] = label; - const subscriptionDetails = await stripeAdapter.getSubscriptionDetails( - subscription_id - ); - - const portalSession = - await stripeAdapter.stripe.billingPortal.sessions.create({ - customer: subscriptionDetails.customer.toString(), - return_url: `${process.env.DASHBOARD_BASE_URL}/#/settings/subscription`, - }); + try { + const subscriptionDetails = await stripeAdapter.getSubscriptionDetails( + subscription_id + ); + + const portalSession = + await stripeAdapter.stripe.billingPortal.sessions.create({ + customer: subscriptionDetails.customer.toString(), + return_url: `${process.env.DASHBOARD_BASE_URL}/#/settings/subscription`, + }); + + jsonSubscription["status"] = subscriptionDetails.status; + jsonSubscription["portal_url"] = portalSession.url; + } catch (error: any) { + // The Stripe customer/subscription is gone (e.g. deleted out-of-band). + // Our record is stale — mark it canceled and report no active + // subscription instead of failing the whole request. + if (error?.code === "resource_missing") { + console.warn( + `[subscription] Stripe object missing for ${subscription_id}; marking local subscription canceled` + ); + await subscriptionsCollection.updateOne( + { _id: activeSubscription._id }, + { $set: { status: "canceled" } } + ); + return null; + } + throw error; + } - jsonSubscription["status"] = subscriptionDetails.status; - jsonSubscription["portal_url"] = portalSession.url; delete jsonSubscription.payments; } diff --git a/server/src/modules/subscription/tiers.ts b/server/src/modules/subscription/tiers.ts new file mode 100644 index 0000000..134f390 --- /dev/null +++ b/server/src/modules/subscription/tiers.ts @@ -0,0 +1,253 @@ +/** + * Tier registry — the single source of truth for SubTurtle's 3-tier pricing ladder. + * + * Pure data + pure helpers, with ZERO db/Stripe imports, so it is trivially + * unit-testable and safe to import from both the plans RPC and the Stripe webhook. + * + * "Monetize any feature with the same standard": every monetizable capability is a + * `FeatureKey` with a per-tier cap in `caps`. To monetize a NEW feature: + * 1. add a `FeatureKey` + * 2. add its cap to all three tiers + * 3. call `tierAllowsFeature(tierId, key)` at the feature's entry point + * + * STRIPE IDS: the `stripeProductId` / `prices.*` values below are PLACEHOLDERS. + * Run `yarn setup:stripe` (Phase 2) and paste the printed real IDs over them. + */ + +export type TierId = "starter" | "learner" | "fluent"; +export type Cadence = "monthly" | "annual"; +export type Currency = "usd" | "eur" | "gbp"; +export type TierStatus = "live" | "dark"; // dark = "Coming soon, Notify me" + +/** + * Every monetizable capability. A cap of `null` means unlimited, `0` means locked + * (not available on that tier), a positive number is a hard per-window cap. + */ +export type FeatureKey = + | "save_words" + | "smart_review" + | "ai_credits" + | "weekly_insights" + | "session_history" + | "live_conversations"; + +export interface TierPrices { + monthly: Partial>; // Stripe price IDs + annual: Partial>; +} + +export interface TierAmounts { + monthly: Partial>; // display amounts, major units + annual: Partial>; +} + +export interface TierDefinition { + id: TierId; + status: TierStatus; + /** User-facing name — never "Pro"/"Premium". */ + userFacingName: string; + tagline: string; + isPaid: boolean; + /** Stripe product ID; null for the free Starter tier. */ + stripeProductId: string | null; + /** Stripe price IDs per cadence/currency; null for the free Starter tier. */ + prices: TierPrices | null; + /** Display amounts per cadence/currency; null for the free Starter tier. */ + amount: TierAmounts | null; + /** Internal AI credit budget per 30-day window. Never shown to users. */ + creditBudget: number; + /** Length of a billing/allocation window in days. */ + durationDays: number; + /** Credit-card-required free trial length in days; 0 = no trial. */ + trialDays: number; + /** Per-feature caps: null = unlimited, 0 = locked, n = hard cap. */ + caps: Record; + /** Plain-English card bullets — must not contain the word "credit". */ + featureLabels: string[]; + /** Plain-English label for the AI budget on the comparison table. */ + aiBudgetLabel: string; +} + +/** + * Public, bundle-safe projection of a tier — what `getSubscriptionPlans` returns. + * Deliberately omits Stripe price IDs and the raw credit budget. + */ +export interface PublicTierPlan { + id: TierId; + status: TierStatus; + name: string; + tagline: string; + isPaid: boolean; + featureLabels: string[]; + aiBudgetLabel: string; + /** null for the free Starter tier. */ + pricing: TierAmounts | null; +} + +export const TIERS: Record = { + starter: { + id: "starter", + status: "live", + userFacingName: "Starter", + tagline: "Start learning English from the videos you already watch.", + isPaid: false, + stripeProductId: null, + prices: null, + amount: null, + creditBudget: 5_000_000, + durationDays: 30, + trialDays: 0, + caps: { + save_words: 200, + smart_review: null, + ai_credits: 5_000_000, // mirrors creditBudget + weekly_insights: 0, + session_history: 0, + live_conversations: 3, + }, + featureLabels: [ + "Save up to 200 phrases a month", + "Unlimited Smart Review flashcards", + "Hover to translate any subtitle, any time", + "A taste of AI tools each month", + "Basic progress stats", + ], + aiBudgetLabel: "a taste each month", + }, + learner: { + id: "learner", + status: "live", + userFacingName: "Learner", + tagline: "Make real progress. Learn every day without running out of tools.", + isPaid: true, + stripeProductId: "prod_UW04JV1WnBPlvv", + prices: { + monthly: { + usd: "price_1TWy4AJzqwOMGRBg9jLyvCxN", + eur: "price_1TWy4AJzqwOMGRBgFIunKN0o", + gbp: "price_1TWy4BJzqwOMGRBgiVzYadH7", + }, + annual: { + usd: "price_1TWy4CJzqwOMGRBgL0fV6xtm", + eur: "price_1TWy4CJzqwOMGRBga45x0ptR", + gbp: "price_1TWy4DJzqwOMGRBgj1vSI9UM", + }, + }, + amount: { + monthly: { usd: 9.99, eur: 9.99, gbp: 8.99 }, + annual: { usd: 95.99, eur: 95.99, gbp: 85.99 }, + }, + creditBudget: 300_000_000, + durationDays: 30, + trialDays: 3, + caps: { + save_words: null, + smart_review: null, + ai_credits: 300_000_000, // mirrors creditBudget + weekly_insights: null, + session_history: null, + live_conversations: null, + }, + featureLabels: [ + "Save as many phrases as you want — no limits", + "Full AI translation budget for daily practice", + "Live AI voice conversations — plenty each month", + "Weekly progress insights", + "Full session history", + ], + aiBudgetLabel: "full monthly budget", + }, + fluent: { + id: "fluent", + status: "dark", // not buyable until "Mini Lectures" (PRFAQ-003) ships + userFacingName: "Fluent", + tagline: "For learners who are ready to go further.", + isPaid: true, + stripeProductId: "prod_UW04hTRTzN7iiB", + prices: { + monthly: { + usd: "price_1TWy4EJzqwOMGRBgAr1Fp6o3", + eur: "price_1TWy4EJzqwOMGRBgEjgAjUU7", + gbp: "price_1TWy4FJzqwOMGRBgtqdREIq3", + }, + annual: { + usd: "price_1TWy4FJzqwOMGRBgUhy6mVXk", + eur: "price_1TWy4GJzqwOMGRBgvLf7ZxoX", + gbp: "price_1TWy4GJzqwOMGRBgTLq1K5Cb", + }, + }, + amount: { + monthly: { usd: 16.99, eur: 16.99, gbp: 14.99 }, + annual: { usd: 159.99, eur: 159.99, gbp: 143.99 }, + }, + creditBudget: 600_000_000, + durationDays: 30, + trialDays: 0, + caps: { + save_words: null, + smart_review: null, + ai_credits: 600_000_000, // mirrors creditBudget + weekly_insights: null, + session_history: null, + live_conversations: null, + }, + featureLabels: [ + "Everything in Learner", + "Mini Lectures — short, focused lessons built from real content", + "Improved Live Sessions with richer feedback", + "A larger AI budget for longer daily practice", + ], + aiBudgetLabel: "larger monthly budget", + }, +}; + +/** Get a tier definition by id. */ +export function getTier(id: TierId): TierDefinition { + return TIERS[id]; +} + +/** Tiers currently visible/sellable as live — excludes "dark" tiers like Fluent. */ +export function liveTiers(): TierDefinition[] { + return Object.values(TIERS).filter((t) => t.status === "live"); +} + +/** + * Resolve a Stripe price ID back to its tier + cadence + currency. + * Used by the Stripe webhook to derive entitlements from an incoming subscription. + */ +export function resolveTierByPriceId( + priceId: string +): { tier: TierDefinition; cadence: Cadence; currency: Currency } | null { + const cadences: Cadence[] = ["monthly", "annual"]; + for (const tier of Object.values(TIERS)) { + if (!tier.prices) continue; + for (const cadence of cadences) { + const byCurrency = tier.prices[cadence]; + for (const currency of Object.keys(byCurrency) as Currency[]) { + if (byCurrency[currency] === priceId) { + return { tier, cadence, currency }; + } + } + } + } + return null; +} + +/** Resolve a Stripe product ID back to its tier. */ +export function resolveTierByProductId( + productId: string +): TierDefinition | null { + return ( + Object.values(TIERS).find((t) => t.stripeProductId === productId) || null + ); +} + +/** Whether a tier grants access to a feature at all (cap !== 0). */ +export function tierAllowsFeature(id: TierId, feature: FeatureKey): boolean { + return TIERS[id].caps[feature] !== 0; +} + +/** The per-tier cap for a feature: null = unlimited, 0 = locked, n = hard cap. */ +export function featureCap(id: TierId, feature: FeatureKey): number | null { + return TIERS[id].caps[feature]; +} diff --git a/server/src/modules/subscription/types.ts b/server/src/modules/subscription/types.ts index 706bc40..e17362b 100644 --- a/server/src/modules/subscription/types.ts +++ b/server/src/modules/subscription/types.ts @@ -1,20 +1,15 @@ import { Types } from "mongoose"; import { PaymentProvider } from "../gateway/adapters"; - -export interface SubscriptionPlan { - name: string; - product_id: string; - price: string; - currency: string; - description?: string; - features: string[]; - is_freemium: boolean; -} +import { TierId } from "./tiers"; export interface Subscription { _id?: Types.ObjectId; user_id: Types.ObjectId; - subscription_type: "monthly" | "quarterly" | "annual"; + subscription_type?: "monthly" | "annual"; + tier?: TierId; + price_id?: string; + trial_end?: Date; + cancel_at_period_end?: boolean; start_date: Date; end_date: Date; total_credits: number; @@ -22,6 +17,7 @@ export interface Subscription { status: | "active" | "canceled" + | "expired" | "incomplete" | "incomplete_expired" | "past_due" @@ -54,7 +50,9 @@ export interface FreeCredit { allowed_save_words_used: number; allowed_lived_sessions: number; allowed_lived_sessions_used: number; + ai_exhausted_flagged?: boolean; available_credit?: number; + usage_percentage?: number; createdAt?: Date; updatedAt?: Date; } @@ -73,7 +71,7 @@ export interface Usage { } export interface PaymentDetails { - subscriptionType: "monthly" | "quarterly" | "annual"; + subscriptionType: "monthly" | "annual"; paymentMethod?: string; transactionId?: string; currency?: string; diff --git a/server/src/utils/analytics.ts b/server/src/utils/analytics.ts new file mode 100644 index 0000000..78f4b5c --- /dev/null +++ b/server/src/utils/analytics.ts @@ -0,0 +1,40 @@ +/** + * Server-side Mixpanel — fire-and-forget analytics for events that must be + * server-truth (trial conversion, AI-budget exhaustion). No-ops when + * MIXPANEL_TOKEN is not configured, so it never blocks a webhook or RPC. + */ +const mixpanel = require("mixpanel"); + +let client: any = null; +const token = process.env.MIXPANEL_TOKEN; +if (token) { + client = mixpanel.init(token); +} else { + console.warn( + "[analytics] MIXPANEL_TOKEN not set — server-side analytics events are disabled" + ); +} + +// Mirrors frontend/constants/analyticsEvents.ts for the server-fired events. +export const SERVER_ANALYTICS_EVENTS = { + TRIAL_CONVERTED: "trial-converted", + TRIAL_CANCELED: "trial-canceled", + STARTER_AI_EXHAUSTED: "starter-ai-exhausted", +}; + +/** + * Track a server-truth event. Fire-and-forget: errors are swallowed so a + * failed analytics call can never break the calling webhook or RPC. + */ +export function trackServerEvent( + event: string, + userId: string, + properties: Record = {} +): void { + if (!client) return; + try { + client.track(event, { distinct_id: userId, ...properties }); + } catch (err) { + console.error("[analytics] failed to track event", event, err); + } +} diff --git a/server/yarn.lock b/server/yarn.lock index 3703ae2..bc68cfa 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -56,7 +56,7 @@ resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz" integrity sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw== -"@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.0", "@babel/core@^7.23.9", "@babel/core@^7.27.4", "@babel/core@>=7.0.0-beta.0 <8": +"@babel/core@^7.23.9", "@babel/core@^7.27.4": version "7.28.0" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz" integrity sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ== @@ -350,6 +350,28 @@ resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz" integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw== +"@emnapi/core@^1.4.3": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467" + integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c" + integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + "@exodus/bytes@^1.6.0": version "1.9.0" resolved "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.9.0.tgz" @@ -609,7 +631,7 @@ jest-haste-map "30.0.5" slash "^3.0.0" -"@jest/transform@^29.0.0 || ^30.0.0", "@jest/transform@30.0.5": +"@jest/transform@30.0.5": version "30.0.5" resolved "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz" integrity sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg== @@ -630,7 +652,7 @@ slash "^3.0.0" write-file-atomic "^5.0.1" -"@jest/types@^29.0.0 || ^30.0.0", "@jest/types@30.0.5": +"@jest/types@30.0.5": version "30.0.5" resolved "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz" integrity sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ== @@ -661,39 +683,15 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== -"@jridgewell/trace-mapping@^0.3.12": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@jridgewell/trace-mapping@^0.3.23": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@jridgewell/trace-mapping@^0.3.24": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@jridgewell/trace-mapping@^0.3.25": - version "0.3.29" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" - integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.28": +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.29" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz" integrity sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ== @@ -701,14 +699,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@js-sdsl/ordered-map@^4.4.2": version "4.4.2" resolved "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz" @@ -751,16 +741,20 @@ dependencies: sparse-bitfield "^3.0.3" +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + "@noble/hashes@^1.1.5": version "1.7.2" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.7.2.tgz" integrity sha512-biZ0NUSxyjLLqo6KxEJ1b+C2NAx0wtDoFvCaXHGgUkeHzf3Xc1xKumFKREuT7f7DARNZ/slvYUwFG6B0f2b6hQ== -"@noble/hashes@^1.8.0 || ^2.0.0": - version "2.0.1" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz" - integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== - "@paralleldrive/cuid2@^2.2.2": version "2.2.2" resolved "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz" @@ -875,6 +869,13 @@ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== +"@tybys/wasm-util@^0.10.0": + version "0.10.2" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e" + integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg== + dependencies: + tslib "^2.4.0" + "@types/accepts@*": version "1.3.7" resolved "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz" @@ -1198,11 +1199,103 @@ resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz" integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== + +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== + "@unrs/resolver-binding-darwin-arm64@1.11.1": version "1.11.1" resolved "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz" integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== +"@unrs/resolver-binding-darwin-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== + +"@unrs/resolver-binding-freebsd-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== + +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== + +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== + +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== + +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== + +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + +"@unrs/resolver-binding-wasm32-wasi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== + +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -1230,6 +1323,13 @@ acorn@^8.11.0, acorn@^8.4.1: resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +agent-base@6: + version "6.0.2" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + agent-base@^7.1.0: version "7.1.4" resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" @@ -1240,13 +1340,6 @@ agent-base@^7.1.2: resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz" integrity sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw== -agent-base@6: - version "6.0.2" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" @@ -1333,7 +1426,7 @@ b4a@^1.6.4: resolved "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz" integrity sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg== -"babel-jest@^29.0.0 || ^30.0.0", babel-jest@30.0.5: +babel-jest@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz" integrity sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg== @@ -1457,7 +1550,7 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0, "browserslist@>= 4.21.0": +browserslist@^4.24.0: version "4.25.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz" integrity sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw== @@ -1734,26 +1827,26 @@ date-and-time@^3.6.0: resolved "https://registry.npmjs.org/date-and-time/-/date-and-time-3.6.0.tgz" integrity sha512-V99gLaMqNQxPCObBumb31Bfy3OByXnpqUM0yHPi/aBQE61g42A2rGk6Z2CDnpLrWsOFLQwOgl4Vgshw6D44ebw== -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== +debug@3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== dependencies: - ms "^2.1.1" + ms "2.0.0" -debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1, debug@4: +debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.1: version "4.4.1" resolved "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz" integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" -debug@3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz" - integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== +debug@^3.1.0: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: - ms "2.0.0" + ms "^2.1.1" decimal.js-light@^2.5.1: version "2.5.1" @@ -1795,7 +1888,7 @@ denque@^1.4.1: resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz" integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== -depd@^2.0.0, depd@~2.0.0, depd@2.0.0: +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -1857,7 +1950,7 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -1992,7 +2085,7 @@ exit-x@^0.2.2: resolved "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz" integrity sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ== -expect@^30.0.0, expect@30.0.5: +expect@30.0.5, expect@^30.0.0: version "30.0.5" resolved "https://registry.npmjs.org/expect/-/expect-30.0.5.tgz" integrity sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ== @@ -2014,7 +2107,7 @@ fast-fifo@^1.2.0, fast-fifo@^1.3.2: resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== -fast-json-stable-stringify@^2.1.0, fast-json-stable-stringify@2.x: +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -2141,16 +2234,6 @@ function-bind@^1.1.2: resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -gaxios@^5.0.0: - version "5.1.3" - resolved "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz" - integrity sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA== - dependencies: - extend "^3.0.2" - https-proxy-agent "^5.0.0" - is-stream "^2.0.0" - node-fetch "^2.6.9" - gaxios@^6.0.0, gaxios@^6.0.3, gaxios@^6.1.1: version "6.7.1" resolved "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz" @@ -2162,30 +2245,31 @@ gaxios@^6.0.0, gaxios@^6.0.3, gaxios@^6.1.1: node-fetch "^2.6.9" uuid "^9.0.1" -gaxios@^7.0.0-rc.1, gaxios@^7.0.0-rc.4: - version "7.0.0-rc.4" - resolved "https://registry.npmjs.org/gaxios/-/gaxios-7.0.0-rc.4.tgz" - integrity sha512-fwQMwbs3o8Odl/nc/rkQJwyHeOXdderOwmybUl0gkyTdZXMK1oSTWj4Em7gSogVJsRWDeHPXLY06+e8Rkr01iw== +gaxios@^7.0.0, gaxios@^7.1.4: + version "7.1.4" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz" + integrity sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA== dependencies: extend "^3.0.2" https-proxy-agent "^7.0.1" node-fetch "^3.3.2" -gaxios@^7.0.0, gaxios@^7.1.4: - version "7.1.4" - resolved "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz" - integrity sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA== +gaxios@^7.0.0-rc.1, gaxios@^7.0.0-rc.4: + version "7.0.0-rc.4" + resolved "https://registry.npmjs.org/gaxios/-/gaxios-7.0.0-rc.4.tgz" + integrity sha512-fwQMwbs3o8Odl/nc/rkQJwyHeOXdderOwmybUl0gkyTdZXMK1oSTWj4Em7gSogVJsRWDeHPXLY06+e8Rkr01iw== dependencies: extend "^3.0.2" https-proxy-agent "^7.0.1" node-fetch "^3.3.2" -gcp-metadata@^5.2.0: - version "5.3.0" - resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz" - integrity sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w== +gcp-metadata@8.1.2: + version "8.1.2" + resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz" + integrity sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== dependencies: - gaxios "^5.0.0" + gaxios "^7.0.0" + google-logging-utils "^1.0.0" json-bigint "^1.0.0" gcp-metadata@^6.1.0: @@ -2206,15 +2290,6 @@ gcp-metadata@^7.0.0-rc.1: google-logging-utils "^1.0.0" json-bigint "^1.0.0" -gcp-metadata@8.1.2: - version "8.1.2" - resolved "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz" - integrity sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg== - dependencies: - gaxios "^7.0.0" - google-logging-utils "^1.0.0" - json-bigint "^1.0.0" - gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" @@ -2338,16 +2413,16 @@ google-gax@^5.0.1-rc.0: protobufjs "^7.5.0" retry-request "^8.0.0" +google-logging-utils@1.1.3, google-logging-utils@^1.0.0, google-logging-utils@^1.1.1: + version "1.1.3" + resolved "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz" + integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== + google-logging-utils@^0.0.2: version "0.0.2" resolved "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz" integrity sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ== -google-logging-utils@^1.0.0, google-logging-utils@^1.1.1, google-logging-utils@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz" - integrity sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA== - googleapis-common@^7.0.0: version "7.2.0" resolved "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz" @@ -2438,6 +2513,17 @@ http-assert@^1.3.0: deep-equal "~1.0.1" http-errors "~1.8.0" +http-errors@2.0.0, http-errors@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + http-errors@^1.3.1, http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: version "1.8.1" resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz" @@ -2449,17 +2535,6 @@ http-errors@^1.3.1, http-errors@^1.6.3, http-errors@^1.7.3, http-errors@~1.8.0: statuses ">= 1.5.0 < 2" toidentifier "1.0.1" -http-errors@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - http-errors@~1.6.2: version "1.6.3" resolved "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz" @@ -2470,17 +2545,6 @@ http-errors@~1.6.2: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - http-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz" @@ -2498,6 +2562,14 @@ http-proxy-agent@^7.0.2: agent-base "^7.1.0" debug "^4.3.4" +https-proxy-agent@7.0.6, https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.6: + version "7.0.6" + resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== + dependencies: + agent-base "^7.1.2" + debug "4" + https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz" @@ -2506,14 +2578,6 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" -https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.6: - version "7.0.6" - resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz" - integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== - dependencies: - agent-base "^7.1.2" - debug "4" - human-signals@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" @@ -2552,7 +2616,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@~2.0.3, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2612,16 +2676,16 @@ is-stream@^2.0.0: resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - isarray@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz" integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" @@ -2888,7 +2952,7 @@ jest-resolve-dependencies@30.0.5: jest-regex-util "30.0.1" jest-snapshot "30.0.5" -jest-resolve@*, jest-resolve@30.0.5: +jest-resolve@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.5.tgz" integrity sha512-d+DjBQ1tIhdz91B79mywH5yYu76bZuE96sSbxj8MkjWVx5WNdt1deEFRONVL4UkKLSrAbMkdhb24XN691yDRHg== @@ -2985,7 +3049,7 @@ jest-snapshot@30.0.5: semver "^7.7.2" synckit "^0.11.8" -"jest-util@^29.0.0 || ^30.0.0", jest-util@30.0.5: +jest-util@30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz" integrity sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g== @@ -3034,7 +3098,7 @@ jest-worker@30.0.5: merge-stream "^2.0.0" supports-color "^8.1.1" -"jest@^29.0.0 || ^30.0.0", jest@^30.0.5: +jest@^30.0.5: version "30.0.5" resolved "https://registry.npmjs.org/jest/-/jest-30.0.5.tgz" integrity sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ== @@ -3095,6 +3159,11 @@ json-bigint@^1.0.0: dependencies: bignumber.js "^9.0.0" +json-logic-js@2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/json-logic-js/-/json-logic-js-2.0.5.tgz#55f0c687dd6f56b02ccdcfdd64171ed998ab5499" + integrity sha512-rTT2+lqcuUmj4DgWfmzupZqQDA64AdmYqizzMPWj3DxGdfFNsxPpcNVSaTj4l8W2tG/+hg7/mQhxjU3aPacO6g== + json-parse-even-better-errors@^2.3.0: version "2.3.1" resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" @@ -3454,14 +3523,7 @@ mimic-fn@^2.1.0: resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^3.0.4, minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimatch@^3.1.2: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -3487,6 +3549,14 @@ minimatch@^9.0.4: resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +mixpanel@^0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/mixpanel/-/mixpanel-0.22.0.tgz#52f8c7ca548e2f0ffb7f9ee514c9dac4894c07f6" + integrity sha512-KH3K9hFwuGxMLhYlSbMXCWXAZE88Sao+K+4TqlteE+gQ0xEXbCfOWweSOwG+Hy2NsZRAic+/kMEUc+JQJv7OzA== + dependencies: + https-proxy-agent "7.0.6" + json-logic-js "2.0.5" + mongodb-connection-string-url@^3.0.0: version "3.0.2" resolved "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz" @@ -3521,15 +3591,6 @@ mongodb-memory-server@^10.2.0: mongodb-memory-server-core "10.2.0" tslib "^2.8.1" -mongodb@^6.9.0: - version "6.18.0" - resolved "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz" - integrity sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ== - dependencies: - "@mongodb-js/saslprep" "^1.1.9" - bson "^6.10.4" - mongodb-connection-string-url "^3.0.0" - mongodb@3.7.4: version "3.7.4" resolved "https://registry.npmjs.org/mongodb/-/mongodb-3.7.4.tgz" @@ -3543,12 +3604,21 @@ mongodb@3.7.4: optionalDependencies: saslprep "^1.0.0" +mongodb@^6.9.0: + version "6.18.0" + resolved "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz" + integrity sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ== + dependencies: + "@mongodb-js/saslprep" "^1.1.9" + bson "^6.10.4" + mongodb-connection-string-url "^3.0.0" + mongoose-legacy-pluralize@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/mongoose-legacy-pluralize/-/mongoose-legacy-pluralize-1.0.2.tgz" integrity sha512-Yo/7qQU4/EyIS8YDFSeenIvXxZN+ld7YdV9LqFVQJzTLye8unujAWPZ4NWKfFA+RNjh+wvTWKY9Z3E5XM6ZZiQ== -mongoose@*, mongoose@^5.10.9: +mongoose@^5.10.9: version "5.13.23" resolved "https://registry.npmjs.org/mongoose/-/mongoose-5.13.23.tgz" integrity sha512-Q5bo1yYOcH2wbBPP4tGmcY5VKsFkQcjUDh66YjrbneAFB3vNKQwLvteRFLuLiU17rA5SDl3UMcMJLD9VS8ng2Q== @@ -3584,11 +3654,6 @@ mquery@3.2.5: safe-buffer "5.1.2" sliced "1.0.1" -ms@^2.1.1, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -3599,6 +3664,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + napi-postinstall@^0.3.0: version "0.3.2" resolved "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.2.tgz" @@ -3631,7 +3701,7 @@ node-domexception@^1.0.0: resolved "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz" integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== -node-fetch@^2.6.9, node-fetch@2: +node-fetch@2, node-fetch@^2.6.9: version "2.7.0" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -3714,6 +3784,11 @@ only@~0.0.2: resolved "https://registry.npmjs.org/only/-/only-0.0.2.tgz" integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== +optional-require@1.0.x: + version "1.0.3" + resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz" + integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA== + optional-require@^1.1.8: version "1.1.8" resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.1.8.tgz" @@ -3721,11 +3796,6 @@ optional-require@^1.1.8: dependencies: require-at "^1.0.6" -optional-require@1.0.x: - version "1.0.3" - resolved "https://registry.npmjs.org/optional-require/-/optional-require-1.0.3.tgz" - integrity sha512-RV2Zp2MY2aeYK5G+B/Sps8lW5NHAzE5QClbFP15j+PWmP+T9PxlJXBOOLoSAdgwFvS4t0aMR4vpedMkbHfh0nA== - p-limit@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" @@ -3792,7 +3862,7 @@ path-exists@^4.0.0: resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-is-absolute@^1.0.0, path-is-absolute@1.0.1: +path-is-absolute@1.0.1, path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz" integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== @@ -3854,7 +3924,7 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pretty-format@^30.0.0, pretty-format@30.0.5: +pretty-format@30.0.5, pretty-format@^30.0.0: version "30.0.5" resolved "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz" integrity sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw== @@ -3947,7 +4017,7 @@ readable-stream@^3.1.1: string_decoder "^1.1.1" util-deprecate "^1.0.1" -regexp-clone@^1.0.0, regexp-clone@1.0.0: +regexp-clone@1.0.0, regexp-clone@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/regexp-clone/-/regexp-clone-1.0.0.tgz" integrity sha512-TuAasHQNamyyJ2hb97IuBEif4qBHGjPHBS64sZwytpLEqtBQ1gPJTnOaQ6qmpET16cK14kkjbazl6+p0RRv0yw== @@ -4001,25 +4071,15 @@ retry@^0.13.1: resolved "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz" integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== -safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: - version "5.2.1" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -safe-buffer@~5.1.0: - version "5.1.2" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-buffer@~5.1.1: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== safe-regex-test@^1.1.0: version "1.1.0" @@ -4054,27 +4114,12 @@ semver@^5.6.0: resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== -semver@^6.0.0: +semver@^6.0.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^6.3.1: - version "6.3.1" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.5.3: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - -semver@^7.5.4: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - -semver@^7.7.2: +semver@^7.5.3, semver@^7.5.4, semver@^7.7.2: version "7.7.2" resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== @@ -4208,16 +4253,16 @@ stack-utils@^2.0.6: dependencies: escape-string-regexp "^2.0.0" -statuses@^1.5.0, "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2": - version "1.5.0" - resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== - statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@^1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + stream-events@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz" @@ -4240,20 +4285,6 @@ streamx@^2.15.0: optionalDependencies: bare-events "^2.2.0" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - string-length@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz" @@ -4262,16 +4293,7 @@ string-length@^4.0.2: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4289,14 +4311,21 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== dependencies: - ansi-regex "^5.0.1" + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -4469,7 +4498,7 @@ ts-jest@^29.4.0: type-fest "^4.41.0" yargs-parser "^21.1.1" -ts-node@^10.9.2, ts-node@>=9.0.0: +ts-node@^10.9.2: version "10.9.2" resolved "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz" integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== @@ -4528,7 +4557,7 @@ type-is@^1.6.16: media-typer "0.3.0" mime-types "~2.1.24" -typescript@^5.6.3, typescript@>=2.7, "typescript@>=4.3 <6": +typescript@^5.6.3: version "5.8.2" resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz" integrity sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ== @@ -4702,16 +4731,7 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -4813,7 +4833,7 @@ zod-to-json-schema@^3.24.5: resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz" integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g== -zod@^3.19.1, zod@^3.24.1, zod@^3.24.3: +zod@^3.19.1, zod@^3.24.3: version "3.24.3" resolved "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz" integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg== From 0430e26a86165a86c54b902219c1e610df55a309 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 14 May 2026 21:12:32 +0300 Subject: [PATCH 2/6] feat: rebuild subscription page for 3-tier pricing and cap-moment upgrades #86exkhcza Rebuild /settings/subscription as a 3-card pricing page (Starter/Learner/ Fluent) with a monthly/annual toggle and currency selector. Adds the soft-cap usage banner, cap-moment upgrade modals, the cancel-trial off-ramp, shared analytics event constants, and retires the "Pro/Premium" naming. Deletes the hardcoded billing.vue mock. Also fixes live-session Start-button spacing and the "Manage Subscription" button rendering as disabled in pilotui link mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../bundle/StartLiveSessionFormModal.vue | 38 +- .../freemium_alerts/LimitationModal.vue | 4 +- .../freemium_alerts/UsageCapBanner.vue | 42 ++ .../liveSession/gemini/StartNew.vue | 4 +- .../liveSession/openai/StartNew.vue | 4 +- frontend/components/profile/Sidebar.vue | 11 - .../composables/useDashboardNavigatorItems.ts | 5 - frontend/constants/analyticsEvents.ts | 12 + frontend/layouts/default.vue | 2 + frontend/locales/en.json | 60 ++- frontend/pages/bundles/[id].vue | 37 +- frontend/pages/practice/live-session-[id].vue | 27 +- .../practice/live-session-openai/[id].vue | 27 +- frontend/pages/settings/billing.vue | 156 ------- frontend/pages/settings/subscription.vue | 411 +++++++++++------- frontend/stores/profile.ts | 11 + frontend/types/tiers.ts | 18 + 17 files changed, 481 insertions(+), 388 deletions(-) create mode 100644 frontend/components/freemium_alerts/UsageCapBanner.vue create mode 100644 frontend/constants/analyticsEvents.ts delete mode 100644 frontend/pages/settings/billing.vue create mode 100644 frontend/types/tiers.ts diff --git a/frontend/components/bundle/StartLiveSessionFormModal.vue b/frontend/components/bundle/StartLiveSessionFormModal.vue index e73efaa..aab12f4 100644 --- a/frontend/components/bundle/StartLiveSessionFormModal.vue +++ b/frontend/components/bundle/StartLiveSessionFormModal.vue @@ -9,28 +9,24 @@ diff --git a/frontend/components/freemium_alerts/LimitationModal.vue b/frontend/components/freemium_alerts/LimitationModal.vue index 7139cc6..52eb584 100644 --- a/frontend/components/freemium_alerts/LimitationModal.vue +++ b/frontend/components/freemium_alerts/LimitationModal.vue @@ -73,9 +73,9 @@ const props = withDefaults( // Default values - plain strings, translations handled by parent modalTitle: 'Upgrade Required', mainMessage: 'No free spots left', - subMessage: 'Upgrade to Pro to access unlimited features.', + subMessage: 'Upgrade to Learner to access unlimited features.', iconName: 'IconLock', - primaryButtonLabel: 'Go Pro!', + primaryButtonLabel: 'Upgrade to Learner', primaryButtonIcon: 'IconCrown', secondaryButtonLabel: 'Continue with limits', showSecondaryButton: true, diff --git a/frontend/components/freemium_alerts/UsageCapBanner.vue b/frontend/components/freemium_alerts/UsageCapBanner.vue new file mode 100644 index 0000000..d8f3868 --- /dev/null +++ b/frontend/components/freemium_alerts/UsageCapBanner.vue @@ -0,0 +1,42 @@ + + + diff --git a/frontend/components/liveSession/gemini/StartNew.vue b/frontend/components/liveSession/gemini/StartNew.vue index c9a26b4..46e6902 100644 --- a/frontend/components/liveSession/gemini/StartNew.vue +++ b/frontend/components/liveSession/gemini/StartNew.vue @@ -13,12 +13,12 @@ - + -
+
- + -
+
- - - -
diff --git a/frontend/composables/useDashboardNavigatorItems.ts b/frontend/composables/useDashboardNavigatorItems.ts index 33d059e..7f2ee62 100644 --- a/frontend/composables/useDashboardNavigatorItems.ts +++ b/frontend/composables/useDashboardNavigatorItems.ts @@ -62,11 +62,6 @@ export const useDashboardNavigatorItems = (): Array => { icon: 'IconCreditCard', to: '/settings/subscription', }, - // { - // title: t('billing.billing'), - // icon: 'IconClipboardText', - // to: '/settings/billing', - // }, ], }, ] as Array; diff --git a/frontend/constants/analyticsEvents.ts b/frontend/constants/analyticsEvents.ts new file mode 100644 index 0000000..77cf566 --- /dev/null +++ b/frontend/constants/analyticsEvents.ts @@ -0,0 +1,12 @@ +// Shared registry of Mixpanel event names — keeps event names consistent and +// greppable across the app. Fired via `analytic.track(...)` (see ~/plugins/mixpanel). +// The server fires its own copies of the server-truth events via +// server/src/utils/analytics.ts. +export const ANALYTICS_EVENTS = { + CAP_HIT: 'cap-hit', // props: { cap: 'save_words' | 'ai_taste' } + TRIAL_STARTED: 'trial-started', // props: { cadence, currency } + TRIAL_CONVERTED: 'trial-converted', // server-fired + TRIAL_CANCELED: 'trial-canceled', // server-fired + STARTER_AI_EXHAUSTED: 'starter-ai-exhausted', // server-fired + FLUENT_WAITLIST_SIGNUP: 'fluent-waitlist-signup', +} as const; diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 667b432..ce602a7 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -20,6 +20,7 @@ @@ -33,6 +34,7 @@ diff --git a/frontend/pages/settings/subscription.vue b/frontend/pages/settings/subscription.vue index 5652eff..6523ce8 100644 --- a/frontend/pages/settings/subscription.vue +++ b/frontend/pages/settings/subscription.vue @@ -13,101 +13,77 @@
- -

- {{ activeSubscriptionData.label }} -

- -
-
-
-

{{ t('subscription.started-at') }} {{ new - Date(activeSubscriptionData.start_date).toLocaleDateString() }}

- -
-
-
+
+
- {{ t('billing.days-left') }}: {{ activeSubscriptionData.remaining_days }} + {{ t('billing.days-left') }}: {{ activeSubscriptionData.remaining_days ?? 0 }}
-
- + -