diff --git a/api/create-checkout-session.ts b/api/create-checkout-session.ts index 4c21ee35..f077134b 100644 --- a/api/create-checkout-session.ts +++ b/api/create-checkout-session.ts @@ -2,7 +2,7 @@ import { VercelApiHandler } from "@vercel/node"; import type { default as Stripe } from "stripe"; import { stripe } from "./_lib/_stripe"; import { getBaseUrl } from "./_lib/_helpers"; -import { PASS_METADATA_KEY } from "./_lib/_pass"; +import { PASS_METADATA_KEY, getActivePass } from "./_lib/_pass"; const plans = { monthly: { @@ -44,6 +44,27 @@ const handler: VercelApiHandler = async (req, res) => { .filter((c) => !c.deleted) .sort((a, b) => b.created - a.created)[0]; + // A pass never renews and there are no webhooks, so this is the only + // server-side guard against charging an active pass holder twice + // (e.g. a stale "pass" selection on the pricing page). + if (customer) { + const paymentIntents = await stripe.paymentIntents.list({ + customer: customer.id, + limit: 100, + expand: ["data.latest_charge"], + }); + const activePass = getActivePass( + paymentIntents.data, + Math.floor(Date.now() / 1000) + ); + if (activePass) { + res.status(400).json({ + error: { message: "You already have an active 30-Day Pass" }, + }); + return; + } + } + session = await stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price: planConfig.priceId, quantity: 1 }], diff --git a/app/e2e/pass.spec.ts b/app/e2e/pass.spec.ts index a187bdb0..231b9e91 100644 --- a/app/e2e/pass.spec.ts +++ b/app/e2e/pass.spec.ts @@ -27,7 +27,7 @@ async function logIn(page: Page, email: string, password: string) { * account stays non-pro for the paywall assertions in logged-in.spec.ts. */ test.describe("30-Day Pass CTAs", () => { - test("pricing page shows the pass ticket; logged-out checkout asks for login", async ({ + test("pricing page shows the pass callout; logged-out checkout asks for login", async ({ page, }) => { await page.goto(`${BASE_URL}/pricing?isE2E=true`); @@ -46,7 +46,8 @@ test.describe("30-Day Pass CTAs", () => { }) => { await logIn(page, TESTING_EMAIL, TESTING_PASSWORD); await page.goto(`${BASE_URL}/pricing?isE2E=true`); - await page.getByTestId("pass-button").click(); + await page.getByTestId("pass-plan-button").click(); + await page.getByTestId("checkout-button").click(); await page.waitForURL(/checkout\.stripe\.com/, { timeout: 30_000 }); await expect(page.getByText("$9.00").first()).toBeVisible(); await expect(page.getByText(/30-Day Pass/).first()).toBeVisible(); @@ -58,7 +59,7 @@ test.describe("30-Day Pass CTAs", () => { await page.getByTestId("pro-link").waitFor({ state: "detached" }); await page.goto(`${BASE_URL}/pricing?isE2E=true`); await expect(page.getByText("You're already a Pro User")).toBeVisible(); - await expect(page.getByTestId("pass-button")).toHaveCount(0); + await expect(page.getByTestId("pass-plan-button")).toHaveCount(0); }); }); @@ -99,7 +100,8 @@ test.describe("30-Day Pass full purchase", () => { TESTING_PASS_PASS as string ); await page.goto(`${BASE_URL}/pricing?isE2E=true`); - await page.getByTestId("pass-button").click(); + await page.getByTestId("pass-plan-button").click(); + await page.getByTestId("checkout-button").click(); await page.waitForURL(/checkout\.stripe\.com/, { timeout: 30_000 }); // Stripe hosted checkout, test mode diff --git a/app/src/components/Checkout.test.tsx b/app/src/components/Checkout.test.tsx index 9769415b..07893482 100644 --- a/app/src/components/Checkout.test.tsx +++ b/app/src/components/Checkout.test.tsx @@ -1,9 +1,10 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { ReactNode } from "react"; import { QueryClientProvider } from "react-query"; import { MemoryRouter } from "react-router-dom"; import { queryClient } from "../lib/queries"; +import { usePricingPlanStore } from "../lib/usePricingPlanStore"; import { fakeCustomer, fakePassCustomer, fakeSession } from "../test-utils"; import { AppContext } from "./AppContextProvider"; import { Checkout } from "./Checkout"; @@ -32,25 +33,58 @@ function renderCheckout(customer: unknown) { } describe("Checkout with the 30-Day Pass", () => { - test("logged-in free user sees plan buttons and the pass callout", () => { + // The plan store is module-level; don't let selections leak between tests + beforeEach(() => { + usePricingPlanStore.setState({ plan: "yearly" }); + }); + + test("logged-in free user sees all three plan cards", () => { renderCheckout({ customerId: "cus_free" }); expect(screen.getByTestId("yearly-plan-button")).toBeInTheDocument(); expect(screen.getByTestId("monthly-plan-button")).toBeInTheDocument(); - expect(screen.getByTestId("pass-button")).toBeInTheDocument(); + expect(screen.getByTestId("pass-plan-button")).toBeInTheDocument(); }); - test("pass holder keeps the subscription options but loses the pass callout", () => { + test("pass holder keeps the subscription options but loses the pass card", () => { renderCheckout(fakePassCustomer); expect(screen.getByText(/active 30-Day Pass until/i)).toBeInTheDocument(); expect(screen.getByTestId("yearly-plan-button")).toBeInTheDocument(); expect(screen.getByTestId("monthly-plan-button")).toBeInTheDocument(); - expect(screen.queryByTestId("pass-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("pass-plan-button")).not.toBeInTheDocument(); }); test("subscriber sees the existing already-pro message", () => { renderCheckout(fakeCustomer); expect(screen.getByText(/already a Pro User/i)).toBeInTheDocument(); - expect(screen.queryByTestId("pass-button")).not.toBeInTheDocument(); + expect(screen.queryByTestId("pass-plan-button")).not.toBeInTheDocument(); expect(screen.queryByTestId("yearly-plan-button")).not.toBeInTheDocument(); }); + + test("selecting the pass card switches the CTA to the pass", () => { + renderCheckout({ customerId: "cus_free" }); + expect(screen.getByTestId("checkout-button")).toHaveTextContent( + "Get Pro Access Now" + ); + fireEvent.click(screen.getByTestId("pass-plan-button")); + expect(screen.getByTestId("checkout-button")).toHaveTextContent( + "Get a 30-Day Pass — $9" + ); + expect(screen.getByTestId("pass-plan-button")).toHaveAttribute( + "aria-current", + "true" + ); + }); + + test("a stale pass selection falls back to yearly for a pass holder", () => { + usePricingPlanStore.setState({ plan: "pass" }); + renderCheckout(fakePassCustomer); + expect(screen.queryByTestId("pass-plan-button")).not.toBeInTheDocument(); + expect(screen.getByTestId("checkout-button")).toHaveTextContent( + "Get Pro Access Now" + ); + expect(screen.getByTestId("yearly-plan-button")).toHaveAttribute( + "aria-current", + "true" + ); + }); }); diff --git a/app/src/components/Checkout.tsx b/app/src/components/Checkout.tsx index ad7b7879..48119251 100644 --- a/app/src/components/Checkout.tsx +++ b/app/src/components/Checkout.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from "react"; +import { ReactNode, useContext } from "react"; import { AppContext, useSession } from "./AppContextProvider"; import { useHasActivePass } from "../lib/hooks"; import { formatDate } from "../lib/helpers"; @@ -8,6 +8,8 @@ import { useMutation } from "react-query"; import { Trans, t } from "@lingui/macro"; import classNames from "classnames"; import { LockSimple, CreditCard, ArrowClockwise } from "phosphor-react"; +import { usePostHog } from "posthog-js/react"; +import { PricingPlan, usePricingPlanStore } from "../lib/usePricingPlanStore"; export function Checkout({ pricing2, @@ -19,6 +21,7 @@ export function Checkout({ }) { const session = useSession(); const sessionEmail = session?.user?.email; + const posthog = usePostHog(); const { checkedSession, customerIsLoading, customer } = useContext(AppContext); // Deliberately keyed to the subscription, not useIsProUser(): a pass-only @@ -30,9 +33,15 @@ export function Checkout({ ["trialing", "active", "past_due", "unpaid"].includes(subStatus) ); const hasActivePass = useHasActivePass(); - const [plan, setPlan] = useState<"monthly" | "yearly">("yearly"); + const { plan, setPlan } = usePricingPlanStore(); + // Single source of truth for what will be purchased. A pass holder can't + // buy a second pass (there is no server-side guard against double + // charging), so a stale "pass" selection falls back to yearly everywhere: + // card highlight, CTA label, analytics, and the checkout request. + const effectivePlan: PricingPlan = + plan === "pass" && hasActivePass ? "yearly" : plan; const createCheckoutSession = useMutation( - async (plan: "monthly" | "yearly" | "pass") => { + async (plan: PricingPlan) => { const response = await fetch("/api/create-checkout-session", { method: "POST", headers: { @@ -75,7 +84,7 @@ export function Checkout({
-
-
+