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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
254 changes: 245 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,173 @@ 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 only after filling in all required fields
await selectComboboxOption(page, "Recipient", contractorUser.preferredName ?? "");
await selectComboboxOption(page, "Relationship to company", "Consultant");

// 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();
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");

// Now verify the button is enabled
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