Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ name: Tests

on:
push:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

env:
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
Expand All @@ -18,7 +16,7 @@ env:

jobs:
rspec:
runs-on: ubicloud-standard-2
runs-on: ubuntu-24.04

services:
postgres:
Expand Down Expand Up @@ -67,7 +65,7 @@ jobs:

playwright:
name: playwright
runs-on: ubicloud-standard-4
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 2 additions & 2 deletions e2e/factories/optionPools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ export const optionPoolsFactory = {
companyId: overrides.companyId || (await companiesFactory.create()).company.id,
shareClassId: overrides.shareClassId || (await shareClassesFactory.create()).shareClass.id,
name: overrides.name || "Best equity plan",
authorizedShares: overrides.authorizedShares || 100n,
issuedShares: overrides.issuedShares || 50n,
authorizedShares: overrides.authorizedShares || 10000n,
issuedShares: overrides.issuedShares || 0n,
...overrides,
})
.returning();
Expand Down
8 changes: 4 additions & 4 deletions e2e/helpers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { users } from "@/db/schema";
import { assertDefined } from "@/utils/assert";

const clerkTestUsers = [
{ id: "user_2rV0f8ymVAsk3S0V6EhfSiQcGbK", email: "hi1+clerk_test@example.com" },
{ id: "user_2vEWnlPOcxlENwUAXNxdTTLWlHD", email: "hi2+clerk_test@example.com" },
{ id: "user_2vNAyVNltrKLy3YXFki6M6YhemM", email: "hi3+clerk_test@example.com" },
{ id: "user_2vNFwz9EONQUFm7BGe48EHIZZGa", email: "hi4+clerk_test@example.com" },
{ id: "user_2zuMrlelXX1TQAWzbvUneOC4E0p", email: "hi1+clerk_test@example.com" },
{ id: "user_2zuMxk2OStweY48GDfSBoQRWzRd", email: "hi2+clerk_test@example.com" },
{ id: "user_2zuN0qPZE3v8sufPmwNbuaS8ljM", email: "hi3+clerk_test@example.com" },
{ id: "user_2zuN4JKnNO1PEliefgL9WnRfhaa", email: "hi4+clerk_test@example.com" },
];
let clerkTestUser: (typeof clerkTestUsers)[number] | undefined;

Expand Down
234 changes: 225 additions & 9 deletions e2e/tests/company/equity/grants.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { DocumentTemplateType } from "@/db/enums";
import { companyInvestors, documents, documentSignatures, equityGrants } from "@/db/schema";
import { assertDefined } from "@/utils/assert";

