diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4e4be9a54f..ba49a5d3f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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 }} @@ -18,7 +16,7 @@ env: jobs: rspec: - runs-on: ubicloud-standard-2 + runs-on: ubuntu-24.04 services: postgres: @@ -67,7 +65,7 @@ jobs: playwright: name: playwright - runs-on: ubicloud-standard-4 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 diff --git a/e2e/factories/optionPools.ts b/e2e/factories/optionPools.ts index 0328aa40a9..bd7f5b6484 100644 --- a/e2e/factories/optionPools.ts +++ b/e2e/factories/optionPools.ts @@ -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(); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts index 00b6d3ec0f..94c565ef0b 100644 --- a/e2e/helpers/auth.ts +++ b/e2e/helpers/auth.ts @@ -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; diff --git a/e2e/tests/company/equity/grants.spec.ts b/e2e/tests/company/equity/grants.spec.ts index 1823a4fac6..72a779291a 100644 --- a/e2e/tests/company/equity/grants.spec.ts +++ b/e2e/tests/company/equity/grants.spec.ts @@ -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 }; @@ -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"); @@ -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"); @@ -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"); @@ -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({ @@ -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(); + }); }); diff --git a/frontend/app/(dashboard)/companies/[companyId]/administrator/equity_grants/new/page.tsx b/frontend/app/(dashboard)/companies/[companyId]/administrator/equity_grants/new/page.tsx deleted file mode 100644 index bc072b36c5..0000000000 --- a/frontend/app/(dashboard)/companies/[companyId]/administrator/equity_grants/new/page.tsx +++ /dev/null @@ -1,555 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import TemplateSelector from "@/app/(dashboard)/document_templates/TemplateSelector"; -import { - optionGrantTypeDisplayNames, - relationshipDisplayNames, - vestingTriggerDisplayNames, -} from "@/app/(dashboard)/equity/grants"; -import ComboBox from "@/components/ComboBox"; -import { DashboardHeader } from "@/components/DashboardHeader"; -import DatePicker from "@/components/DatePicker"; -import { MutationStatusButton } from "@/components/MutationButton"; -import NumberInput from "@/components/NumberInput"; -import { Button } from "@/components/ui/button"; -import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { - DocumentTemplateType, - optionGrantIssueDateRelationships, - optionGrantTypes, - optionGrantVestingTriggers, -} from "@/db/enums"; -import { useCurrentCompany } from "@/global"; -import { trpc } from "@/trpc/client"; - -const MAX_VESTING_DURATION_IN_MONTHS = 120; - -const formSchema = z.object({ - companyWorkerId: z.string().min(1, "Must be present."), - optionPoolId: z.string().min(1, "Must be present."), - numberOfShares: z.number().gt(0), - issueDateRelationship: z.enum(optionGrantIssueDateRelationships), - optionGrantType: z.enum(optionGrantTypes), - optionExpiryMonths: z.number().min(0), - vestingTrigger: z.enum(optionGrantVestingTriggers), - vestingScheduleId: z.string().nullish(), - vestingCommencementDate: z.instanceof(CalendarDate, { message: "This field is required." }), - totalVestingDurationMonths: z.number().nullish(), - cliffDurationMonths: z.number().nullish(), - vestingFrequencyMonths: z.string().nullish(), - voluntaryTerminationExerciseMonths: z.number().min(0), - involuntaryTerminationExerciseMonths: z.number().min(0), - terminationWithCauseExerciseMonths: z.number().min(0), - deathExerciseMonths: z.number().min(0), - disabilityExerciseMonths: z.number().min(0), - retirementExerciseMonths: z.number().min(0), - boardApprovalDate: z.instanceof(CalendarDate, { message: "This field is required." }), - docusealTemplateId: z.string(), -}); -const refinedSchema = formSchema.refine( - (data) => data.optionGrantType !== "iso" || ["employee", "founder"].includes(data.issueDateRelationship), - { - message: "ISOs can only be issued to employees or founders.", - path: ["optionGrantType"], - }, -); - -type FormValues = z.infer; - -export default function NewEquityGrant() { - const router = useRouter(); - const trpcUtils = trpc.useUtils(); - const company = useCurrentCompany(); - const [data] = trpc.equityGrants.new.useSuspenseQuery({ companyId: company.id }); - - const form = useForm({ - resolver: zodResolver(refinedSchema), - defaultValues: { - companyWorkerId: "", - optionPoolId: data.optionPools[0]?.id ?? "", - numberOfShares: 10_000, - optionGrantType: "nso", - vestingCommencementDate: today(getLocalTimeZone()), - vestingTrigger: "invoice_paid", - boardApprovalDate: today(getLocalTimeZone()), - }, - context: { - optionPools: data.optionPools, - }, - }); - - const recipientId = form.watch("companyWorkerId"); - const optionPoolId = form.watch("optionPoolId"); - const optionPool = data.optionPools.find((pool) => pool.id === optionPoolId); - const recipient = data.workers.find(({ id }) => id === recipientId); - - useEffect(() => { - if (!recipientId) return; - - if (recipient?.salaried) { - form.setValue("optionGrantType", "iso"); - form.setValue("issueDateRelationship", "employee"); - } else { - const lastGrant = recipient?.lastGrant; - form.setValue("optionGrantType", lastGrant?.optionGrantType ?? "nso"); - form.setValue("issueDateRelationship", lastGrant?.issueDateRelationship ?? "employee"); - } - }, [recipientId]); - - useEffect(() => { - if (!optionPool) return; - - form.setValue("optionExpiryMonths", optionPool.defaultOptionExpiryMonths); - form.setValue("voluntaryTerminationExerciseMonths", optionPool.voluntaryTerminationExerciseMonths); - form.setValue("involuntaryTerminationExerciseMonths", optionPool.involuntaryTerminationExerciseMonths); - form.setValue("terminationWithCauseExerciseMonths", optionPool.terminationWithCauseExerciseMonths); - form.setValue("deathExerciseMonths", optionPool.deathExerciseMonths); - form.setValue("disabilityExerciseMonths", optionPool.disabilityExerciseMonths); - form.setValue("retirementExerciseMonths", optionPool.retirementExerciseMonths); - }, [optionPoolId]); - - const createEquityGrant = trpc.equityGrants.create.useMutation({ - onSuccess: async () => { - await trpcUtils.equityGrants.list.invalidate(); - await trpcUtils.equityGrants.totals.invalidate(); - await trpcUtils.capTable.show.invalidate(); - await trpcUtils.documents.list.invalidate(); - router.push(`/equity/grants`); - }, - onError: (error) => { - const fieldNames = Object.keys(formSchema.shape); - const errorInfoSchema = z.object({ - error: z.string(), - attribute_name: z - .string() - .nullable() - .transform((value) => { - const isFormField = (val: string): val is keyof FormValues => fieldNames.includes(val); - return value && isFormField(value) ? value : "root"; - }), - }); - - const errorInfo = errorInfoSchema.parse(JSON.parse(error.message)); - form.setError(errorInfo.attribute_name, { message: errorInfo.error }); - }, - }); - - const submit = form.handleSubmit(async (values: FormValues): Promise => { - if (optionPool && optionPool.availableShares < values.numberOfShares) - return form.setError("numberOfShares", { - message: `Not enough shares available in the option pool "${optionPool.name}" to create a grant with this number of options.`, - }); - - if (values.vestingTrigger === "scheduled") { - if (!values.vestingScheduleId) return form.setError("vestingScheduleId", { message: "Must be present." }); - - if (values.vestingScheduleId === "custom") { - if (!values.totalVestingDurationMonths || values.totalVestingDurationMonths <= 0) - return form.setError("totalVestingDurationMonths", { message: "Must be present and greater than 0." }); - if (values.totalVestingDurationMonths > MAX_VESTING_DURATION_IN_MONTHS) - return form.setError("totalVestingDurationMonths", { - message: `Must not be more than ${MAX_VESTING_DURATION_IN_MONTHS} months (${MAX_VESTING_DURATION_IN_MONTHS / 12} years).`, - }); - if (values.cliffDurationMonths == null || values.cliffDurationMonths < 0) - return form.setError("cliffDurationMonths", { message: "Must be present and greater than or equal to 0." }); - if (values.cliffDurationMonths >= values.totalVestingDurationMonths) - return form.setError("cliffDurationMonths", { message: "Must be less than total vesting duration." }); - if (!values.vestingFrequencyMonths) - return form.setError("vestingFrequencyMonths", { message: "Must be present." }); - if (Number(values.vestingFrequencyMonths) > values.totalVestingDurationMonths) - return form.setError("vestingFrequencyMonths", { message: "Must be less than total vesting duration." }); - } - } - - await createEquityGrant.mutateAsync({ - companyId: company.id, - ...values, - totalVestingDurationMonths: values.totalVestingDurationMonths ?? null, - cliffDurationMonths: values.cliffDurationMonths ?? null, - vestingFrequencyMonths: values.vestingFrequencyMonths ?? null, - vestingCommencementDate: values.vestingCommencementDate.toString(), - vestingScheduleId: values.vestingScheduleId ?? null, - boardApprovalDate: values.boardApprovalDate.toString(), - }); - }); - - return ( - <> - - Cancel - - } - /> - -
- void submit(e)} className="grid gap-6"> -
-

