Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@

# Agent E2E run reports (the specs are tracked; the generated run logs are not)
agent-tests/_runs/
.playwright-mcp
4 changes: 2 additions & 2 deletions frontend/components/FeatureLocked.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
</script>
2 changes: 1 addition & 1 deletion frontend/components/TextChatCounter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
</script>
2 changes: 1 addition & 1 deletion frontend/components/VoiceCapBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
</script>
2 changes: 1 addition & 1 deletion frontend/components/freemium_alerts/LimitationModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/freemium_alerts/UsageCapBanner.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ const state = computed<'hidden' | 'warning' | 'paused'>(() => {
});

function goToPlans() {
router.push('/settings/subscription');
router.push('/settings/subscription?from=cap-hit-banner');
}
</script>
3 changes: 3 additions & 0 deletions frontend/constants/analyticsEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
12 changes: 12 additions & 0 deletions frontend/pages/settings/subscription.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 16 additions & 1 deletion server/src/modules/auth/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 50 additions & 2 deletions server/src/modules/gateway/__tests__/stripe.adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function createdEvent() {
customer: "cus_1",
id: "sub_1",
status: "trialing",
currency: "gbp",
trial_end: 1234,
items: {
data: [
Expand All @@ -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");
});
Expand Down Expand Up @@ -131,6 +133,7 @@ describe("StripeAdapter.handleWebhook (metadata-driven grants)", () => {
expect(trackServerEvent).toHaveBeenCalledWith("trial_started", "u1", {
cadence: "monthly",
tier: "learner",
currency: "gbp",
});
});

Expand All @@ -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 }
);
});

Expand All @@ -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: {
Expand All @@ -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,
}
);
});

Expand Down Expand Up @@ -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,
});
});
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
});
});
Expand All @@ -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",
});
});

Expand Down
47 changes: 44 additions & 3 deletions server/src/modules/gateway/adapters/stripe.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
);
}
}
Expand All @@ -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,
Expand All @@ -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,
}
);
}

Expand Down Expand Up @@ -640,6 +679,8 @@ export class StripeAdapter implements PaymentAdapter {
{
cadence,
tier: entitlements.tierId,
currency: subscription.currency,
subscription_id: subscription.id,
via_trial: previousStatus === "trialing",
}
);
Expand Down
47 changes: 47 additions & 0 deletions server/src/modules/phrase_bundle/__tests__/createPhrase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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();
});
});
Loading
Loading