diff --git a/apps/web/app/api/lemon-squeezy/webhook/route.ts b/apps/web/app/api/lemon-squeezy/webhook/route.ts index 573b0781c1..2d5aa139cc 100644 --- a/apps/web/app/api/lemon-squeezy/webhook/route.ts +++ b/apps/web/app/api/lemon-squeezy/webhook/route.ts @@ -21,7 +21,7 @@ import type { Payload } from "@/app/api/lemon-squeezy/webhook/types"; import { cancelledPremium, switchedPremiumPlan, - upgradedToPremium, + startedTrial, } from "@inboxzero/loops"; import { SafeError } from "@/utils/error"; import { getLemonSubscriptionTier } from "@/app/(app)/premium/config"; @@ -159,7 +159,7 @@ async function subscriptionCreated({ payload.data.attributes.status === "on_trial" ? trackTrialStarted(email, payload.data.attributes) : trackUpgradedToPremium(email, payload.data.attributes), - upgradedToPremium(email, tier), + startedTrial(email, tier), ]); } catch (error) { logger.error("Error capturing event", { diff --git a/apps/web/ee/billing/stripe/loops-events.test.ts b/apps/web/ee/billing/stripe/loops-events.test.ts new file mode 100644 index 0000000000..6f0250b774 --- /dev/null +++ b/apps/web/ee/billing/stripe/loops-events.test.ts @@ -0,0 +1,502 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { handleLoopsEvents } from "./loops-events"; + +// Mock the Loops functions +vi.mock("@inboxzero/loops", () => ({ + createContact: vi.fn(), + completedTrial: vi.fn(), + startedTrial: vi.fn(), + cancelledPremium: vi.fn(), +})); + +// Mock the logger +vi.mock("@/utils/logger", () => ({ + createScopedLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +import { + createContact, + completedTrial, + startedTrial, + cancelledPremium, +} from "@inboxzero/loops"; + +describe("handleLoopsEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const mockCurrentPremium = { + stripeSubscriptionStatus: null, + stripeTrialEnd: null, + tier: null, + users: [{ email: "user@example.com", name: "John Doe" }], + admins: [], + }; + + const mockNewSubscription = { + status: "active", + trial_end: null, + }; + + describe("Trial started scenarios", () => { + it("should create contact when trial starts for new user", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: null, // No previous subscription + }; + + const newSubscription = { + ...mockNewSubscription, + trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // 7 days in future + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).toHaveBeenCalledWith("user@example.com", "John"); + }); + + it("should not create contact when trial_end is in the past", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + ...mockNewSubscription, + trial_end: Math.floor(Date.now() / 1000) - 1000, // Past timestamp + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).not.toHaveBeenCalled(); + }); + + it("should not create contact when user already has subscription status", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "active", // Already has subscription + }; + + const newSubscription = { + ...mockNewSubscription, + trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).not.toHaveBeenCalled(); + }); + + it("should handle user with no name", async () => { + const currentPremium = { + ...mockCurrentPremium, + users: [{ email: "user@example.com", name: null }], + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + ...mockNewSubscription, + trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).toHaveBeenCalledWith("user@example.com", undefined); + }); + }); + + describe("Trial completion scenarios", () => { + it("should call completedTrial when trial ends and subscription becomes active", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "trialing", + stripeTrialEnd: new Date(Date.now() + 1000 * 60 * 60), // 1 hour in future (was in trial) + }; + + const newSubscription = { + status: "active", + trial_end: Math.floor(Date.now() / 1000) - 1000, // Trial ended + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(completedTrial).toHaveBeenCalledWith( + "user@example.com", + "BUSINESS_MONTHLY", + ); + expect(startedTrial).not.toHaveBeenCalled(); // Should not call direct upgrade + }); + + it("should not call completedTrial when tier is null", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "trialing", + stripeTrialEnd: new Date(Date.now() + 1000 * 60 * 60), + }; + + const newSubscription = { + status: "active", + trial_end: Math.floor(Date.now() / 1000) - 1000, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: null, // No tier + }); + + expect(completedTrial).not.toHaveBeenCalled(); + }); + }); + + describe("Direct upgrade scenarios", () => { + it("should call startedTrial for first subscription (no previous status, no trial)", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: null, // First subscription + stripeTrialEnd: null, // No trial + }; + + const newSubscription = { + status: "active", + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(startedTrial).toHaveBeenCalledWith( + "user@example.com", + "BUSINESS_MONTHLY", + ); + expect(completedTrial).not.toHaveBeenCalled(); // Should not call trial completion + }); + + it("should call startedTrial when transitioning from incomplete", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "incomplete", + stripeTrialEnd: null, // No trial + }; + + const newSubscription = { + status: "active", + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(startedTrial).toHaveBeenCalledWith( + "user@example.com", + "BUSINESS_MONTHLY", + ); + expect(completedTrial).not.toHaveBeenCalled(); + }); + + it("should not call startedTrial when tier is null", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + status: "active", + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: null, // No tier + }); + + expect(startedTrial).not.toHaveBeenCalled(); + }); + + it("should not call startedTrial when subscription is not active", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + status: "trialing", // Not active + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(startedTrial).not.toHaveBeenCalled(); + }); + + it("should not call startedTrial for users who were in trial", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "trialing", + stripeTrialEnd: new Date(Date.now() + 1000 * 60 * 60), // Was in trial + }; + + const newSubscription = { + status: "active", + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + // Should call completedTrial, not startedTrial + expect(startedTrial).not.toHaveBeenCalled(); + expect(completedTrial).toHaveBeenCalled(); + }); + }); + + describe("Subscription cancelled scenarios", () => { + it("should call cancelledPremium when subscription is canceled", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "active", + }; + + const newSubscription = { + status: "canceled", + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(cancelledPremium).toHaveBeenCalledWith("user@example.com"); + }); + + it("should call cancelledPremium when subscription is unpaid", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "active", + }; + + const newSubscription = { + status: "unpaid", + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(cancelledPremium).toHaveBeenCalledWith("user@example.com"); + }); + + it("should call cancelledPremium when subscription is incomplete_expired", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "active", + }; + + const newSubscription = { + status: "incomplete_expired", + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(cancelledPremium).toHaveBeenCalledWith("user@example.com"); + }); + + it("should not call cancelledPremium when status hasn't changed", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: "canceled", // Already canceled + }; + + const newSubscription = { + status: "canceled", // Same status + trial_end: null, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(cancelledPremium).not.toHaveBeenCalled(); + }); + }); + + describe("Edge cases", () => { + it("should return early when currentPremium is null", async () => { + await handleLoopsEvents({ + currentPremium: null, + newSubscription: mockNewSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).not.toHaveBeenCalled(); + expect(completedTrial).not.toHaveBeenCalled(); + expect(startedTrial).not.toHaveBeenCalled(); + expect(cancelledPremium).not.toHaveBeenCalled(); + }); + + it("should return early when no email found", async () => { + const currentPremium = { + ...mockCurrentPremium, + users: [{ email: "", name: "John Doe" }], // Empty email + admins: [], + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription: mockNewSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).not.toHaveBeenCalled(); + expect(completedTrial).not.toHaveBeenCalled(); + expect(startedTrial).not.toHaveBeenCalled(); + expect(cancelledPremium).not.toHaveBeenCalled(); + }); + + it("should use admin email when user email is not available", async () => { + const currentPremium = { + ...mockCurrentPremium, + users: [], + admins: [{ email: "admin@example.com", name: "Admin User" }], + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + ...mockNewSubscription, + trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).toHaveBeenCalledWith("admin@example.com", "Admin"); + }); + + it("should handle Loops function errors gracefully", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + ...mockNewSubscription, + trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }; + + // Mock createContact to throw an error + vi.mocked(createContact).mockRejectedValueOnce( + new Error("Loops API error"), + ); + + // Should not throw + await expect( + handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }), + ).resolves.not.toThrow(); + }); + }); + + describe("Complex scenarios", () => { + it("should handle trial start and not trigger payment events", async () => { + const currentPremium = { + ...mockCurrentPremium, + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + status: "trialing", // Still in trial + trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, // Future trial end + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + // Should create contact for trial start + expect(createContact).toHaveBeenCalledWith("user@example.com", "John"); + // Should NOT trigger payment events since still trialing + expect(completedTrial).not.toHaveBeenCalled(); + expect(startedTrial).not.toHaveBeenCalled(); + }); + + it("should handle user with multiple spaces in name", async () => { + const currentPremium = { + ...mockCurrentPremium, + users: [{ email: "user@example.com", name: "John Middle Doe" }], + stripeSubscriptionStatus: null, + }; + + const newSubscription = { + ...mockNewSubscription, + trial_end: Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60, + }; + + await handleLoopsEvents({ + currentPremium, + newSubscription, + newTier: "BUSINESS_MONTHLY", + }); + + expect(createContact).toHaveBeenCalledWith("user@example.com", "John"); + }); + }); +}); diff --git a/apps/web/ee/billing/stripe/loops-events.ts b/apps/web/ee/billing/stripe/loops-events.ts new file mode 100644 index 0000000000..c72f134c66 --- /dev/null +++ b/apps/web/ee/billing/stripe/loops-events.ts @@ -0,0 +1,98 @@ +import { + createContact, + completedTrial, + startedTrial, + cancelledPremium, +} from "@inboxzero/loops"; +import { createScopedLogger } from "@/utils/logger"; + +const logger = createScopedLogger("stripe/syncStripeDataToDb"); + +export async function handleLoopsEvents({ + currentPremium, + newSubscription, + newTier, +}: { + currentPremium: { + stripeSubscriptionStatus: string | null; + stripeTrialEnd: Date | null; + tier: string | null; + users: { email: string; name: string | null }[]; + admins: { email: string; name: string | null }[]; + } | null; + newSubscription: any; + newTier: string | null; +}) { + try { + if (!currentPremium) return; + + const email = + currentPremium.users[0]?.email || currentPremium.admins[0]?.email; + const name = + currentPremium.users[0]?.name || currentPremium.admins[0]?.name; + + if (!email) { + logger.warn("No email found for premium user"); + return; + } + + // 1. Trial started - new trial end date and no previous subscription status + const hasNewTrial = + newSubscription.trial_end && + newSubscription.trial_end > Date.now() / 1000 && + !currentPremium.stripeSubscriptionStatus; + + if (hasNewTrial) { + logger.info("Trial started", { email }); + await createContact(email, name?.split(" ")[0]); + } + + // 2. Payment scenarios - distinguish between trial completion and direct purchase + const wasInTrial = + currentPremium.stripeTrialEnd && + currentPremium.stripeTrialEnd > new Date(); + const isNowActive = newSubscription.status === "active"; + const noLongerInTrial = + !newSubscription.trial_end || + newSubscription.trial_end <= Date.now() / 1000; + + // 2a. Trial completed and converted to paid subscription + const trialCompleted = isNowActive && wasInTrial && noLongerInTrial; + + if (trialCompleted) { + logger.info("Trial completed", { email, tier: newTier }); + if (newTier) { + await completedTrial(email, newTier); + } + } + + // 2b. Direct upgrade (no trial) or upgrade from incomplete status + const directUpgrade = + isNowActive && + !wasInTrial && + (!currentPremium.stripeSubscriptionStatus || // First subscription without trial + currentPremium.stripeSubscriptionStatus === "incomplete"); // Completing incomplete payment + + if (directUpgrade) { + logger.info("Direct upgrade to premium", { email, tier: newTier }); + if (newTier) { + await startedTrial(email, newTier); + } + } + + // 3. Subscription cancelled + const wasCancelled = + (newSubscription.status === "canceled" || + newSubscription.status === "unpaid" || + newSubscription.status === "incomplete_expired") && + currentPremium.stripeSubscriptionStatus !== newSubscription.status; + + if (wasCancelled) { + logger.info("Subscription cancelled", { email }); + await cancelledPremium(email); + } + } catch (error) { + logger.error("Error handling Loops events", { error }); + // Don't throw - we don't want Loops errors to break sync + } +} diff --git a/apps/web/ee/billing/stripe/sync-stripe.ts b/apps/web/ee/billing/stripe/sync-stripe.ts index 3cc9852112..b70be79ea2 100644 --- a/apps/web/ee/billing/stripe/sync-stripe.ts +++ b/apps/web/ee/billing/stripe/sync-stripe.ts @@ -2,6 +2,7 @@ import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; import { getStripe } from "@/ee/billing/stripe"; import { getStripeSubscriptionTier } from "@/app/(app)/premium/config"; +import { handleLoopsEvents } from "@/ee/billing/stripe/loops-events"; const logger = createScopedLogger("stripe/syncStripeDataToDb"); @@ -13,6 +14,18 @@ export async function syncStripeDataToDb({ try { const stripe = getStripe(); + // Get current state before updating + const currentPremium = await prisma.premium.findUnique({ + where: { stripeCustomerId: customerId }, + select: { + stripeSubscriptionStatus: true, + stripeTrialEnd: true, + tier: true, + users: { select: { email: true, name: true } }, + admins: { select: { email: true, name: true } }, + }, + }); + // Fetch latest subscription data from Stripe, expanding necessary fields const subscriptions = await stripe.subscriptions.list({ customer: customerId, @@ -76,6 +89,10 @@ export async function syncStripeDataToDb({ const tier = getStripeSubscriptionTier({ priceId: price.id }); + const newTrialEnd = subscription.trial_end + ? new Date(subscription.trial_end * 1000) + : null; + await prisma.premium.update({ where: { stripeCustomerId: customerId }, data: { @@ -89,9 +106,7 @@ export async function syncStripeDataToDb({ ? new Date(subscriptionItem.current_period_end * 1000) : null, stripeCancelAtPeriodEnd: subscription.cancel_at_period_end, - stripeTrialEnd: subscription.trial_end - ? new Date(subscription.trial_end * 1000) - : null, + stripeTrialEnd: newTrialEnd, stripeCanceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null, @@ -101,6 +116,13 @@ export async function syncStripeDataToDb({ }, }); + // Handle Loops events based on state changes + await handleLoopsEvents({ + currentPremium, + newSubscription: subscription, + newTier: tier, + }); + logger.info("Successfully updated Premium record from Stripe data", { customerId, }); diff --git a/packages/loops/src/loops.ts b/packages/loops/src/loops.ts index ed5c48e918..d9a6237a0b 100644 --- a/packages/loops/src/loops.ts +++ b/packages/loops/src/loops.ts @@ -40,7 +40,7 @@ export async function deleteContact( return resp; } -export async function upgradedToPremium( +export async function startedTrial( email: string, tier: string, ): Promise<{ success: boolean }> { @@ -55,6 +55,21 @@ export async function upgradedToPremium( return resp; } +export async function completedTrial( + email: string, + tier: string, +): Promise<{ success: boolean }> { + const loops = getLoopsClient(); + if (!loops) return { success: false }; + const resp = await loops.sendEvent({ + eventName: "completed_trial", + email, + contactProperties: { tier }, + eventProperties: { tier }, + }); + return resp; +} + export async function switchedPremiumPlan( email: string, tier: string,