Grant details

- ( - - Recipient - - a.user.name.localeCompare(b.user.name)) - .map((worker) => ({ label: worker.user.name, value: worker.id }))} - placeholder="Select recipient" - /> - - - - )} - /> - - ( - - Option pool - - ({ - label: optionPool.name, - value: optionPool.id, - }))} - placeholder="Select option pool" - /> - - - {optionPool ? ( - - Available shares in this option pool: {optionPool.availableShares.toLocaleString()} - - ) : null} - - )} - /> - - ( - - Number of options - - - - - - )} - /> - - ( - - Relationship to company - - ({ - label: value, - value: key, - }))} - placeholder="Select relationship" - /> - - - - )} - /> - - ( - - Grant type - - ({ - label: value, - value: key, - }))} - placeholder="Select grant type" - /> - - - - )} - /> - - ( - - Expiry - - - - - If not exercised, options will expire after this period. - - )} - /> -
- - ( - - - - - - - )} - /> - -
-

Vesting details

- ( - - Shares will vest - - ({ - label: value, - value: key, - }))} - placeholder="Select an option" - /> - - - - )} - /> - - {form.watch("vestingTrigger") === "scheduled" && ( - <> - ( - - Vesting schedule - - ({ - label: schedule.name, - value: schedule.id, - })), - { label: "Custom", value: "custom" }, - ]} - placeholder="Select a vesting schedule" - /> - - - - )} - /> - - ( - - - - - - - )} - /> - - {form.watch("vestingScheduleId") === "custom" && ( - <> - ( - - Total vesting duration - - - - - - )} - /> - - ( - - Cliff period - - - - - - )} - /> - - ( - - Vesting frequency - - - - - - )} - /> - - )} - - )} -
- -
-

