import { expect, type Page } from "@playwright/test"; import { Account, Subscription } from "../apps/backend/src/database/models"; import { loggedTest } from "./logged-test"; import { ensureTeamOwner, screenshot } from "./util"; loggedTest.beforeEach(async ({ auth, team }) => { await ensureTeamOwner({ team: team.team, user: auth.user }); }); /** * Capture the page with the survey open, scrolled back to the top first. * * The dialog and its backdrop are fixed, so a full-page capture draws them * wherever the page was scrolled. Opening the survey scrolls down to the cancel * card at the bottom of Billing, and picking an option while the list is still * animating in makes Playwright retry with a different alignment each time, so * the offset changes from run to run. */ async function screenshotSurvey(page: Page, name: string) { await page.evaluate(() => window.scrollTo(0, 0)); await screenshot(page, name); } /** * Put the team on a real Stripe subscription. * * The fixture team is on a forced plan, which the plan card reads as a contract * we bill by hand — so the cancellation controls only appear once Stripe is * actually behind the plan. */ async function subscribeToStripe(input: { accountId: string; planId: string; subscriberId: string; slug: string; /** Seeds a running trial instead of a paid subscription. */ trial?: boolean; }) { await Account.query() .findById(input.accountId) .patch({ forcedPlanId: null, stripeCustomerId: `cus_${input.slug}` }); await Subscription.query().insert({ planId: input.planId, accountId: input.accountId, provider: "stripe", stripeSubscriptionId: `sub_${input.slug}`, subscriberId: input.subscriberId, startDate: new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString(), endDate: null, // A trial prices its period off `trialEndDate`, so the card needs one. trialEndDate: input.trial ? new Date(Date.now() + 10 * 24 * 3600 * 1000).toISOString() : null, paymentMethodFilled: !input.trial, status: input.trial ? "trialing" : "active", }); } loggedTest( "team billing - cancelation asks why, and offers a call on price", async ({ page, team, auth, plan }) => { await subscribeToStripe({ accountId: team.account.id, planId: plan.id, subscriberId: auth.user.id, slug: team.account.slug, }); await page.goto(`/${team.account.slug}/settings/billing`); await page.getByRole("button", { name: "Cancel subscription" }).click(); const dialog = page.getByRole("dialog"); await expect( dialog.getByRole("heading", { name: "Cancel subscription" }), ).toBeVisible(); await screenshotSurvey(page, "cancel-subscription-survey"); // Neither picked nor written: the survey asks again rather than letting the // cancelation through with no reason at all. await dialog.getByRole("button", { name: "Cancel subscription" }).click(); await expect( dialog.getByText("Tell us why, from the list or in your own words."), ).toBeVisible(); // "Other" is the one choice that cannot stand on its own. await dialog.getByLabel("Why are you leaving?").click(); await page.getByRole("option", { name: "Other", exact: true }).click(); await dialog.getByRole("button", { name: "Cancel subscription" }).click(); await expect( dialog.getByText("Tell us what went wrong so we can fix it."), ).toBeVisible(); await dialog.getByLabel("Why are you leaving?").click(); await page.getByRole("option", { name: "It costs too much" }).click(); // Price is the one answer that opens an offer, and it opens in place: no // extra screen between the leaver and the way out. await expect(dialog.getByText("Before you go")).toBeVisible(); await expect( dialog.getByText("The price is usually something we can work on"), ).toBeVisible(); await expect( dialog.getByRole("link", { name: "book a 15-minute call" }), ).toHaveCount(1); await screenshotSurvey(page, "cancel-subscription-price-offer"); }, ); loggedTest( "team billing - a scheduled cancelation can be called off", async ({ page, team, auth, plan }) => { await subscribeToStripe({ accountId: team.account.id, planId: plan.id, subscriberId: auth.user.id, slug: team.account.slug, }); await Subscription.query() .patch({ endDate: new Date(Date.now() + 20 * 24 * 3600 * 1000).toISOString(), }) .where({ accountId: team.account.id }); await page.goto(`/${team.account.slug}/settings/billing`); // The card announces the date the plan stops on instead of a payment that // will never be taken. await expect( page.getByText("Your subscription is canceled."), ).toBeVisible(); // The cancellation card turns into the way back, and the banner offers // the same action under the same name: the portal no longer can. await expect( page.getByRole("button", { name: "Resume subscription" }), ).toHaveCount(2); await expect( page.getByRole("button", { name: "Cancel subscription" }), ).toHaveCount(0); }, ); loggedTest( "team billing - a trial is never told it already paid", async ({ page, team, auth, plan }) => { await subscribeToStripe({ accountId: team.account.id, planId: plan.id, subscriberId: auth.user.id, slug: team.account.slug, trial: true, }); // A trial with no card ends on its own: there is nothing to cancel. await page.goto(`/${team.account.slug}/settings/billing`); await expect( page.getByRole("button", { name: "Add payment method" }).first(), ).toBeVisible(); await expect(page.getByRole("button", { name: /^Cancel/ })).toHaveCount(0); await Subscription.query() .patch({ paymentMethodFilled: true }) .where({ accountId: team.account.id }); await page.reload(); await page.getByRole("button", { name: "Cancel trial" }).click(); const dialog = page.getByRole("dialog"); await expect(dialog.getByText("until the trial ends")).toBeVisible(); await expect(dialog.getByText("already paid for")).toHaveCount(0); }, );