/* oxlint-disable no-empty-pattern */ import type { BuildType } from "@argos/schemas/build-type"; import { expect, test, type Page } from "@playwright/test"; import type { Account } from "../apps/backend/src/database/models"; import { Build, Plan as PlanModel, ScreenshotBucket, StripeInvoice, StripeInvoiceSync, Subscription, TeamUser, } from "../apps/backend/src/database/models"; import { createProject, createTeamAccount, createUserAccount, } from "../apps/backend/src/database/seeds"; import { loggedTest } from "./logged-test"; import { getUniqueTestIdentifier, screenshot } from "./util"; function daysFromNow(days: number) { const date = new Date(); date.setDate(date.getDate() + days); return date.toISOString(); } async function createBuilds(account: Account, types: BuildType[]) { if (types.length === 0) { return; } const project = await createProject({ accountId: account.id, name: "web" }); const buckets = await ScreenshotBucket.query().insertAndFetch( types.map(() => ({ name: "default", commit: "029b662f3ae57bae7a215301067262c1e95bbc95", branch: "main", projectId: project.id, complete: true, valid: true, screenshotCount: 24, storybookScreenshotCount: 0, })), ); // Sequential on purpose: the build number is resolved with a // `max(number) + 1` sub-query, so a batch insert would hand the same number // to every row. for (const [index, type] of types.entries()) { await Build.query().insert({ projectId: project.id, compareScreenshotBucketId: buckets[index]!.id, jobStatus: "complete", conclusion: "no-changes", type, stats: { total: 24, failure: 0, added: 0, unchanged: 24, changed: 0, removed: 0, retryFailure: 0, ignored: 0, }, }); } } /** * A team sitting at a given point of the pipeline. The builds drive the check * build column, the subscription drives the status and the bank info one. */ async function createPipelineTeam(input: { slug: string; name: string; planId: string; subscriberId: string; createdDaysAgo: number; trialDaysLeft: number | null; paymentMethodFilled: boolean; buildTypes: BuildType[]; }): Promise { const { account } = await createTeamAccount({ slug: input.slug, name: input.name, }); // Both tables sort by creation date, and these teams are created // concurrently — without an explicit date their order would differ from one // run to the next. await account .$query() .patch({ createdAt: daysFromNow(-input.createdDaysAgo) }); const subscription = Subscription.query().insert({ planId: input.planId, accountId: account.id, provider: "stripe", // `check_stripe_fields` requires both of these on a Stripe subscription. stripeSubscriptionId: `sub_${input.slug}`, subscriberId: input.subscriberId, startDate: daysFromNow(-3), endDate: null, trialEndDate: input.trialDaysLeft === null ? null : daysFromNow(input.trialDaysLeft), paymentMethodFilled: input.paymentMethodFilled, status: input.trialDaysLeft === null ? "active" : "trialing", }); await Promise.all([subscription, createBuilds(account, input.buildTypes)]); return account; } /** * A team past its trial and billed by usage, with a screenshot volume placed * inside each of the periods that have closed since. * * The period boundaries are read back off the subscription rather than guessed * from day offsets: they follow the anniversary of `startDate`, so a fixed * "45 days ago" would land in one period or the next depending on the length of * the months the run happens to fall on. */ async function createBilledTeam(input: { slug: string; name: string; planId: string; subscriberId: string; createdDaysAgo: number; /** Days since the trial ended, for a team that converted. */ trialEndedDaysAgo?: number; /** * Days until the trial ends, for a team still inside one. * * Stripe never invoices trial usage, so such a team opens no billed period — * which is precisely the row the Screenshots column has to report anyway. */ trialEndsInDays?: number; /** * Where the running period opened, which sets the billing anniversary — the * offset into the month it falls at is the day the cycle bills on. */ periodStart: string; /** What the plan costs per month, as Stripe holds it — null when Argos has * not read it yet, which every subscription is until its next sync. */ flatPrice: number | null; /** Whether the team bought the static IP add-on, which its plan leaves out. */ staticIpEnabled?: boolean; /** Screenshots uploaded during each closed period, most recent first. */ screenshotsByClosedPeriod: number[]; /** * Screenshots uploaded since the running period opened, which is what the * Screenshots column reports. Left out where the team has yet to build into * the period it is in — a real state, and the one that reads as zero. */ screenshotsInRunningPeriod?: number; }): Promise { const runningTrial = input.trialEndsInDays; const { account, team } = await createTeamAccount({ slug: input.slug, name: input.name, }); if (input.staticIpEnabled) { await team.$query().patch({ staticIpEnabled: true }); } await account.$query().patch({ createdAt: daysFromNow(-input.createdDaysAgo), // A billed team reached checkout, so it has a Stripe customer — which is // what puts the Stripe tile at the edge of its team cell. stripeCustomerId: `cus_${input.slug}`, }); const subscription = await Subscription.query().insertAndFetch({ planId: input.planId, accountId: account.id, provider: "stripe", stripeSubscriptionId: `sub_${input.slug}`, subscriberId: input.subscriberId, // The row is as old as the team: periods that predate it are not billed. createdAt: daysFromNow(-input.createdDaysAgo), startDate: input.periodStart, endDate: null, trialEndDate: runningTrial === undefined ? daysFromNow(-(input.trialEndedDaysAgo ?? 0)) : daysFromNow(runningTrial), paymentMethodFilled: true, status: runningTrial === undefined ? "active" : "trialing", flatPrice: input.flatPrice, includedScreenshots: 35_000, additionalScreenshotPrice: 0.005, additionalStorybookScreenshotPrice: 0.002, currency: "usd", }); // Read off the plan rather than taken as an argument: the backend prices // against `plan.interval`, so a caller free to state its own could seed every // bucket on monthly boundaries a yearly aggregate never looks at — the // overage would silently read zero and the assertions would still pass. const plan = await PlanModel.query().findById(input.planId).throwIfNotFound(); const periodStarts = subscription.getPeriodStarts( new Date(), plan.interval, input.screenshotsByClosedPeriod.length + 1, ); const project = await createProject({ accountId: account.id, name: "usage", }); // Index 0 is the period still running, so the closed ones start at 1. const volumes = [ ...(input.screenshotsInRunningPeriod === undefined ? [] : [{ index: 0, screenshotCount: input.screenshotsInRunningPeriod }]), ...input.screenshotsByClosedPeriod.map((screenshotCount, index) => ({ index: index + 1, screenshotCount, })), ]; // One build per period, rather than a bare bucket: the Screenshots column is // summed off build stats while billing reads the buckets, and in real data // every bucket comes from a build. Sequential because the build number is // resolved with a `max(number) + 1` sub-query. for (const { index, screenshotCount } of volumes) { const periodStart = periodStarts[index]; if (!periodStart) { throw new Error("missing period"); } const createdAt = new Date(periodStart.getTime() + 3_600_000).toISOString(); const bucket = await ScreenshotBucket.query().insertAndFetch({ name: "default", commit: "029b662f3ae57bae7a215301067262c1e95bbc95", branch: "main", projectId: project.id, complete: true, valid: true, screenshotCount, storybookScreenshotCount: 0, createdAt, }); await Build.query().insert({ projectId: project.id, compareScreenshotBucketId: bucket.id, jobStatus: "complete", conclusion: "no-changes", type: "check", createdAt, stats: { total: screenshotCount, failure: 0, added: 0, unchanged: screenshotCount, changed: 0, removed: 0, retryFailure: 0, ignored: 0, }, }); } return account; } type PipelineTeams = { prefix: string }; const staffTest = loggedTest.extend<{ pipelineTeams: PipelineTeams }>({ /** The staff pages are gated on the `staff` flag, so the viewer needs it. */ user: async ({}, use, testInfo) => { const id = getUniqueTestIdentifier(testInfo); const user = await createUserAccount({ email: `staff-${id}@argos-ci.com`, name: "Alex Moreau", slug: `staff-${id}`, staff: true, }); await use(user); }, /** * A spread of pipeline states, so the captures show every rendering the * tables can produce rather than a column of identical rows. They share a * slug prefix, which each test uses to narrow the page down to them — every * other test in the suite creates a team too, and these pages list them all. */ pipelineTeams: async ({ plan, user }, use, testInfo) => { const prefix = `pipeline-${getUniqueTestIdentifier(testInfo)}`; const common = { planId: plan.id, subscriberId: user.user.id }; // The shared plan is flat, so it has no usage to price. The teams below // need four more: `pro`, which the price estimate is built for, one that // is not, a granted one that bills nothing at all — each row naming its // plan when it is not Pro — and one billed by the year, whose amounts are // stated for a year rather than a month. const [proPlan, enterprisePlan, grantedPlan, annualPlan] = await Promise.all([ PlanModel.query().insertAndFetch({ name: "pro", includedScreenshots: 35_000, usageBased: true, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "month", }), PlanModel.query().insertAndFetch({ name: "enterprise", includedScreenshots: 35_000, usageBased: true, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "month", }), PlanModel.query().insertAndFetch({ name: "open source", includedScreenshots: 1_000_000, usageBased: false, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "month", }), PlanModel.query().insertAndFetch({ name: "enterprise annual", includedScreenshots: 35_000, usageBased: true, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "year", }), ]); await Promise.all([ // A granted plan reads as active everywhere while billing nothing, so // its row carries no price — only the plan, which is what says why. createTeamAccount({ slug: `${prefix}-hexagon`, name: "Hexagon", forcedPlanId: grantedPlan.id, }).then(({ account }) => account.$query().patch({ createdAt: daysFromNow(-40) }), ), // Converted almost three months ago, so two periods have closed since: // 40k screenshots, then 50k. The row prices the most recent of the two. createBilledTeam({ ...common, planId: proPlan.id, slug: `${prefix}-soylent`, name: "Soylent", createdDaysAgo: 88, trialEndedDaysAgo: 85, periodStart: daysFromNow(-14), flatPrice: 100, screenshotsByClosedPeriod: [50_000, 40_000], // Well inside the 35k quota with half the period still to run: the // column says so long before the invoice does. screenshotsInRunningPeriod: 12_340, }), // A contract whose amount Argos has not read yet: the row falls back to // the guess for its plan rather than to the cheapest plan we sell. createBilledTeam({ ...common, planId: enterprisePlan.id, slug: `${prefix}-vandelay`, name: "Vandelay", createdDaysAgo: 70, trialEndedDaysAgo: 67, periodStart: daysFromNow(-6), flatPrice: null, screenshotsByClosedPeriod: [20_000], }), // Same shape, on a contract with a negotiated amount of its own — the // row has to quote that rather than a constant. createBilledTeam({ ...common, planId: enterprisePlan.id, slug: `${prefix}-umbrella`, name: "Umbrella", createdDaysAgo: 80, trialEndedDaysAgo: 77, flatPrice: 750, // Billed $200 a month on top of the plan. staticIpEnabled: true, periodStart: daysFromNow(-9), screenshotsByClosedPeriod: [60_000], // Already 6k past the quota, which is the whole point of the column: // the running amount has started moving with days still left on it. screenshotsInRunningPeriod: 41_000, }), // Billed by the year. Stripe states both halves of the amount for a year, // so the row has to quote the year — dividing it into a month would // report a figure that was never charged. Its previous period predates // the subscription row, which is why it has none. // Still inside its trial, and building. Stripe invoices no trial usage, so // this team opens no billed period at all — the two amount columns stay // empty — while the Screenshots column has to report what it consumes, // which is the whole signal for whether it converts. createBilledTeam({ ...common, planId: proPlan.id, slug: `${prefix}-trialsonic`, name: "Trialsonic", createdDaysAgo: 10, trialEndsInDays: 4, periodStart: daysFromNow(-10), flatPrice: 100, screenshotsByClosedPeriod: [], screenshotsInRunningPeriod: 5_625, }), createBilledTeam({ ...common, planId: annualPlan.id, slug: `${prefix}-initrode`, name: "Initrode", createdDaysAgo: 400, trialEndedDaysAgo: 397, periodStart: daysFromNow(-216), flatPrice: 12_000, screenshotsByClosedPeriod: [], }), createPipelineTeam({ ...common, slug: `${prefix}-northwind`, createdDaysAgo: 1, name: "Northwind", trialDaysLeft: 2, paymentMethodFilled: false, buildTypes: [], }), createPipelineTeam({ ...common, slug: `${prefix}-globex`, createdDaysAgo: 2, name: "Globex", trialDaysLeft: 11, paymentMethodFilled: false, buildTypes: ["orphan", "orphan"], }), createPipelineTeam({ ...common, slug: `${prefix}-initech`, createdDaysAgo: 3, name: "Initech", trialDaysLeft: null, paymentMethodFilled: true, buildTypes: ["orphan", "check", "check"], }), ]); await use({ prefix }); }, }); staffTest("staff all teams", async ({ page, pipelineTeams }) => { await page.goto("/staff/teams"); await expect(page.getByRole("heading", { name: "All Teams" })).toBeVisible(); await page.getByRole("searchbox").fill(pipelineTeams.prefix); await expect(page.getByText("Showing 1-9 of 9 teams")).toBeVisible(); // A yearly subscription states its amounts for a year, so the column has to // quote the year. Read as a monthly rate this row is a twelfth of itself. await expect(page.getByRole("row", { name: /Initrode/ })).toContainText( "$12,000", ); // Soylent is 12,340 into the 35k this month, well inside it — so the running // amount is still the flat price alone. On Pro, which includes the same // 35k on every row, the quota itself is left unsaid. const soylent = page.getByRole("row", { name: /Soylent/ }); await expect(soylent).toContainText("12,340"); await expect(soylent).not.toContainText("35,000"); // Umbrella is on a contract, so its quota is named — and it is 6k past it // with days still to run: $750 flat, $200 for its static IP and $30 of // overage, which is the figure the column exists to explain. const umbrella = page.getByRole("row", { name: /Umbrella/ }); await expect(umbrella).toContainText("41,000"); await expect(umbrella).toContainText("/ 35,000"); await expect(umbrella).toContainText("$980"); // The running period is partial by construction, so what is left of it is // what tells a genuine drop from a period that simply opened three days ago. await expect(umbrella).toContainText(/\d+ days left/); // A trial is invoiced nothing, so it opens no billed period and both amount // columns stay empty — but what it consumes is exactly what says whether it // will convert, so the Screenshots column reports it all the same. const trialsonic = page.getByRole("row", { name: /Trialsonic/ }); await expect(trialsonic).toContainText("5,625"); await expect(trialsonic).toContainText("trialing"); await expect(trialsonic).not.toContainText("$"); await screenshot(page, "staff-all-teams"); }); staffTest( "staff teams filtered by billing interval", async ({ page, pipelineTeams }) => { await page.goto("/staff/teams"); await page.getByRole("searchbox").fill(pipelineTeams.prefix); await expect(page.getByText("Showing 1-9 of 9 teams")).toBeVisible(); // Narrowing to one interval is what makes the two period columns comparable // between rows: a year's amount next to a month's is an order of magnitude. await page.getByRole("combobox", { name: "Billing interval" }).click(); await page.getByRole("option", { name: "Yearly" }).click(); await expect(page.getByText("Showing 1-1 of 1 teams")).toBeVisible(); await expect(page.getByRole("row", { name: /Initrode/ })).toBeVisible(); // Every other team is on a monthly plan, trials and the granted one // included: the filter is on how a team is billed, not on whether it pays. await page.getByRole("combobox", { name: "Billing interval" }).click(); await page.getByRole("option", { name: "Monthly" }).click(); await expect(page.getByText("Showing 1-8 of 8 teams")).toBeVisible(); await expect(page.getByRole("row", { name: /Initrode/ })).toBeHidden(); // Ordering and filtering are applied on the server, on two different code // paths: narrowing to an interval has to keep the order the column asked // for — newest first, which is the default — rather than fall back to // whatever order it happened to read the rows in. await expect .poll(() => getTeamNames(page)) .toEqual([ "Northwind", "Globex", "Initech", "Trialsonic", "Hexagon", "Vandelay", "Umbrella", "Soylent", ]); }, ); staffTest( "staff teams show the table is busy while re-sorting", async ({ page, pipelineTeams }) => { await page.goto("/staff/teams"); await page.getByRole("searchbox").fill(pipelineTeams.prefix); await expect(page.getByText("Showing 1-9 of 9 teams")).toBeVisible(); // Ordering by an amount prices every billed team, which takes long enough // on real data to look like the click did nothing. Held here so the state // the delay produces can be asserted at all. let release = () => {}; const held = new Promise((resolve) => { release = resolve; }); await page.route("**/graphql", async (route) => { await held; await route.continue(); }); const table = page.getByRole("table"); await page.getByRole("button", { name: "Last period" }).click(); await expect( table.locator("xpath=ancestor::div[@aria-busy]"), ).toHaveAttribute("aria-busy", "true"); // The rows already fetched stay readable underneath rather than being // replaced by a spinner. await expect(page.getByRole("row", { name: /Soylent/ })).toBeVisible(); release(); await expect( table.locator("xpath=ancestor::div[@aria-busy]"), ).toHaveAttribute("aria-busy", "false"); }, ); /** The team names in the order the table renders them. */ async function getTeamNames(page: Page): Promise { const names = await page .locator("tbody tr td:first-child .font-medium") .allInnerTexts(); return names.map((name) => name.trim()); } staffTest("staff trial pipeline", async ({ page, pipelineTeams }) => { test.slow(); await page.goto("/staff/trials"); await expect( page.getByRole("heading", { name: "Trial pipeline" }), ).toBeVisible(); await page.getByRole("searchbox").fill(pipelineTeams.prefix); // The page lists every team the suite created, and the trial labels below are // not unique among them — several fixtures seed the same spread. The count // only renders once the search has been applied (it runs through // `useDeferredValue`, so it lands a render late), which makes it the signal // that the table is down to this test's three teams. await expect(page.getByText(/^Showing 4 of \d+ teams$/)).toBeVisible(); await expect(page.getByText("2d left")).toBeVisible(); await expect(page.getByText("11d left")).toBeVisible(); await screenshot(page, "staff-trial-pipeline"); }); staffTest( "staff trial pipeline over 90 days", async ({ page, pipelineTeams }) => { test.slow(); await page.goto("/staff/trials?period=last90Days"); await expect( page.getByRole("heading", { name: "Trial pipeline" }), ).toBeVisible(); await page.getByRole("searchbox").fill(pipelineTeams.prefix); await expect(page.getByText(/^Showing 8 of \d+ teams$/)).toBeVisible(); // Soylent uploaded 50k screenshots over its last closed month: 15k past the // 35k included, at $0.005, on top of the $100 flat plan. The month still // running holds 12,340, well inside the quota, so reading that one instead // would print $100. await expect(page.getByText("$175")).toBeVisible(); // Umbrella is on Enterprise: same 60k over the same quota, but its // contract is $750, read from the subscription rather than assumed. The row // names the plan under the amount to say so. await expect(page.getByText("$875")).toBeVisible(); // Both Enterprise rows name their plan: the one quoting its contract and // the one falling back to a guess. await expect(page.getByText("Enterprise")).toHaveCount(2); // Hexagon is billed nothing at all, and the plan is the only thing that // says why — the price cell is an em dash. await expect(page.getByText("Open source")).toBeVisible(); // Vandelay stayed inside its quota, and its own amount is unknown, so the // row shows the guess for an Enterprise contract rather than Pro's $100. await expect(page.getByText("$1,000")).toBeVisible(); await screenshot(page, "staff-trial-pipeline-90-days"); }, ); /** The cancellations page's euros, written the way the page writes them. */ function cancellationAmount(amount: number): string { return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0, }).format(amount); } /** * One team per way a subscription can end up on the cancellations page, plus * a trial that ran out without a card, which must stay off it. */ async function createCanceledTeams(input: { prefix: string; planId: string; annualPlanId: string; subscriberId: string; }) { const { prefix, planId, annualPlanId, subscriberId } = input; async function createTeam( name: string, subscription: Partial & { invoices?: number[]; /** Defaults to a month before the first of its monthly bills. */ createdDaysAgo?: number; }, ) { const slug = `${prefix}-${name.toLowerCase().replaceAll(" ", "-")}`; const { account, team } = await createTeamAccount({ slug, name }); const { invoices = [], createdDaysAgo = 30 * (invoices.length + 1), ...data } = subscription; await account.$query().patch({ createdAt: daysFromNow(-createdDaysAgo) }); const provider = data.provider ?? "stripe"; const created = await Subscription.query().insertAndFetch({ planId, accountId: account.id, provider, ...(provider === "stripe" && { stripeSubscriptionId: `sub_${slug}`, subscriberId, }), startDate: daysFromNow(-20), paymentMethodFilled: true, ...data, }); // Monthly bills, the most recent last, each one month further back. await Promise.all( invoices.map((amount, index) => StripeInvoice.query().insert({ stripeInvoiceId: `in_${slug}_${index}`, stripeCustomerId: `cus_${slug}`, stripeSubscriptionId: `sub_${slug}`, stripeCreatedAt: daysFromNow(-30 * (invoices.length - index)), status: "paid", billingReason: "subscription_cycle", currency: "eur", total: amount * 100, totalExcludingTax: amount * 100, creditedAmountExcludingTax: 0, refundedAmount: 0, }), ), ); return { account, team, subscription: created }; } const [acme] = await Promise.all([ // Still running, so the one worth writing to first. createTeam("Acme", { status: "active", canceledAt: daysFromNow(-2), endDate: daysFromNow(18), cancellationReason: "cancellation_requested", cancellationFeedback: "too_expensive", cancellationComment: "We are cutting tool spend this quarter.", invoices: [120, 120, 120], }), createTeam("Pied Piper", { status: "canceled", canceledAt: daysFromNow(-40), endDate: daysFromNow(-12), cancellationReason: "cancellation_requested", cancellationFeedback: "switched_service", cancellationComment: "Moving to in-house tooling.", staffContactedAt: daysFromNow(-10), invoices: [100, 100], }), // Billed once a year: the column reads a twelfth of the bill. createTeam("Hooli", { planId: annualPlanId, createdDaysAgo: 400, status: "canceled", canceledAt: daysFromNow(-5), endDate: daysFromNow(-5), cancellationReason: "payment_failed", }).then(async ({ account }) => { const slug = account.slug; await StripeInvoice.query().insert({ stripeInvoiceId: `in_${slug}_annual`, stripeCustomerId: `cus_${slug}`, stripeSubscriptionId: `sub_${slug}`, stripeCreatedAt: daysFromNow(-300), status: "paid", billingReason: "subscription_cycle", currency: "eur", total: 12_000 * 100, totalExcludingTax: 12_000 * 100, creditedAmountExcludingTax: 0, refundedAmount: 0, periodStart: daysFromNow(-300), periodEnd: daysFromNow(65), }); }), // Canceled with a card on file before the trial converted: never billed. createTeam("Stark", { status: "trialing", trialEndDate: daysFromNow(6), canceledAt: daysFromNow(-1), endDate: daysFromNow(6), cancellationReason: "cancellation_requested", cancellationFeedback: "missing_features", }), createTeam("Tyrell", { status: "canceled", canceledAt: daysFromNow(-30), endDate: daysFromNow(-25), cancellationReason: "cancellation_requested", cancellationFeedback: "too_expensive", invoices: [100, 100, 100, 100, 100, 100], }), createTeam("Cyberdyne", { provider: "github", createdDaysAgo: 150, status: "canceled", canceledAt: daysFromNow(-50), endDate: daysFromNow(-20), }), createTeam("Massive Dynamic", { status: "canceled", paymentMethodFilled: false, trialEndDate: daysFromNow(-3), endDate: daysFromNow(-3), cancellationReason: "cancellation_requested", }), ]); // An owner, so the drafted email has someone to go to. const owner = await createUserAccount({ email: `${prefix}-jane@example.com`, slug: `${prefix}-jane`, name: "Jane Cooper", }); await TeamUser.query().insert({ teamId: acme.team.id, userId: owner.user.id, userLevel: "owner", }); } staffTest("staff cancellations", async ({ page, plan, user }, testInfo) => { test.slow(); const prefix = `cancel-${getUniqueTestIdentifier(testInfo)}`; const annualPlan = await PlanModel.query().insertAndFetch({ name: "enterprise annual", includedScreenshots: 35_000, usageBased: true, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "year", }); await createCanceledTeams({ prefix, planId: plan.id, annualPlanId: annualPlan.id, subscriberId: user.user.id, }); await page.goto("/staff/cancellations"); await expect( page.getByRole("heading", { name: "Cancellations" }), ).toBeVisible(); await page.getByRole("searchbox").fill(prefix); await expect(page.getByText(/^Showing 6 of \d+ teams$/)).toBeVisible(); // A trial that ran out on its own is the trial pipeline's. await expect(page.getByText("Massive Dynamic")).toBeHidden(); const acme = page.getByRole("row", { name: /Acme/ }); await expect(acme).toContainText("Too expensive"); await expect(acme).toContainText("ending"); await expect(acme).toContainText(cancellationAmount(120)); await expect(acme).toContainText(`${cancellationAmount(360)} in all`); await expect( acme.getByRole("link", { name: "Draft follow-up email" }), ).toHaveAttribute( "href", new RegExp(`^mailto:${prefix}-jane@example.com\\?`), ); // A yearly bill of 12,000 is 1,000 a month. await expect(page.getByRole("row", { name: /Hooli/ })).toContainText( cancellationAmount(1000), ); await expect(page.getByRole("row", { name: /Stark/ })).toContainText("trial"); await screenshot(page, "staff-cancellations"); // The mark is kept on the server, not on the page. const acmeSwitch = acme.getByRole("switch", { name: "Follow-up email sent" }); await expect(acmeSwitch).not.toBeChecked(); await acmeSwitch.click(); await expect(acmeSwitch).toBeChecked(); await page.reload(); await page.getByRole("searchbox").fill(prefix); await expect( page .getByRole("row", { name: /Acme/ }) .getByRole("switch", { name: "Follow-up email sent" }), ).toBeChecked(); }); /** * The revenue page's own notation, which its assertions have to be written in. * * Built rather than typed out: French groups its thousands with a narrow * no-break space, and a literal one in a spec is a character nobody can see * when the assertion stops matching. */ function revenueAmount(amount: number, currency: "EUR" | "USD"): string { return new Intl.NumberFormat("fr-FR", { style: "currency", currency, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0, }).format(amount); } /** The first instant of the month `offset` months from now, in UTC. */ function startOfUTCMonth(offset: number) { const now = new Date(); return new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + offset, 1), ); } type RevenueBook = { /** The teams the mirror was seeded for, by the name the page shows. */ monthlyTeam: string; dollarTeam: string; contractTeam: string; /** What a month of the contract comes to, as the page prints it. */ contractPerMonth: string; /** The days last month's two invoices were raised, as the page prints them. */ monthlyInvoiceDate: string; dollarInvoiceDate: string; }; /** * A mirror of invoices to read a revenue page from. * * Dated against the running month rather than fixed, because the page reports * the last thirteen calendar months: a fixed date would fall out of the window * a month after it was written. */ const revenueTest = staffTest.extend<{ revenueBook: RevenueBook }>({ revenueBook: async ({ user }, use, testInfo) => { const prefix = `revenue-${getUniqueTestIdentifier(testInfo)}`; const [monthlyPlan, annualPlan] = await Promise.all([ PlanModel.query().insertAndFetch({ name: "pro", includedScreenshots: 35_000, usageBased: true, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "month", }), PlanModel.query().insertAndFetch({ name: "enterprise annual", includedScreenshots: 1_000_000, usageBased: true, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "year", }), ]); async function createTeam(input: { slug: string; name: string; planId: string; }) { const { account } = await createTeamAccount({ slug: `${prefix}-${input.slug}`, name: input.name, }); await account .$query() .patch({ stripeCustomerId: `cus_${prefix}-${input.slug}` }); await Subscription.query().insert({ planId: input.planId, accountId: account.id, provider: "stripe", stripeSubscriptionId: `sub_${prefix}-${input.slug}`, subscriberId: user.user.id, startDate: daysFromNow(-400), endDate: null, paymentMethodFilled: true, status: "active", }); return account; } /** A team that was invoiced, then left: no subscription, invoices kept. */ async function createChurnedTeam(input: { slug: string; name: string }) { const { account } = await createTeamAccount({ slug: `${prefix}-${input.slug}`, name: input.name, }); await account .$query() .patch({ stripeCustomerId: `cus_${prefix}-${input.slug}` }); return account; } const monthlyTeam = "Kruger Industrial"; const dollarTeam = "Wernham Hogg"; const contractTeam = "Bluth Company"; await Promise.all([ createTeam({ slug: "kruger", name: monthlyTeam, planId: monthlyPlan.id, }), createTeam({ slug: "wernham", name: dollarTeam, planId: monthlyPlan.id, }), createTeam({ slug: "bluth", name: contractTeam, planId: annualPlan.id, }), createChurnedTeam({ slug: "sterling", name: "Sterling Cooper" }), ]); const lastMonth = new Date( startOfUTCMonth(-1).getTime() + 5 * 24 * 3600 * 1000, ); // A week after the dollar team's bill, on the lighter team: the breakdown // opens newest invoice first, and only a date order that disagrees with // the heaviest-first order the backend sends can show which one won. const lastMonthLater = new Date( startOfUTCMonth(-1).getTime() + 12 * 24 * 3600 * 1000, ); await StripeInvoice.query().insert([ // Two teams billed last month, one of them in dollars: the page states // euros, so the row has to show both what Stripe charged and what it // converts to. { stripeInvoiceId: `in_${prefix}-kruger-last`, stripeCustomerId: `cus_${prefix}-kruger`, stripeCreatedAt: lastMonthLater.toISOString(), status: "paid", billingReason: "subscription_cycle", currency: "eur", total: 50_000, totalExcludingTax: 50_000, creditedAmountExcludingTax: 0, }, { stripeInvoiceId: `in_${prefix}-wernham-last`, stripeCustomerId: `cus_${prefix}-wernham`, stripeCreatedAt: lastMonth.toISOString(), status: "paid", billingReason: "subscription_cycle", currency: "usd", total: 100_000, totalExcludingTax: 100_000, creditedAmountExcludingTax: 0, }, // The month before, so the month after it has something to compare // against — without it every row's change is an em dash and the column // is never rendered at all. { stripeInvoiceId: `in_${prefix}-kruger-before`, stripeCustomerId: `cus_${prefix}-kruger`, stripeCreatedAt: new Date( startOfUTCMonth(-2).getTime() + 5 * 24 * 3600 * 1000, ).toISOString(), status: "paid", billingReason: "subscription_cycle", currency: "eur", total: 100_000, totalExcludingTax: 100_000, creditedAmountExcludingTax: 0, }, // The running month, so the second card has something partial to report. { stripeInvoiceId: `in_${prefix}-kruger-running`, stripeCustomerId: `cus_${prefix}-kruger`, stripeCreatedAt: new Date().toISOString(), status: "paid", billingReason: "subscription_cycle", currency: "eur", total: 10_000, totalExcludingTax: 10_000, creditedAmountExcludingTax: 0, }, // A team that has since churned off a yearly contract keeps the renewal // it was sent. Nothing about its subscription says "annual" any more — // only the invoice's own period does — so this is what stands between a // year's contract and a twelvefold spike in the month it was raised. { stripeInvoiceId: `in_${prefix}-sterling-renewal`, stripeCustomerId: `cus_${prefix}-sterling`, stripeCreatedAt: lastMonth.toISOString(), status: "paid", billingReason: "subscription_cycle", currency: "eur", total: 4_800_000, totalExcludingTax: 4_800_000, creditedAmountExcludingTax: 0, periodStart: startOfUTCMonth(-13).toISOString(), periodEnd: startOfUTCMonth(-1).toISOString(), }, // An annual contract raised last month: it must stay out of that month's // monthly figures and be amortized over the year it covers instead. { stripeInvoiceId: `in_${prefix}-bluth-contract`, stripeCustomerId: `cus_${prefix}-bluth`, stripeCreatedAt: lastMonth.toISOString(), status: "paid", billingReason: "subscription_cycle", currency: "eur", total: 1_200_000, totalExcludingTax: 1_200_000, creditedAmountExcludingTax: 0, periodStart: startOfUTCMonth(-1).toISOString(), periodEnd: startOfUTCMonth(11).toISOString(), }, ]); // The page refuses a window the mirror was never swept for, so the sweep // has to be on record before it will report anything. await StripeInvoiceSync.query().insert({ // Deep enough for the window the page asks for, plus the year of // contracts that can still be paying for its first months. sinceDate: startOfUTCMonth(-36).toISOString(), completedAt: new Date().toISOString(), }); // The contract covers twelve months from this month's start, so a month // of it is its amount over that stretch, weighted by this month's length. const termMs = startOfUTCMonth(12).getTime() - startOfUTCMonth(0).getTime(); const monthMs = startOfUTCMonth(1).getTime() - startOfUTCMonth(0).getTime(); const contractPerMonth = revenueAmount((12_000 * monthMs) / termMs, "EUR"); const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC", }); await use({ monthlyTeam, dollarTeam, contractTeam, contractPerMonth, monthlyInvoiceDate: dateFormat.format(lastMonthLater), dollarInvoiceDate: dateFormat.format(lastMonth), }); }, }); /** * Where a subscription must have started for its period to close later today. * * The anniversary is the offset into the month `startDate` falls at, so the * offset is taken from the moment the period should close and applied to an * earlier month long enough to hold it — February cannot carry a thirtieth. */ function anchorClosingToday(): string { const monthEnd = startOfUTCMonth(1).getTime(); // An hour from now, or the last minute of the month when that hour would // fall outside it: the anniversary has to stay in the month it closes. const closesAt = Math.min(Date.now() + 3_600_000, monthEnd - 60_000); const offset = closesAt - startOfUTCMonth(0).getTime(); for (let monthsBack = 1; monthsBack <= 12; monthsBack += 1) { const start = startOfUTCMonth(-monthsBack).getTime(); if (start + offset < startOfUTCMonth(-monthsBack + 1).getTime()) { return new Date(start + offset).toISOString(); } } throw new Error("no month in the last year long enough to hold the anchor"); } /** * A team billed by the month whose cycle has not come round yet, with the usage * its bill will be raised on. * * Seeded on its own rather than into the book above: the running month is the * only month that can hold a bill still to come, and the book's assertions are * all about months that have closed. */ const pendingBillTest = staffTest.extend<{ pendingBill: { team: string; amount: string; screenshots: string }; }>({ pendingBill: async ({ user }, use, testInfo) => { const prefix = `pending-${getUniqueTestIdentifier(testInfo)}`; const plan = await PlanModel.query().insertAndFetch({ name: "pro", includedScreenshots: 35_000, usageBased: true, githubSsoIncluded: true, fineGrainedAccessControlIncluded: true, samlIncluded: true, interval: "month", }); await createBilledTeam({ planId: plan.id, subscriberId: user.user.id, slug: `${prefix}-dunder`, name: "Dunder Mifflin", createdDaysAgo: 60, trialEndedDaysAgo: 57, // Placed so the period closes later today: still ahead of now, and // inside the running month, which is the pair of conditions a bill this // month's projection is waiting on has to meet. Read off the calendar // rather than counted in days — a fortnight from the end of a month // lands the anniversary in the next one, where the bill would not be // this month's at all. periodStart: anchorClosingToday(), flatPrice: 100, screenshotsByClosedPeriod: [], screenshotsInRunningPeriod: 45_000, }); // The page refuses a window the mirror was never swept for, so the sweep // has to be on record even though this test reads no invoice at all. await StripeInvoiceSync.query().insert({ sinceDate: startOfUTCMonth(-36).toISOString(), completedAt: new Date().toISOString(), }); // The plan's own amount, plus the ten thousand screenshots past the quota // at the half-cent the helper prices them at. await use({ team: "Dunder Mifflin", amount: revenueAmount(150, "USD"), screenshots: new Intl.NumberFormat("fr-FR").format(45_000), }); }, }); pendingBillTest.describe("staff revenue estimates", () => { // One browser project, like the book above: the page has no filter, so the // second run would read the first one's teams as well as its own. pendingBillTest.skip( ({ browserName }) => browserName !== "chromium", "the page sums every team, so it can only be seeded once", ); pendingBillTest( "the running month lists the bills still to come", async ({ page, pendingBill }) => { await page.goto("/staff/revenue"); const monthlyPlans = page .locator("table") .filter({ has: page.getByRole("columnheader", { name: "ARPU" }) }); await monthlyPlans .locator("tbody tr") .first() .getByRole("button", { name: "View details" }) .click(); // Scoped to the breakdown's own rows: the month row wrapping them // carries the same text, and every other team seeded by the suite has a // line in there too. const line = monthlyPlans .locator("tbody tr") .nth(1) .locator("tbody tr") .filter({ hasText: pendingBill.team }); // Priced off the usage the period has accumulated: the cycle has raised // nothing to read. await expect(line.getByText(pendingBill.amount)).toBeVisible(); await expect(line.getByText(pendingBill.screenshots)).toBeVisible(); // The amount carries what makes it an estimate, the line itself saying // it only in the grey it is written in. await expect(async () => { await page.mouse.move(0, 0); await line.getByText(pendingBill.amount).hover(); await expect( page.getByText("Not yet invoiced or included above"), ).toBeVisible({ timeout: 2_000 }); }).toPass(); }, ); }); revenueTest.describe("staff revenue", () => { // One browser project, not both: the page has no filter, so every figure on // it is a sum over all the teams in the database — and the projects share // one, truncated once. Seeded twice, the totals double and the assertions // below describe neither run. The skip is declared here so the fixture never // seeds for the project that does not run it. revenueTest.skip( ({ browserName }) => browserName !== "chromium", "the page sums every team, so it can only be seeded once", ); revenueTest("staff revenue", async ({ page, revenueBook }) => { test.slow(); await page.goto("/staff/revenue"); await expect(page.getByRole("heading", { name: "Revenue" })).toBeVisible(); // €500 from one team and $1,000 from the other, the dollars converted at the // page's fixed rate: €500 + €855. await expect( page.getByText(`${revenueAmount(1355, "EUR")} Monthly`).first(), ).toBeVisible(); const monthlyPlans = page .locator("table") .filter({ has: page.getByRole("columnheader", { name: "ARPU" }) }); const lastMonthRow = monthlyPlans.locator("tbody tr").nth(1); await expect( lastMonthRow.getByText(revenueAmount(1355, "EUR")), ).toBeVisible(); // Two teams invoiced, so the average is half the month. Neither of the two // annual bills raised that same month is in either figure — not the current // contract, and not the churned team's renewal, whose only mark of being a // year's worth is the period on the invoice itself. await expect( lastMonthRow.getByText(revenueAmount(678, "EUR")), ).toBeVisible(); // €1,000 the month before, so the column reports the climb rather than the // em dash it falls back to with nothing to divide by. await expect( lastMonthRow.getByText( new Intl.NumberFormat("fr-FR", { style: "percent", maximumFractionDigits: 0, signDisplay: "exceptZero", }).format(0.36), ), ).toBeVisible(); await lastMonthRow.getByRole("button", { name: "View details" }).click(); const breakdown = monthlyPlans.locator("tbody tr").nth(2); await expect(breakdown.getByText(revenueBook.monthlyTeam)).toBeVisible(); // What Stripe charged, beside what the page counts it as. await expect(breakdown.getByText(revenueAmount(1000, "USD"))).toBeVisible(); await expect(breakdown.getByText(revenueAmount(855, "EUR"))).toBeVisible(); // Each line carries the day its invoice was raised. await expect( breakdown.getByText(revenueBook.monthlyInvoiceDate), ).toBeVisible(); await expect( breakdown.getByText(revenueBook.dollarInvoiceDate), ).toBeVisible(); // Newest invoice first to open: the monthly team was billed a week after // the dollar team, so it leads even though it weighs less — the date // orders the breakdown, not the heaviest-first payload the backend sends. const breakdownRows = breakdown.locator("tbody tr"); await expect(breakdownRows.nth(0)).toContainText(revenueBook.monthlyTeam); await expect(breakdownRows.nth(0).locator("td").first()).toHaveText("1"); await expect(breakdownRows.nth(1)).toContainText(revenueBook.dollarTeam); await expect(breakdownRows.nth(1).locator("td").first()).toHaveText("2"); // The contract is listed on its own, with the invoice its figure is read // from and what a month of it comes to — its twelve months are not all the // same length, so the share of this one is what the row reports. const contractRow = page .getByRole("row") .filter({ hasText: revenueBook.contractTeam }); // Once: the amount it was sold for. The team has uploaded nothing, so it // has run up no overage — nothing stacks above the contract, and the // renewal column has nothing to owe. await expect( contractRow.getByText(revenueAmount(12_000, "EUR")), ).toHaveCount(1); await expect( contractRow.getByText(revenueBook.contractPerMonth), ).toBeVisible(); // The term's own quota, which is what the count above it is read against — // and what says the row has no overage to carry. await expect( contractRow.getByText( `/ ${new Intl.NumberFormat("fr-FR").format(1_000_000)}`, ), ).toBeVisible(); // Scoped to the monthly table: the contracts table below lists every annual // team in the database, which the rest of the suite seeds too. await screenshot(page, "staff-revenue-monthly-plans", { element: monthlyPlans, }); // After the screenshot: the baseline is worth having in the order the // breakdown opens in. // Sorting by weight reverses the two, and the ranks follow the rows rather // than travelling with them. await breakdown.getByRole("button", { name: "In euros" }).click(); await expect(breakdownRows.nth(0)).toContainText(revenueBook.dollarTeam); await expect(breakdownRows.nth(0).locator("td").first()).toHaveText("1"); }); }); loggedTest("staff pages are refused to regular users", async ({ page }) => { // Every staff page, not just one: each owns its own query, so each has to // turn the refusal into something a reader can act on rather than into a // figure that failed to load. for (const path of [ "/staff/teams", "/staff/trials", "/staff/cancellations", "/staff/revenue", ]) { await page.goto(path); await expect(page.getByText("Access restricted")).toBeVisible(); } });