Post-termination exercise periods

- ( - - Voluntary termination exercise period - - - - - - )} - /> - - ( - - Involuntary termination exercise period - - - - - - )} - /> - - ( - - Termination with cause exercise period - - - - - - )} - /> - - ( - - Death exercise period - - - - - - )} - /> - - ( - - Disability exercise period - - - - - - )} - /> - - ( - - Retirement exercise period - - - - - - )} - /> -
- - } - /> - -
- {form.formState.errors.root ? ( -
- {form.formState.errors.root.message ?? "An error occurred"} -
- ) : null} - - Create option grant - -
- - - - ); -} diff --git a/frontend/app/(dashboard)/document_templates/TemplateSelector.tsx b/frontend/app/(dashboard)/document_templates/TemplateSelector.tsx index 634a2f5825..141bc92c24 100644 --- a/frontend/app/(dashboard)/document_templates/TemplateSelector.tsx +++ b/frontend/app/(dashboard)/document_templates/TemplateSelector.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useId } from "react"; import ComboBox from "@/components/ComboBox"; import { FormControl, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { DocumentTemplateType } from "@/db/enums"; import { useCurrentCompany } from "@/global"; -import { DocumentTemplateType, trpc } from "@/trpc/client"; +import { trpc } from "@/trpc/client"; const TemplateSelector = ({ type, diff --git a/frontend/app/(dashboard)/equity/grants/NewEquityGrantModal.tsx b/frontend/app/(dashboard)/equity/grants/NewEquityGrantModal.tsx new file mode 100644 index 0000000000..0212894a9d --- /dev/null +++ b/frontend/app/(dashboard)/equity/grants/NewEquityGrantModal.tsx @@ -0,0 +1,610 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import TemplateSelector from "@/app/(dashboard)/document_templates/TemplateSelector"; +import { + optionGrantTypeDisplayNames, + relationshipDisplayNames, + vestingTriggerDisplayNames, +} from "@/app/(dashboard)/equity/grants"; +import ComboBox from "@/components/ComboBox"; +import DatePicker from "@/components/DatePicker"; +import { MutationStatusButton } from "@/components/MutationButton"; +import NumberInput from "@/components/NumberInput"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { + DocumentTemplateType, + optionGrantIssueDateRelationships, + optionGrantTypes, + optionGrantVestingTriggers, +} from "@/db/enums"; +import { useCurrentCompany } from "@/global"; +import { trpc } from "@/trpc/client"; + +const MAX_VESTING_DURATION_IN_MONTHS = 120; + +const formSchema = z.object({ + companyWorkerId: z.string().min(1, "Must be present."), + optionPoolId: z.string().min(1, "Must be present."), + numberOfShares: z.number().gt(0), + issueDateRelationship: z.enum(optionGrantIssueDateRelationships), + optionGrantType: z.enum(optionGrantTypes), + optionExpiryMonths: z.number().min(0), + vestingTrigger: z.enum(optionGrantVestingTriggers), + vestingScheduleId: z.string().nullish(), + vestingCommencementDate: z.instanceof(CalendarDate, { message: "This field is required." }), + totalVestingDurationMonths: z.number().nullish(), + cliffDurationMonths: z.number().nullish(), + vestingFrequencyMonths: z.string().nullish(), + voluntaryTerminationExerciseMonths: z.number().min(0), + involuntaryTerminationExerciseMonths: z.number().min(0), + terminationWithCauseExerciseMonths: z.number().min(0), + deathExerciseMonths: z.number().min(0), + disabilityExerciseMonths: z.number().min(0), + retirementExerciseMonths: z.number().min(0), + boardApprovalDate: z.instanceof(CalendarDate, { message: "This field is required." }), + docusealTemplateId: z.string(), +}); +const refinedSchema = formSchema.refine( + (data) => data.optionGrantType !== "iso" || ["employee", "founder"].includes(data.issueDateRelationship), + { + message: "ISOs can only be issued to employees or founders.", + path: ["optionGrantType"], + }, +); + +type FormValues = z.infer; + +interface NewEquityGrantModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function NewEquityGrantModal({ open, onOpenChange }: NewEquityGrantModalProps) { + const trpcUtils = trpc.useUtils(); + const company = useCurrentCompany(); + const [data] = trpc.equityGrants.new.useSuspenseQuery({ companyId: company.id }); + const [showExercisePeriods, setShowExercisePeriods] = useState(false); + + const form = useForm({ + resolver: zodResolver(refinedSchema), + defaultValues: { + companyWorkerId: "", + optionPoolId: data.optionPools[0]?.id ?? "", + numberOfShares: 10_000, + optionGrantType: "nso" as const, + vestingCommencementDate: today(getLocalTimeZone()), + vestingTrigger: "invoice_paid" as const, + boardApprovalDate: today(getLocalTimeZone()), + optionExpiryMonths: data.optionPools[0]?.defaultOptionExpiryMonths ?? 120, + voluntaryTerminationExerciseMonths: data.optionPools[0]?.voluntaryTerminationExerciseMonths ?? 3, + involuntaryTerminationExerciseMonths: data.optionPools[0]?.involuntaryTerminationExerciseMonths ?? 3, + terminationWithCauseExerciseMonths: data.optionPools[0]?.terminationWithCauseExerciseMonths ?? 3, + deathExerciseMonths: data.optionPools[0]?.deathExerciseMonths ?? 12, + disabilityExerciseMonths: data.optionPools[0]?.disabilityExerciseMonths ?? 12, + retirementExerciseMonths: data.optionPools[0]?.retirementExerciseMonths ?? 12, + }, + context: { + optionPools: data.optionPools, + }, + }); + + const recipientId = form.watch("companyWorkerId"); + const optionPoolId = form.watch("optionPoolId"); + const numberOfShares = form.watch("numberOfShares"); + const optionPool = data.optionPools.find((pool) => pool.id === optionPoolId); + const recipient = data.workers.find(({ id }) => id === recipientId); + + const estimatedValue = + data.sharePriceUsd && numberOfShares && !isNaN(Number(data.sharePriceUsd)) + ? (Number(data.sharePriceUsd) * numberOfShares).toFixed(2) + : null; + + const isFormValid = form.formState.isValid; + + useEffect(() => { + if (!recipientId) return; + + if (recipient?.salaried) { + form.setValue("optionGrantType", "iso"); + form.setValue("issueDateRelationship", "employee"); + } else { + const lastGrant = recipient?.lastGrant; + form.setValue("optionGrantType", lastGrant?.optionGrantType ?? "nso"); + form.setValue("issueDateRelationship", lastGrant?.issueDateRelationship ?? "employee"); + } + }, [recipientId]); + + useEffect(() => { + if (!optionPool) return; + + form.setValue("optionExpiryMonths", optionPool.defaultOptionExpiryMonths); + form.setValue("voluntaryTerminationExerciseMonths", optionPool.voluntaryTerminationExerciseMonths); + form.setValue("involuntaryTerminationExerciseMonths", optionPool.involuntaryTerminationExerciseMonths); + form.setValue("terminationWithCauseExerciseMonths", optionPool.terminationWithCauseExerciseMonths); + form.setValue("deathExerciseMonths", optionPool.deathExerciseMonths); + form.setValue("disabilityExerciseMonths", optionPool.disabilityExerciseMonths); + form.setValue("retirementExerciseMonths", optionPool.retirementExerciseMonths); + }, [optionPool]); + + const createEquityGrant = trpc.equityGrants.create.useMutation({ + onSuccess: async () => { + await trpcUtils.equityGrants.list.invalidate(); + await trpcUtils.equityGrants.totals.invalidate(); + await trpcUtils.capTable.show.invalidate(); + await trpcUtils.documents.list.invalidate(); + + handleClose(false); + }, + onError: (error) => { + const fieldNames = Object.keys(formSchema.shape); + const errorInfoSchema = z.object({ + error: z.string(), + attribute_name: z + .string() + .nullable() + .transform((value) => { + const isFormField = (val: string): val is keyof FormValues => fieldNames.includes(val); + return value && isFormField(value) ? value : "root"; + }), + }); + + const errorInfo = errorInfoSchema.parse(JSON.parse(error.message)); + form.setError(errorInfo.attribute_name, { message: errorInfo.error }); + }, + }); + + const submit = form.handleSubmit(async (values: FormValues): Promise => { + if (optionPool && optionPool.availableShares < values.numberOfShares) + return form.setError("numberOfShares", { + message: `Not enough shares available in the option pool "${optionPool.name}" to create a grant with this number of options.`, + }); + + if (values.vestingTrigger === "scheduled") { + if (!values.vestingScheduleId) return form.setError("vestingScheduleId", { message: "Must be present." }); + + if (values.vestingScheduleId === "custom") { + if (!values.totalVestingDurationMonths || values.totalVestingDurationMonths <= 0) + return form.setError("totalVestingDurationMonths", { message: "Must be present and greater than 0." }); + if (values.totalVestingDurationMonths > MAX_VESTING_DURATION_IN_MONTHS) + return form.setError("totalVestingDurationMonths", { + message: `Must not be more than ${MAX_VESTING_DURATION_IN_MONTHS} months (${MAX_VESTING_DURATION_IN_MONTHS / 12} years).`, + }); + if (values.cliffDurationMonths == null || values.cliffDurationMonths < 0) + return form.setError("cliffDurationMonths", { message: "Must be present and greater than or equal to 0." }); + if (values.cliffDurationMonths >= values.totalVestingDurationMonths) + return form.setError("cliffDurationMonths", { message: "Must be less than total vesting duration." }); + if (!values.vestingFrequencyMonths) + return form.setError("vestingFrequencyMonths", { message: "Must be present." }); + if (Number(values.vestingFrequencyMonths) > values.totalVestingDurationMonths) + return form.setError("vestingFrequencyMonths", { message: "Must be less than total vesting duration." }); + } + } + + await createEquityGrant.mutateAsync({ + companyId: company.id, + ...values, + totalVestingDurationMonths: values.totalVestingDurationMonths ?? null, + cliffDurationMonths: values.cliffDurationMonths ?? null, + vestingFrequencyMonths: values.vestingFrequencyMonths ?? null, + vestingCommencementDate: values.vestingCommencementDate.toString(), + vestingScheduleId: values.vestingScheduleId ?? null, + boardApprovalDate: values.boardApprovalDate.toString(), + }); + }); + + const handleClose = (isOpen: boolean) => { + if (!isOpen) { + setShowExercisePeriods(false); + form.reset(); + } + onOpenChange(isOpen); + }; + + return ( + + + + New equity grant + Fill in the details below to create an equity grant. + + +
+ void submit(e)} className="grid gap-6"> +
+

Recipient details

+ ( + + Recipient + + a.user.name.localeCompare(b.user.name)) + .map((worker) => ({ label: worker.user.name, value: worker.id }))} + placeholder="Select recipient" + /> + + + + )} + /> + + ( + + Relationship to company + + ({ + label: value, + value: key, + }))} + placeholder="Select relationship" + /> + + + + )} + /> +
+ +
+

