Ctrl K
ComunidadGuías…

tests/staff.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* 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<Account> {
  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<Account> {
  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<void>((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<string[]> {
  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<Subscription> & {
      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();
  }
});