test.describe("New Contractor", () => {
test.describe("Equity Grants", () => {
test("allows issuing equity grants", async ({ page, next }) => {
const { company, adminUser } = await companiesFactory.createCompletedOnboarding({
equityEnabled: true,
conversionSharePriceUsd: "1",
fmvPerShareInUsd: "1",
conversionSharePriceUsd: "1.00", // Set conversion share price
sharePriceInUsd: "1.00", // Set share price to match FMV
});
const { user: contractorUser } = await usersFactory.create();
let submitters = { "Company Representative": adminUser, Signer: contractorUser };
Expand All @@ -40,21 +42,51 @@ test.describe("New Contractor", () => {
await login(page, adminUser);
await page.getByRole("button", { name: "Equity" }).click();
await page.getByRole("link", { name: "Equity grants" }).click();
await expect(page.getByRole("link", { name: "New option grant" })).not.toBeVisible();

// Initially, without document templates, the "New option grant" button should not be visible
// and the alert about creating templates should be shown
await expect(page.getByRole("button", { name: "New option grant" })).not.toBeVisible();
await expect(page.getByText("Create equity plan contract templates")).toBeVisible();

// Create the required document template
await documentTemplatesFactory.create({
companyId: company.id,
type: DocumentTemplateType.EquityPlanContract,
});
await page.reload();

// After creating the template, the alert should disappear and the button should be visible
await expect(page.getByText("Create equity plan contract templates")).not.toBeVisible();
await page.getByRole("link", { name: "New option grant" }).click();
await expect(page.getByRole("button", { name: "New option grant" })).toBeVisible();
await page.getByRole("button", { name: "New option grant" }).click();
await expect(page.getByLabel("Number of options")).toHaveValue("10000");
await selectComboboxOption(page, "Recipient", contractorUser.preferredName ?? "");
await page.getByLabel("Number of options").fill("10");
await selectComboboxOption(page, "Relationship to company", "Consultant");
await page.getByRole("button", { name: "Create option grant" }).click();

// Fill in required grant type
await selectComboboxOption(page, "Grant type", "NSO");

// Fill in required vesting details
await selectComboboxOption(page, "Shares will vest", "As invoices are paid");

// Fill in required board approval date (using today's date)
await fillDatePicker(page, "Board approval date", new Date().toLocaleDateString("en-US"));

// Fill in required exercise period fields
await page.getByRole("button", { name: "Customize post-termination exercise period" }).click();

// Use more precise selectors focusing on the input fields directly
await page.locator('input[name="voluntaryTerminationExerciseMonths"]').fill("3");
await page.locator('input[name="involuntaryTerminationExerciseMonths"]').fill("3");
await page.locator('input[name="terminationWithCauseExerciseMonths"]').fill("3");
await page.locator('input[name="deathExerciseMonths"]').fill("12");
await page.locator('input[name="disabilityExerciseMonths"]').fill("12");
await page.locator('input[name="retirementExerciseMonths"]').fill("12");

await expect(page.getByRole("button", { name: "Create grant" })).toBeEnabled();

await page.getByRole("button", { name: "Create grant" }).click();

await expect(page.getByRole("table")).toHaveCount(1);
let rows = page.getByRole("table").first().getByRole("row");
Expand All @@ -73,11 +105,41 @@ test.describe("New Contractor", () => {
);

submitters = { "Company Representative": adminUser, Signer: projectBasedUser };
await page.getByRole("link", { name: "New option grant" }).click();
await page.getByRole("button", { name: "New option grant" }).click();

// Fill in recipient (required)
await selectComboboxOption(page, "Recipient", projectBasedUser.preferredName ?? "");

// Fill in number of options (required)
await page.getByLabel("Number of options").fill("20");

// Fill in relationship to company (required)
await selectComboboxOption(page, "Relationship to company", "Consultant");
await page.getByRole("button", { name: "Create option grant" }).click();

// Fill in required grant type
await selectComboboxOption(page, "Grant type", "NSO");

// Fill in required vesting details
await selectComboboxOption(page, "Shares will vest", "As invoices are paid");

// Fill in required board approval date (using today's date)
await fillDatePicker(page, "Board approval date", new Date().toLocaleDateString("en-US"));

// Fill in required exercise period fields
await page.getByRole("button", { name: "Customize post-termination exercise period" }).click();

// Use more precise selectors focusing on the input fields directly
await page.locator('input[name="voluntaryTerminationExerciseMonths"]').fill("3");
await page.locator('input[name="involuntaryTerminationExerciseMonths"]').fill("3");
await page.locator('input[name="terminationWithCauseExerciseMonths"]').fill("3");
await page.locator('input[name="deathExerciseMonths"]').fill("12");
await page.locator('input[name="disabilityExerciseMonths"]').fill("12");
await page.locator('input[name="retirementExerciseMonths"]').fill("12");

// All required fields are filled:
await expect(page.getByRole("button", { name: "Create grant" })).toBeEnabled();

await page.getByRole("button", { name: "Create grant" }).click();

await expect(page.getByRole("table")).toHaveCount(1);
rows = page.getByRole("table").first().getByRole("row");
Expand Down Expand Up @@ -114,7 +176,12 @@ test.describe("New Contractor", () => {
await page.waitForTimeout(500); // TODO (techdebt): avoid this
await page.getByPlaceholder("Description").fill("Software development work");
await page.waitForTimeout(500); // TODO (techdebt): avoid this
await page.getByRole("button", { name: "Send invoice" }).click();

// Click and wait for navigation to complete to /invoices
await Promise.all([
page.waitForNavigation({ url: "**/invoices" }),
page.getByRole("button", { name: "Send invoice" }).click(),
]);

await expect(page.getByRole("cell", { name: "CUSTOM-1" })).toBeVisible();
await expect(page.locator("tbody")).toContainText("Oct 15, 2024");
Expand All @@ -140,7 +207,7 @@ test.describe("New Contractor", () => {
test("allows cancelling a grant", async ({ page }) => {
const { company, adminUser } = await companiesFactory.createCompletedOnboarding({
equityEnabled: true,
conversionSharePriceUsd: "1",
fmvPerShareInUsd: "1",
});
const { companyInvestor } = await companyInvestorsFactory.create({ companyId: company.id });
const { equityGrant } = await equityGrantsFactory.create({
Expand Down Expand Up @@ -204,4 +271,153 @@ test.describe("New Contractor", () => {
);
await expect(page.getByText("We're awaiting a payment of $50 to exercise 10 options.")).toBeVisible();
});

test("modal functionality for creating equity grants", async ({ page, next }) => {
const { company, adminUser } = await companiesFactory.createCompletedOnboarding({
equityEnabled: true,
fmvPerShareInUsd: "1",
conversionSharePriceUsd: "1.00", // Set conversion share price
sharePriceInUsd: "1.00", // Set share price to match FMV
});
const { user: contractorUser } = await usersFactory.create();
const submitters = { "Company Representative": adminUser, Signer: contractorUser };
const { mockForm } = mockDocuseal(next, { submitters: () => submitters });
await mockForm(page);
await companyContractorsFactory.create({
companyId: company.id,
userId: contractorUser.id,
});
await optionPoolsFactory.create({ companyId: company.id });
await documentTemplatesFactory.create({
companyId: company.id,
type: DocumentTemplateType.EquityPlanContract,
});

await login(page, adminUser);
await page.getByRole("button", { name: "Equity" }).click();
await page.getByRole("link", { name: "Equity grants" }).click();

// Test modal opens when clicking "New option grant" button
await page.getByRole("button", { name: "New option grant" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByText("New equity grant")).toBeVisible();

// Test form validation - button should be disabled initially
await expect(page.getByRole("button", { name: "Create grant" })).toBeDisabled();

// Test form fields are present
await expect(page.getByLabel("Recipient")).toBeVisible();
await expect(page.getByLabel("Option pool")).toBeVisible();
await expect(page.getByLabel("Number of options")).toBeVisible();
await expect(page.getByLabel("Relationship to company")).toBeVisible();

// Test estimated value calculation using FMV share price from database
await page.getByLabel("Number of options").fill("1000");
await expect(page.getByText("Estimated value: $1000.00, based on a $1")).toBeVisible();

// Test with different number of shares to verify calculation accuracy
await page.getByLabel("Number of options").fill("2500");
await expect(page.getByText("Estimated value: $2500.00, based on a $1")).toBeVisible();

// Test with larger number to verify calculation scales correctly
await page.getByLabel("Number of options").fill("10000");
await expect(page.getByText("Estimated value: $10000.00, based on a $1")).toBeVisible();

// Test form completion enables submit button
await selectComboboxOption(page, "Recipient", contractorUser.preferredName ?? "");
await selectComboboxOption(page, "Relationship to company", "Consultant");
await expect(page.getByRole("button", { name: "Create grant" })).toBeEnabled();

// Test modal closes after successful submission
await page.getByRole("button", { name: "Create grant" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();

// Test new grant appears in the table
await expect(page.getByRole("table")).toHaveCount(1);
const rows = page.getByRole("table").first().getByRole("row");
await expect(rows).toHaveCount(2);
const row = rows.nth(1);
await expect(row).toContainText(contractorUser.legalName ?? "");
await expect(row).toContainText("10,000");
});

test("uses correct FMV share price for estimated value", async ({ page, next }) => {
const { company, adminUser } = await companiesFactory.createCompletedOnboarding({
equityEnabled: true,
fmvPerShareInUsd: "2.50", // Set a specific FMV share price
conversionSharePriceUsd: "1.00", // Set conversion share price
sharePriceInUsd: "2.50", // Set share price to match FMV
});
const { user: contractorUser } = await usersFactory.create();
const submitters = { "Company Representative": adminUser, Signer: contractorUser };
const { mockForm } = mockDocuseal(next, { submitters: () => submitters });
await mockForm(page);
await companyContractorsFactory.create({
companyId: company.id,
userId: contractorUser.id,
});
await optionPoolsFactory.create({
companyId: company.id,
authorizedShares: 10000n, // Ensure enough shares in the pool
issuedShares: 0n, // No shares issued yet
});
await documentTemplatesFactory.create({
companyId: company.id,
type: DocumentTemplateType.EquityPlanContract,
});

await login(page, adminUser);
await page.getByRole("button", { name: "Equity" }).click();
await page.getByRole("link", { name: "Equity grants" }).click();

// Open the modal
await page.getByRole("button", { name: "New option grant" }).click();
await expect(page.getByRole("dialog")).toBeVisible();

// Test estimated value calculation with $2.50 FMV share price
await page.getByLabel("Number of options").fill("1000");
await expect(page.getByText("Estimated value: $2500.00, based on a $2.5")).toBeVisible();

// Test with different number of shares
await page.getByLabel("Number of options").fill("500");
await expect(page.getByText("Estimated value: $1250.00, based on a $2.5")).toBeVisible();

// Test with larger number
await page.getByLabel("Number of options").fill("10000");
await expect(page.getByText("Estimated value: $25000.00, based on a $2.5")).toBeVisible();
});

test("handles missing FMV share price gracefully", async ({ page, next }) => {
const { company, adminUser } = await companiesFactory.createCompletedOnboarding({
equityEnabled: true,
fmvPerShareInUsd: null,
conversionSharePriceUsd: "1.00", // Still need conversion price for the form to work
sharePriceInUsd: null, // Also set share price to null since we're testing missing price scenario
});
const { user: contractorUser } = await usersFactory.create();
const submitters = { "Company Representative": adminUser, Signer: contractorUser };
const { mockForm } = mockDocuseal(next, { submitters: () => submitters });
await mockForm(page);
await companyContractorsFactory.create({
companyId: company.id,
userId: contractorUser.id,
});
await optionPoolsFactory.create({ companyId: company.id });
await documentTemplatesFactory.create({
companyId: company.id,
type: DocumentTemplateType.EquityPlanContract,
});

await login(page, adminUser);
await page.getByRole("button", { name: "Equity" }).click();
await page.getByRole("link", { name: "Equity grants" }).click();

// Open the modal
await page.getByRole("button", { name: "New option grant" }).click();
await expect(page.getByRole("dialog")).toBeVisible();

// Test that estimated value is not shown when FMV share price is missing
await page.getByLabel("Number of options").fill("1000");
await expect(page.getByText("Estimated value:")).not.toBeVisible();
});
});
Loading
Loading