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
23 changes: 22 additions & 1 deletion api/create-checkout-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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 }],
Expand Down
10 changes: 6 additions & 4 deletions app/e2e/pass.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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();
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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
Expand Down
46 changes: 40 additions & 6 deletions app/src/components/Checkout.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"
);
});
});
Loading
Loading