diff --git a/.gitignore b/.gitignore index 1fd0c02..486af9a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ # Agent E2E run reports (the specs are tracked; the generated run logs are not) agent-tests/_runs/ +.playwright-mcp \ No newline at end of file diff --git a/frontend/components/FeatureLocked.vue b/frontend/components/FeatureLocked.vue index bd7540c..989ea88 100644 --- a/frontend/components/FeatureLocked.vue +++ b/frontend/components/FeatureLocked.vue @@ -63,9 +63,9 @@ // Primary CTA pre-selects the required tier on the pricing page; secondary lists all. function goUpgrade() { - router.push(`/settings/subscription?suggest=${props.requiredTier}`); + router.push(`/settings/subscription?suggest=${props.requiredTier}&from=cap-hit-banner`); } function goToPlans() { - router.push('/settings/subscription'); + router.push('/settings/subscription?from=cap-hit-banner'); } diff --git a/frontend/components/TextChatCounter.vue b/frontend/components/TextChatCounter.vue index 81f929f..8cc1274 100644 --- a/frontend/components/TextChatCounter.vue +++ b/frontend/components/TextChatCounter.vue @@ -41,6 +41,6 @@ // At the cap, point the user at Learner's unlimited text chat. function findOutMore() { - router.push('/settings/subscription?suggest=learner'); + router.push('/settings/subscription?suggest=learner&from=cap-hit-banner'); } diff --git a/frontend/components/VoiceCapBanner.vue b/frontend/components/VoiceCapBanner.vue index dfffbd9..e865c21 100644 --- a/frontend/components/VoiceCapBanner.vue +++ b/frontend/components/VoiceCapBanner.vue @@ -55,6 +55,6 @@ // Learner's "Upgrade plan" highlights Coach; Coach has no tier above (button hidden). function goUpgrade() { - router.push('/settings/subscription?from=voice-banner&suggest=coach'); + router.push('/settings/subscription?from=cap-hit-banner&suggest=coach'); } diff --git a/frontend/components/freemium_alerts/LimitationModal.vue b/frontend/components/freemium_alerts/LimitationModal.vue index 52eb584..b40586d 100644 --- a/frontend/components/freemium_alerts/LimitationModal.vue +++ b/frontend/components/freemium_alerts/LimitationModal.vue @@ -119,7 +119,7 @@ function handlePrimaryAction(toggleModal?: (value: boolean) => void) { // Default behavior: redirect to subscription page (can be overridden by parent) if (props.autoRedirectOnUpgrade) { - router.push('/settings/subscription'); + router.push('/settings/subscription?from=cap-hit-banner'); analytic.track('upgrade-cta_clicked'); } } diff --git a/frontend/components/freemium_alerts/UsageCapBanner.vue b/frontend/components/freemium_alerts/UsageCapBanner.vue index d8f3868..63d0a49 100644 --- a/frontend/components/freemium_alerts/UsageCapBanner.vue +++ b/frontend/components/freemium_alerts/UsageCapBanner.vue @@ -37,6 +37,6 @@ const state = computed<'hidden' | 'warning' | 'paused'>(() => { }); function goToPlans() { - router.push('/settings/subscription'); + router.push('/settings/subscription?from=cap-hit-banner'); } diff --git a/frontend/constants/analyticsEvents.ts b/frontend/constants/analyticsEvents.ts index 790f931..02a1445 100644 --- a/frontend/constants/analyticsEvents.ts +++ b/frontend/constants/analyticsEvents.ts @@ -5,8 +5,11 @@ export const ANALYTICS_EVENTS = { CAP_HIT: 'cap_hit', // props: { cap: 'save_words' | 'ai_taste' } CHECKOUT_OPENED: 'checkout_opened', // props: { tier, cadence, currency } — checkout panel opened + PRICING_PAGE_VIEWED: 'pricing-page_viewed', // props: { from_surface } — subscription page opened FLASHCARD_REVIEW_STARTED: 'flashcard_review_started', // props: { deck_type } USER_LOGGED_IN: 'user_logged-in', // explicit login only, not session restore + ACCOUNT_CREATED: 'account_created', // server-fired (auth: first OAuth exchange for a new account) + PHRASE_SAVED_FIRST_TIME: 'phrase_saved_first-time', // server-fired (first-ever phrase save) TRIAL_STARTED: 'trial_started', // server-fired (Stripe webhook: subscription created as trialing) SUBSCRIPTION_STARTED: 'subscription_started', // server-fired (trial converted, or direct paid start) SUBSCRIPTION_CANCELED: 'subscription_canceled', // server-fired, props: { was_trialing } diff --git a/frontend/pages/settings/subscription.vue b/frontend/pages/settings/subscription.vue index 8d72377..c46a1b9 100644 --- a/frontend/pages/settings/subscription.vue +++ b/frontend/pages/settings/subscription.vue @@ -505,6 +505,18 @@ async function probeLocalCurrency() { } onMounted(async () => { + // Trial-to-paid funnel entry. The auto page-view can't carry attribution, so + // fire an explicit event with the surface the user arrived from (?from=...); + // default to 'settings' for direct/nav visits. Analytics is best-effort: when + // Mixpanel is not initialised (e.g. no token in CI/e2e), track() can throw — + // it must never abort the mount and block the plan cards from loading. + try { + analytic.track(ANALYTICS_EVENTS.PRICING_PAGE_VIEWED, { + from_surface: (route.query.from as string) || 'settings', + }); + } catch (e) { + console.error('Failed to track pricing-page_viewed:', e); + } await fetchPlans(); probeLocalCurrency(); // Returning from a portal plan change (?plan_changed=1): the diff --git a/server/src/modules/auth/router.ts b/server/src/modules/auth/router.ts index 3de1064..a8c36ae 100644 --- a/server/src/modules/auth/router.ts +++ b/server/src/modules/auth/router.ts @@ -3,6 +3,7 @@ import { reply, userManager } from "@modular-rest/server"; import { google } from "googleapis"; import { updateUserProfile } from "../profile/service"; import { LeitnerService } from "../leitner_box/service"; +import { trackServerEvent, SERVER_ANALYTICS_EVENTS } from "../../utils/analytics"; const name = "auth"; const auth = new Router(); @@ -122,6 +123,13 @@ auth.get("/google/code-login", async (ctx) => { if (!registeredUser) { try { userId = await userManager.registerUser({ email }); + // Activation funnel entry: first successful OAuth exchange for a brand-new + // account. Server-truth so it fires once, on the genuine new-user branch. + trackServerEvent(SERVER_ANALYTICS_EVENTS.ACCOUNT_CREATED, userId as string, { + signup_provider: "google", + oauth_timezone: timeZone || undefined, + referrer: redirectUrl || undefined, + }); } catch (error) { ctx.throw( 500, @@ -187,7 +195,14 @@ auth.get("/google/access-token-login", async (ctx) => { if (!registeredUser) { try { - await userManager.registerUser({ email: googleEmail }); + const newUserId = await userManager.registerUser({ email: googleEmail }); + // Activation funnel entry for accounts created via the extension's Google + // token. No timezone/referrer on this path, so only the provider is known. + trackServerEvent( + SERVER_ANALYTICS_EVENTS.ACCOUNT_CREATED, + newUserId as string, + { signup_provider: "google" } + ); } catch (error) { ctx.throw( 500, diff --git a/server/src/modules/gateway/__tests__/stripe.adapter.test.ts b/server/src/modules/gateway/__tests__/stripe.adapter.test.ts index 0aa660a..8670075 100644 --- a/server/src/modules/gateway/__tests__/stripe.adapter.test.ts +++ b/server/src/modules/gateway/__tests__/stripe.adapter.test.ts @@ -70,6 +70,7 @@ function createdEvent() { customer: "cus_1", id: "sub_1", status: "trialing", + currency: "gbp", trial_end: 1234, items: { data: [ @@ -92,6 +93,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { jest.spyOn(console, "error").mockImplementation(() => {}); (getCollection as any).mockReturnValue({ findOne: jest.fn(async () => ({ user_id: "u1" })), // stripe_customer -> userId + find: jest.fn(async () => []), // payment records for lifetime-value (default: none) }); adapter = new StripeAdapter("sk_test_dummy"); }); @@ -131,6 +133,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith("trial_started", "u1", { cadence: "monthly", tier: "learner", + currency: "gbp", }); }); @@ -144,7 +147,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith( "subscription_canceled", "u1", - { was_trialing: false } + { was_trialing: false, lifetime_value_cents: 0 } ); }); @@ -164,6 +167,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { customer: "cus_1", id: "sub_1", status: "active", + currency: "gbp", cancel_at_period_end: false, trial_end: null, items: { @@ -184,7 +188,13 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith( "subscription_started", "u1", - { cadence: "monthly", tier: "learner", via_trial: true } + { + cadence: "monthly", + tier: "learner", + currency: "gbp", + subscription_id: "sub_1", + via_trial: true, + } ); }); @@ -238,6 +248,8 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith("subscription_started", "u1", { cadence: "monthly", tier: "learner", + currency: "gbp", + subscription_id: "sub_1", via_trial: false, }); }); @@ -277,6 +289,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { customer: "cus_1", id: "sub_1", status: "active", + currency: "gbp", cancel_at_period_end: false, trial_end: null, items: { @@ -296,6 +309,8 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { expect(trackServerEvent).toHaveBeenCalledWith("subscription_started", "u1", { cadence: "monthly", tier: "learner", + currency: "gbp", + subscription_id: "sub_1", via_trial: false, }); }); @@ -313,6 +328,39 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => { await adapter.handleWebhook(event); expect(trackServerEvent).toHaveBeenCalledWith("subscription_canceled", "u1", { was_trialing: true, + lifetime_value_cents: 0, + }); + }); + + it("enriches subscription_canceled with tier, lifetime value (cents), and reason", async () => { + (cancelSubscriptionByProviderAndSubscriptionId as any).mockResolvedValueOnce({ + success: true, + message: "ok", + wasTrialing: false, + tier: "learner", + }); + // Two succeeded payments at 4.49 + 4.49 (major units) -> 898 cents. + (getCollection as any).mockReturnValue({ + findOne: jest.fn(async () => ({ user_id: "u1" })), + find: jest.fn(async () => [{ amount: 4.49 }, { amount: 4.49 }]), + }); + const event = { + type: "customer.subscription.deleted", + data: { + object: { + customer: "cus_1", + id: "sub_1", + status: "canceled", + cancellation_details: { reason: "cancellation_requested" }, + }, + }, + }; + await adapter.handleWebhook(event); + expect(trackServerEvent).toHaveBeenCalledWith("subscription_canceled", "u1", { + was_trialing: false, + tier: "learner", + lifetime_value_cents: 898, + cancel_reason: "cancellation_requested", }); }); diff --git a/server/src/modules/gateway/adapters/stripe.adapter.ts b/server/src/modules/gateway/adapters/stripe.adapter.ts index f474e4e..2cbb67c 100644 --- a/server/src/modules/gateway/adapters/stripe.adapter.ts +++ b/server/src/modules/gateway/adapters/stripe.adapter.ts @@ -518,12 +518,19 @@ export class StripeAdapter implements PaymentAdapter { trackServerEvent(SERVER_ANALYTICS_EVENTS.TRIAL_STARTED, userId, { cadence, tier: entitlements.tierId, + currency: subscription.currency, }); } else if (subscription.status === "active") { trackServerEvent( SERVER_ANALYTICS_EVENTS.SUBSCRIPTION_STARTED, userId, - { cadence, tier: entitlements.tierId, via_trial: false } + { + cadence, + tier: entitlements.tierId, + currency: subscription.currency, + subscription_id: subscription.id, + via_trial: false, + } ); } } @@ -538,7 +545,7 @@ export class StripeAdapter implements PaymentAdapter { const subscription = event.data.object as Stripe.Subscription; try { - const { success, message, wasTrialing } = + const { success, message, wasTrialing, tier } = await cancelSubscriptionByProviderAndSubscriptionId({ provider: this.provider, subscriptionId: subscription.id, @@ -554,10 +561,42 @@ export class StripeAdapter implements PaymentAdapter { subscription.customer as string ); if (userId && success) { + // Lifetime value = sum of the succeeded payments recorded for this + // subscription. Stored `amount` is in major units (see verifyPayment), + // so ×100 → integer cents. Best-effort: a lookup failure must not + // block the churn event. + let lifetimeValueCents = 0; + try { + const paymentCollection = getCollection( + DATABASE, + PAYMENT_COLLECTION + ); + const payments = (await paymentCollection.find({ + "provider_data.subscription_id": subscription.id, + status: "succeeded", + })) as any[]; + const total = (payments || []).reduce( + (sum, p) => sum + (Number(p.amount) || 0), + 0 + ); + lifetimeValueCents = Math.round(total * 100); + } catch (ltvErr) { + console.error( + "[analytics] failed to compute lifetime value for cancel", + ltvErr + ); + } + trackServerEvent( SERVER_ANALYTICS_EVENTS.SUBSCRIPTION_CANCELED, userId, - { was_trialing: !!wasTrialing } + { + was_trialing: !!wasTrialing, + tier, + lifetime_value_cents: lifetimeValueCents, + cancel_reason: + subscription.cancellation_details?.reason || undefined, + } ); } @@ -640,6 +679,8 @@ export class StripeAdapter implements PaymentAdapter { { cadence, tier: entitlements.tierId, + currency: subscription.currency, + subscription_id: subscription.id, via_trial: previousStatus === "trialing", } ); diff --git a/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts b/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts index 6a544fc..c3c3eb4 100644 --- a/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts +++ b/server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts @@ -34,6 +34,14 @@ jest.mock("../../subscription/enforcement", () => ({ jest.mock("../triggers", () => ({ phraseBundleTriggers: [] })); +jest.mock("../../../utils/analytics", () => ({ + trackServerEvent: jest.fn(), + SERVER_ANALYTICS_EVENTS: { + PHRASE_SAVED_FIRST_TIME: "phrase_saved_first-time", + }, +})); + +const { trackServerEvent } = require("../../../utils/analytics"); const { functions } = require("../functions"); const createPhrase = functions.find((f: any) => f.name === "createPhrase"); @@ -83,4 +91,43 @@ describe("createPhrase persistence", () => { expect(doc.chunks).toBeUndefined(); expect(doc.sourceUrl).toBeUndefined(); }); + + it("fires phrase_saved_first-time on the user's first-ever save", async () => { + // countDocuments is called first for bundle verification, then for the + // prior-phrase count. Both zero here -> genuine first save. + countDocuments.mockResolvedValueOnce(0).mockResolvedValueOnce(0); + + await createPhrase.callback({ + phrase: "we had to decide quickly", + translation: "...", + translation_language: "fa", + bundleIds: [], + refId: "user-1", + type: "linguistic", + language_info: { source: "en", target: "fa" }, + sourceUrl: "https://www.youtube.com/watch?v=abc123", + }); + + expect(trackServerEvent).toHaveBeenCalledWith( + "phrase_saved_first-time", + "user-1", + { source_platform: "youtube", language_pair: "en-fa" } + ); + }); + + it("does NOT fire phrase_saved_first-time when the user already has phrases", async () => { + // Bundle-verify count 0, then a non-zero prior-phrase count -> not the first save. + countDocuments.mockResolvedValueOnce(0).mockResolvedValueOnce(7); + + await createPhrase.callback({ + phrase: "hello", + translation: "...", + translation_language: "fa", + bundleIds: [], + refId: "user-1", + type: "normal", + }); + + expect(trackServerEvent).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/modules/phrase_bundle/functions.ts b/server/src/modules/phrase_bundle/functions.ts index 60a0da7..5e59dde 100644 --- a/server/src/modules/phrase_bundle/functions.ts +++ b/server/src/modules/phrase_bundle/functions.ts @@ -15,8 +15,27 @@ import { PhraseSchema } from "./db"; import { normaliseSourceUrl } from "../translation/url-normalise"; import { openRouter } from "../../utils/openrouter"; import { TRANSLATION_MODELS } from "../../utils/openrouter-models"; +import { trackServerEvent, SERVER_ANALYTICS_EVENTS } from "../../utils/analytics"; import { z } from "zod"; +/** + * Map a saved-from page URL to the coarse `source_platform` bucket the activation + * funnel filters on. Unknown/absent hosts collapse to `other`. + */ +function sourcePlatformFromUrl(sourceUrl?: string): string { + if (!sourceUrl) return "other"; + let host = ""; + try { + host = new URL(sourceUrl).hostname.toLowerCase(); + } catch { + return "other"; + } + if (host.includes("youtube.")) return "youtube"; + if (host.includes("netflix.")) return "netflix"; + if (host.includes("wikipedia.")) return "wikipedia"; + return "other"; +} + interface RemoveBundleParams { _id: string; refId: string; @@ -189,6 +208,10 @@ const createPhrase = defineFunction({ // Use existing phrase phraseId = existingPhrase._id; } else { + // Detect the user's first-ever save BEFORE inserting: zero prior phrase + // docs for this owner means this new save is their activation moment. + const priorPhraseCount = await phraseCollection.countDocuments({ refId }); + // Enforce the saved-words cap BEFORE creating a new phrase (free: 200 per // window; paid tiers: unlimited). Reusing an existing phrase doc above does // not count against the cap, so the check only guards genuinely new saves. @@ -230,6 +253,23 @@ const createPhrase = defineFunction({ phraseId = insertedPhrases[0]._id; isNewPhrase = true; + // Activation funnel: fire once, only when this is the user's first-ever + // phrase. Both the extension and the dashboard save through this RPC, so + // this is the single server-truth point for the "first save" milestone. + if (priorPhraseCount === 0) { + const languagePair = language_info?.source + ? `${language_info.source}-${language_info.target}` + : translation_language?.trim() || undefined; + trackServerEvent( + SERVER_ANALYTICS_EVENTS.PHRASE_SAVED_FIRST_TIME, + refId, + { + source_platform: sourcePlatformFromUrl(sourceUrl), + language_pair: languagePair, + } + ); + } + // Update freemium allocation only for new phrases const user_id = refId; const isFreemium = await isUserOnFreemium(user_id); diff --git a/server/src/modules/subscription/service.ts b/server/src/modules/subscription/service.ts index 11b1c37..ae9129c 100644 --- a/server/src/modules/subscription/service.ts +++ b/server/src/modules/subscription/service.ts @@ -820,12 +820,15 @@ export async function cancelSubscriptionByProviderAndSubscriptionId(props: { success: true, message: "Subscription canceled successfully", wasTrialing, + // Surface the canceled tier for the subscription_canceled analytics event. + tier: existing?.tier, }; } catch (error: any) { return { success: false, message: error.message || "Failed to cancel subscription", wasTrialing: false, + tier: undefined, }; } } diff --git a/server/src/utils/analytics.ts b/server/src/utils/analytics.ts index 425380d..2e12913 100644 --- a/server/src/utils/analytics.ts +++ b/server/src/utils/analytics.ts @@ -18,6 +18,9 @@ if (token) { // Mirrors frontend/constants/analyticsEvents.ts for the server-fired events. // Naming follows docs/metrics/event-naming.md: [object]_[action], `-` inside a part. export const SERVER_ANALYTICS_EVENTS = { + // Activation funnel (server-truth signals the client can't see reliably). + ACCOUNT_CREATED: "account_created", // first successful OAuth exchange for a new account + PHRASE_SAVED_FIRST_TIME: "phrase_saved_first-time", // user's first-ever phrase save (any surface) // Stripe webhook lifecycle (the roadmap's slim instrumentation set). TRIAL_STARTED: "trial_started", // subscription created with status "trialing" SUBSCRIPTION_STARTED: "subscription_started", // trial converted, or direct paid start — props: { via_trial }