Option grant details

+ ( + + Option pool + + ({ + label: optionPool.name, + value: optionPool.id, + }))} + placeholder="Select option pool" + /> + + + {optionPool ? ( + + Available shares in this option pool: {optionPool.availableShares.toLocaleString()} + + ) : null} + + )} + /> + + ( + + Number of options + + + + + {estimatedValue ? ( + + Estimated value: ${estimatedValue}, based on a ${data.sharePriceUsd} + + ) : null} + + )} + /> + +
+ ( + + Grant type + + ({ + label: value, + value: key, + }))} + placeholder="Select grant type" + /> + + + + )} + /> + + ( + + Expiration period + + + + + + )} + /> +
+ + ( + + + + + + + )} + /> +
+ +
+

Vesting details

+ ( + + Shares will vest + + ({ + label: value, + value: key, + }))} + placeholder="Select an option" + /> + + + + )} + /> + + {form.watch("vestingTrigger") === "scheduled" && ( + <> + ( + + Vesting schedule + + ({ + label: schedule.name, + value: schedule.id, + })), + { label: "Custom", value: "custom" }, + ]} + placeholder="Select a vesting schedule" + /> + + + + )} + /> + + ( + + + + + + + )} + /> + + {form.watch("vestingScheduleId") === "custom" && ( + <> + ( + + Total vesting duration + + + + + + )} + /> + + ( + + Cliff period + + + + + + )} + /> + + ( + + Vesting frequency + + + + + + )} + /> + + )} + + )} +
+ +
+ + + {showExercisePeriods ? ( +
+ ( + + Voluntary termination exercise period + + + + + + )} + /> + + ( + + Involuntary termination exercise period + + + + + + )} + /> + + ( + + Termination with cause exercise period + + + + + + )} + /> + + ( + + Death exercise period + + + + + + )} + /> + + ( + + Disability exercise period + + + + + + )} + /> + + ( + + Retirement exercise period + + + + + + )} + /> +
+ ) : null} +
+ + } + /> + +
+ {form.formState.errors.root ? ( +
+ {form.formState.errors.root.message ?? "An error occurred"} +
+ ) : null} +
+ +
+ + Create grant + +
+ + +
+
+ ); +} diff --git a/frontend/app/(dashboard)/equity/grants/page.tsx b/frontend/app/(dashboard)/equity/grants/page.tsx index 1ec02b2a4f..53df69fc55 100644 --- a/frontend/app/(dashboard)/equity/grants/page.tsx +++ b/frontend/app/(dashboard)/equity/grants/page.tsx @@ -3,6 +3,7 @@ import { CircleAlert, CircleCheck, Info, Pencil } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; +import NewEquityGrantModal from "@/app/(dashboard)/equity/grants/NewEquityGrantModal"; import { DashboardHeader } from "@/components/DashboardHeader"; import DataTable, { createColumnHelper, useTable } from "@/components/DataTable"; import { linkClasses } from "@/components/Link"; @@ -32,6 +33,7 @@ export default function GrantsPage() { const company = useCurrentCompany(); const { data = [], isLoading, refetch } = trpc.equityGrants.list.useQuery({ companyId: company.id }); const [cancellingGrantId, setCancellingGrantId] = useState(null); + const [showNewGrantModal, setShowNewGrantModal] = useState(false); const cancellingGrant = data.find((grant) => grant.id === cancellingGrantId); const cancelGrant = trpc.equityGrants.cancel.useMutation({ onSuccess: () => { @@ -84,11 +86,9 @@ export default function GrantsPage() { title="Equity grants" headerActions={ equityPlanContractTemplates.length > 0 ? ( - ) : null } @@ -167,6 +167,7 @@ export default function GrantsPage() { ) : null} + ); } diff --git a/frontend/trpc/routes/equityGrants.ts b/frontend/trpc/routes/equityGrants.ts index 3579ced5e1..44a63e2b0f 100644 --- a/frontend/trpc/routes/equityGrants.ts +++ b/frontend/trpc/routes/equityGrants.ts @@ -342,6 +342,7 @@ export const equityGrantsRouter = createRouter({ }; }), defaultVestingSchedules, + sharePriceUsd: ctx.company.fmvPerShareInUsd, }; }), cancel: companyProcedure.input(z.object({ id: z.string(), reason: z.string() })).mutation(async ({ input, ctx }) => {