diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx index 249dae9bc..a337cd558 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/captable/page.tsx @@ -1,21 +1,93 @@ +import CaptableMatrix from "@/components/captable/captable-matrix"; +import ConvertiblesCard from "@/components/captable/convertibles-card"; import EmptyState from "@/components/common/empty-state"; +import OverviewCard from "@/components/dashboard/overview/top-card"; import { Button } from "@/components/ui/button"; +import { UnAuthorizedState } from "@/components/ui/un-authorized-state"; +import { serverAccessControl } from "@/lib/rbac/access-control"; +import { withServerComponentSession } from "@/server/auth"; +import { getCapTable } from "@/server/captable"; import { RiPieChartFill } from "@remixicon/react"; import type { Metadata } from "next"; +import Link from "next/link"; export const metadata: Metadata = { title: "Cap table", }; -const CaptablePage = () => { +const CaptablePage = async ({ + params: { publicId }, +}: { + params: { publicId: string }; +}) => { + const { allow } = await serverAccessControl(); + + const canView = allow(true, ["stakeholder", "read"]); + + if (!canView) { + return ; + } + + const session = await withServerComponentSession(); + const companyId = session?.user?.companyId; + + const capTable = companyId ? await getCapTable(companyId) : null; + + if (!capTable || capTable.summary.isEmpty) { + return ( + } + title="Your cap table is empty" + subtitle="Issue shares, grant options or record fundraising to see your ownership breakdown." + > + + + ); + } + return ( - } - title="Work in progress." - subtitle="This page is not yet available." - > - - +
+
+

Cap table

+

+ Fully-diluted ownership of your company by stakeholder +

+
+ +
+
+ + + +
+
+ + + + +
); }; diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/page.tsx index 6e552721a..d8993f097 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/page.tsx @@ -1,22 +1,33 @@ import ActivitiesCard from "@/components/dashboard/overview/activities-card"; import DonutCard from "@/components/dashboard/overview/donut-card"; +import EmptyOverview from "@/components/dashboard/overview/empty"; import SummaryTable from "@/components/dashboard/overview/summary-table"; import OverviewCard from "@/components/dashboard/overview/top-card"; +import { withServerComponentSession } from "@/server/auth"; +import { getOverviewData } from "@/server/overview"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Overview", }; -const OverviewPage = ({ +const OverviewPage = async ({ params: { publicId }, }: { params: { publicId: string }; }) => { + const session = await withServerComponentSession(); + const companyId = session?.user?.companyId; + const firstName = session?.user?.name?.split(" ")[0]; + + const overview = companyId ? await getOverviewData(companyId) : null; + + if (!overview || overview.isEmpty) { + return ; + } + return ( <> - {/* */} -

Overview

@@ -31,17 +42,27 @@ const OverviewPage = ({

- - + +
{/* Tremor chart */}
- +
@@ -59,7 +80,10 @@ const OverviewPage = ({ Summary of your company{`'`}s captable

- + ); diff --git a/src/app/(authenticated)/(dashboard)/[publicId]/reports/page.tsx b/src/app/(authenticated)/(dashboard)/[publicId]/reports/page.tsx index f45fa9c03..2d763b1ad 100644 --- a/src/app/(authenticated)/(dashboard)/[publicId]/reports/page.tsx +++ b/src/app/(authenticated)/(dashboard)/[publicId]/reports/page.tsx @@ -1,21 +1,73 @@ -import EmptyState from "@/components/common/empty-state"; -import { Button } from "@/components/ui/button"; -import { RiFilePdf2Fill } from "@remixicon/react"; +import ReportCard from "@/components/reports/report-card"; +import { UnAuthorizedState } from "@/components/ui/un-authorized-state"; +import { serverAccessControl } from "@/lib/rbac/access-control"; import type { Metadata } from "next"; export const metadata: Metadata = { title: "Reports", }; -const ReportsPage = () => { +const reports = [ + { + type: "captable-summary-pdf", + title: "Cap table summary", + description: + "Printable cap table: summary numbers, share classes and fully-diluted ownership by stakeholder.", + format: "PDF", + }, + { + type: "captable-csv", + title: "Cap table matrix", + description: + "Fully-diluted ownership by stakeholder and share class, as a spreadsheet.", + format: "CSV", + }, + { + type: "securities-ledger-csv", + title: "Securities ledger", + description: + "Every share certificate and option grant with quantities, prices, dates and statuses.", + format: "CSV", + }, + { + type: "stakeholders-csv", + title: "Stakeholders", + description: + "All stakeholders with their contact details, type and relationship.", + format: "CSV", + }, +] as const; + +const ReportsPage = async () => { + const { allow } = await serverAccessControl(); + + const canView = allow(true, ["stakeholder", "read"]); + + if (!canView) { + return ; + } + return ( - } - title="No reports available." - subtitle="Please click the button below to generate a report" - > - - +
+
+

Reports

+

+ Download reports generated from your company{`'`}s cap table +

+
+ +
+ {reports.map((report) => ( + + ))} +
+
); }; diff --git a/src/app/api/(internal)/reports/[type]/route.ts b/src/app/api/(internal)/reports/[type]/route.ts new file mode 100644 index 000000000..600ce25c5 --- /dev/null +++ b/src/app/api/(internal)/reports/[type]/route.ts @@ -0,0 +1,44 @@ +import { RBAC } from "@/lib/rbac"; +import { getPermissions } from "@/lib/rbac/access-control"; +import { getServerAuthSession } from "@/server/auth"; +import { db } from "@/server/db"; +import { REPORTS, isReportType } from "@/server/reports"; + +export const GET = async ( + _req: Request, + { params }: { params: { type: string } }, +) => { + const session = await getServerAuthSession(); + + if (!session?.user?.companyId) { + return new Response("Unauthorized", { status: 401 }); + } + + const { err, val } = await getPermissions({ db, session }); + + if (err) { + return new Response("Forbidden", { status: 403 }); + } + + const rbac = new RBAC(); + rbac.addPolicies({ stakeholder: { allow: ["read"] } }); + const enforced = rbac.enforce(val.permissions); + + if (enforced.err || !enforced.val.valid) { + return new Response("Forbidden", { status: 403 }); + } + + if (!isReportType(params.type)) { + return new Response("Not found", { status: 404 }); + } + + const report = await REPORTS[params.type].generate(val.membership.companyId); + + return new Response(report.body, { + headers: { + "Content-Type": report.contentType, + "Content-Disposition": `attachment; filename="${report.filename}"`, + "Cache-Control": "no-store", + }, + }); +}; diff --git a/src/components/captable/captable-matrix.tsx b/src/components/captable/captable-matrix.tsx new file mode 100644 index 000000000..54614a788 --- /dev/null +++ b/src/components/captable/captable-matrix.tsx @@ -0,0 +1,166 @@ +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { Card } from "@/components/ui/card"; +import type { + CapTablePool, + CapTableRow, + CapTableShareClass, +} from "@/server/captable"; + +const formatter = new Intl.NumberFormat("en-US"); + +const getRelationshipLabel = (relationship: string) => { + switch (relationship) { + case "ADVISOR": + return "Advisor"; + case "BOARD_MEMBER": + return "Board member"; + case "CONSULTANT": + return "Consultant"; + case "EMPLOYEE": + return "Employee"; + case "EX_ADVISOR": + return "Ex Advisor"; + case "EX_CONSULTANT": + return "Ex Consultant"; + case "EX_EMPLOYEE": + return "Ex Employee"; + case "EXECUTIVE": + return "Executive"; + case "FOUNDER": + return "Founder"; + case "INVESTOR": + return "Investor"; + case "NON_US_EMPLOYEE": + return "Non us employee"; + case "OFFICER": + return "Officer"; + default: + return "Other"; + } +}; + +const quantity = (value: number) => (value > 0 ? formatter.format(value) : "—"); + +type CaptableMatrixProps = { + shareClasses: CapTableShareClass[]; + rows: CapTableRow[]; + pool: CapTablePool; + totals: { + sharesTotal: number; + optionsTotal: number; + fullyDilutedTotal: number; + }; +}; + +const CaptableMatrix = ({ + shareClasses, + rows, + pool, + totals, +}: CaptableMatrixProps) => { + const showPoolRow = pool.available > 0; + const columnCount = shareClasses.length + 5; + + return ( + + + + + Stakeholder + Relationship + {shareClasses.map((shareClass) => ( + + {shareClass.name} + + ))} + Options + Fully diluted + Ownership + + + + {rows.length === 0 && !showPoolRow && ( + + + No equity issued yet + + + )} + + {rows.map((row) => ( + + {row.name} + + {getRelationshipLabel(row.relationship)} + + {shareClasses.map((shareClass) => ( + + {quantity(row.sharesByClassId[shareClass.id] ?? 0)} + + ))} + + {quantity(row.optionsTotal)} + + + {formatter.format(row.fullyDilutedTotal)} + + {row.fdPercent} % + + ))} + + {showPoolRow && ( + + Equity plan pool + + Available for grants + + {shareClasses.map((shareClass) => ( + + — + + ))} + + + {formatter.format(pool.available)} + + {pool.fdPercent} % + + )} + + + + Total + {shareClasses.map((shareClass) => ( + + {quantity(shareClass.issued)} + + ))} + + {quantity(totals.optionsTotal)} + + + {formatter.format(totals.fullyDilutedTotal)} + + + {totals.fullyDilutedTotal > 0 ? "100 %" : "—"} + + + +
+
+ ); +}; + +export default CaptableMatrix; diff --git a/src/components/captable/convertibles-card.tsx b/src/components/captable/convertibles-card.tsx new file mode 100644 index 000000000..8319dac01 --- /dev/null +++ b/src/components/captable/convertibles-card.tsx @@ -0,0 +1,111 @@ +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { dayjsExt } from "@/common/dayjs"; +import { Card } from "@/components/ui/card"; +import type { CapTableConvertible } from "@/server/captable"; + +const formatter = new Intl.NumberFormat("en-US"); + +const getStatusClasses = (status: string) => { + switch (status) { + case "ACTIVE": + return "bg-green-50 text-green-600 ring-green-600/20"; + case "DRAFT": + return "bg-yellow-50 text-yellow-600 ring-yellow-600/20"; + case "PENDING": + return "bg-gray-50 text-gray-600 ring-gray-600/20"; + default: + return "bg-gray-50 text-gray-600 ring-gray-600/20"; + } +}; + +const StatusBadge = ({ status }: { status: string }) => ( + + {status.charAt(0) + status.slice(1).toLowerCase()} + +); + +type ConvertiblesCardProps = { + safes: CapTableConvertible[]; + notes: CapTableConvertible[]; + totalCapital: number; +}; + +const ConvertiblesCard = ({ + safes, + notes, + totalCapital, +}: ConvertiblesCardProps) => { + const instruments = [ + ...safes.map((safe) => ({ ...safe, kind: "SAFE" })), + ...notes.map((note) => ({ ...note, kind: "Convertible note" })), + ]; + + if (instruments.length === 0) { + return null; + } + + return ( +
+

Convertible instruments

+

+ SAFEs and convertible notes that have not converted into equity yet +

+ + + + + + Type + ID + Holder + Status + Issue date + Capital + + + + {instruments.map((instrument) => ( + + {instrument.kind} + {instrument.publicId} + {instrument.stakeholderName} + + + + + {dayjsExt(instrument.issueDate).format("ll")} + + + $ {formatter.format(instrument.capital)} + + + ))} + + + + Total + + $ {formatter.format(totalCapital)} + + + +
+
+
+ ); +}; + +export default ConvertiblesCard; diff --git a/src/components/dashboard/overview/donut-card.tsx b/src/components/dashboard/overview/donut-card.tsx index 82289a90f..23398316f 100644 --- a/src/components/dashboard/overview/donut-card.tsx +++ b/src/components/dashboard/overview/donut-card.tsx @@ -12,7 +12,17 @@ type DonutTooltipProps = { value: number; }; -const DonutCard = () => { +type OwnershipSlice = { + key: string; + value: number; +}; + +type DonutCardProps = { + stakeholders: OwnershipSlice[]; + shareClasses: OwnershipSlice[]; +}; + +const DonutCard = ({ stakeholders, shareClasses }: DonutCardProps) => { const [isClient, setIsClient] = useState(false); const [selected, setSelected] = useState("stakeholder"); @@ -20,54 +30,7 @@ const DonutCard = () => { setIsClient(true); }, []); - const shareClasses = [ - { - key: "Common shares", - value: 53, - }, - - { - key: "Preferred (Series A)", - value: 15, - }, - - { - key: "Preferred (Convertible note)", - value: 7, - }, - - { - key: "Stock Plan", - value: 15, - }, - ]; - - const stakeholders = [ - { - key: "Dennis Shelton", - value: 27, - }, - - { - key: "Camila Murphy", - value: 25, - }, - - { - key: "Others", - value: 18, - }, - - { - key: "Equity Plan", - value: 15, - }, - - { - key: "Acme Ventures", - value: 10, - }, - ]; + const data = selected === "stakeholder" ? stakeholders : shareClasses; return ( @@ -83,56 +46,49 @@ const DonutCard = () => { -
- -
    - {selected === "stakeholder" - ? stakeholders.map((stakeholder) => ( -
  • - {stakeholder.key} - {stakeholder.value}% -
  • - )) - : shareClasses.map((stakeholder) => ( -
  • - {stakeholder.key} - {stakeholder.value}% -
  • - ))} -
-
- - { - if (Array.isArray(payload) && payload.length > 0) { - const data = payload[0] as DonutTooltipProps; - - return ( -
-

- {data.name}:{" "} - {data.value}% -

-
- ); - } - - return null; - }} - /> -
+ {data.length === 0 ? ( +
+ No ownership data to show yet +
+ ) : ( +
+ +
    + {data.map((slice) => ( +
  • + {slice.key} + {slice.value}% +
  • + ))} +
+
+ + { + if (Array.isArray(payload) && payload.length > 0) { + const data = payload[0] as DonutTooltipProps; + + return ( +
+

+ {data.name}:{" "} + {data.value}% +

+
+ ); + } + + return null; + }} + /> +
+ )}
)} diff --git a/src/components/dashboard/overview/empty.tsx b/src/components/dashboard/overview/empty.tsx index 84329ef4a..cc1b18d23 100644 --- a/src/components/dashboard/overview/empty.tsx +++ b/src/components/dashboard/overview/empty.tsx @@ -12,7 +12,7 @@ const EmptyOverview = ({ firstName, publicCompanyId }: EmptyOverviewProps) => { return ( } - title={`Welcome to Captable, Inc. ${firstName && `, ${firstName}`} 👋`} + title={`Welcome to Captable, Inc.${firstName ? `, ${firstName}` : ""} 👋`} subtitle={ We will get you setup with your Captable in no time. diff --git a/src/components/dashboard/overview/summary-table.tsx b/src/components/dashboard/overview/summary-table.tsx index 96a110d56..4cdf821f6 100644 --- a/src/components/dashboard/overview/summary-table.tsx +++ b/src/components/dashboard/overview/summary-table.tsx @@ -11,45 +11,21 @@ import { import { Card } from "@/components/ui/card"; const formatter = new Intl.NumberFormat("en-US"); -const SummaryTable = () => { - const shareClasses = [ - { - id: 1, - name: "Common shares", - shares: 7000000, - diluted: 4500000, - ownership: 53, - raised: 10000000, - }, - - { - id: 2, - name: "Preferred (Series A)", - shares: 2000000, - diluted: 1500000, - ownership: 15, - raised: 18000000, - }, - - { - id: 3, - name: "Preferred (Convertible note)", - shares: 1000000, - diluted: 500000, - ownership: 7, - raised: 7000000, - }, +type SummaryRow = { + id: string; + name: string; + authorized: number; + diluted: number; + ownership: number; + raised: number; +}; - { - id: 4, - name: "Stock Plan", - shares: 2000000, - diluted: 1000000, - ownership: 15, - raised: 2000000, - }, - ]; +type SummaryTableProps = { + rows: SummaryRow[]; + totalRaised: number; +}; +const SummaryTable = ({ rows, totalRaised }: SummaryTableProps) => { return ( @@ -63,10 +39,10 @@ const SummaryTable = () => { - {shareClasses.map((klass) => ( + {rows.map((klass) => ( {klass.name} - {formatter.format(klass.shares)} + {formatter.format(klass.authorized)} {formatter.format(klass.diluted)} {formatter.format(klass.ownership)} % @@ -79,7 +55,7 @@ const SummaryTable = () => { Total - $ {formatter.format(55000000)} + $ {formatter.format(totalRaised)} diff --git a/src/components/modals/issue-share-modal.tsx b/src/components/modals/issue-share-modal.tsx index 3c00a446d..3b9db9af5 100644 --- a/src/components/modals/issue-share-modal.tsx +++ b/src/components/modals/issue-share-modal.tsx @@ -8,6 +8,7 @@ import { } from "@/components/ui/stepper"; import { AddShareFormProvider } from "@/providers/add-share-form-provider"; import { api } from "@/trpc/react"; +import type { TypeZodAddShareMutationSchema } from "@/trpc/routers/securities-router/schema"; import type { RouterOutputs } from "@/trpc/shared"; import { ContributionDetails } from "../securities/shares/steps/contribution-details"; import { Documents } from "../securities/shares/steps/documents"; @@ -33,12 +34,15 @@ type IssueShareModalProps = Omit & { shouldClientFetch: boolean; stakeholders: TStakeholders | []; shareClasses: TShareClasses | []; + // When provided, the wizard opens in edit mode pre-filled with this share. + defaultValues?: Partial; }; export const IssueShareModal = ({ shouldClientFetch, stakeholders, shareClasses, + defaultValues, ...rest }: IssueShareModalProps) => { const _stakeholders = api.stakeholder.getStakeholders.useQuery(undefined, { @@ -58,7 +62,7 @@ export const IssueShareModal = ({ return ( - + diff --git a/src/components/reports/report-card.tsx b/src/components/reports/report-card.tsx new file mode 100644 index 000000000..0fd2ff8a1 --- /dev/null +++ b/src/components/reports/report-card.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { RiFileDownloadLine } from "@remixicon/react"; +import { useState } from "react"; +import { toast } from "sonner"; + +type ReportCardProps = { + title: string; + description: string; + format: "PDF" | "CSV"; + type: string; +}; + +const ReportCard = ({ title, description, format, type }: ReportCardProps) => { + const [loading, setLoading] = useState(false); + + const onDownload = async () => { + setLoading(true); + + try { + const response = await fetch(`/api/reports/${type}`); + + if (!response.ok) { + toast.error("Failed to generate the report, please try again."); + return; + } + + const header = response.headers.get("Content-Disposition") ?? ""; + const filename = + /filename="([^"]+)"/.exec(header)?.[1] ?? + `${type}.${format.toLowerCase()}`; + + const url = URL.createObjectURL(await response.blob()); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + } catch { + toast.error("Failed to generate the report, please try again."); + } finally { + setLoading(false); + } + }; + + return ( + + +
+ {title} + + {format} + +
+ {description} +
+ + + +
+ ); +}; + +export default ReportCard; diff --git a/src/components/securities/shares/share-table.tsx b/src/components/securities/shares/share-table.tsx index d511337ab..f0d5355bf 100644 --- a/src/components/securities/shares/share-table.tsx +++ b/src/components/securities/shares/share-table.tsx @@ -26,6 +26,7 @@ import { DataTableHeader } from "@/components/ui/data-table/data-table-header"; import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; import type { RouterOutputs } from "@/trpc/shared"; +import { pushModal } from "@/components/modals"; import { Button } from "@/components/ui/button"; import { SortButton } from "@/components/ui/data-table/data-table-buttons"; import { @@ -299,7 +300,7 @@ export const columns: ColumnDef[] = [ const deleteShareMutation = api.securities.deleteShare.useMutation({ onSuccess: () => { - toast.success("🎉 Successfully deleted the stakeholder"); + toast.success("🎉 Successfully deleted the share"); router.refresh(); }, onError: () => { @@ -310,6 +311,41 @@ export const columns: ColumnDef[] = [ const updateAction = "Update Share"; const deleteAction = "Delete Share"; + const handleUpdateShare = () => { + pushModal("IssueShareModal", { + shouldClientFetch: true, + title: "Update share", + subtitle: "Edit the details of this share.", + stakeholders: [], + shareClasses: [], + defaultValues: { + id: share.id, + stakeholderId: share.stakeholderId, + shareClassId: share.shareClassId, + certificateId: share.certificateId, + quantity: share.quantity, + pricePerShare: share.pricePerShare ?? 0, + capitalContribution: share.capitalContribution ?? 0, + ipContribution: share.ipContribution ?? 0, + debtCancelled: share.debtCancelled ?? 0, + otherContributions: share.otherContributions ?? 0, + status: share.status, + cliffYears: share.cliffYears, + vestingYears: share.vestingYears, + companyLegends: share.companyLegends, + issueDate: dayjsExt(share.issueDate).format("YYYY-MM-DD"), + rule144Date: dayjsExt(share.rule144Date).format("YYYY-MM-DD"), + vestingStartDate: dayjsExt(share.vestingStartDate).format( + "YYYY-MM-DD", + ), + boardApprovalDate: dayjsExt(share.boardApprovalDate).format( + "YYYY-MM-DD", + ), + documents: [], + }, + }); + }; + const handleDeleteShare = async () => { await deleteShareMutation.mutateAsync({ shareId: share.id }); }; @@ -332,7 +368,9 @@ export const columns: ColumnDef[] = [ Actions - {updateAction} + + {updateAction} + ; export const ContributionDetails = ({ stakeholders = [], }: ContributionDetailsProps) => { - const form = useForm({ resolver: zodResolver(formSchema) }); const { next } = useStepper(); - const { setValue } = useAddShareFormValues(); + const { setValue, value } = useAddShareFormValues(); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + stakeholderId: value.stakeholderId, + capitalContribution: value.capitalContribution, + ipContribution: value.ipContribution, + debtCancelled: value.debtCancelled, + otherContributions: value.otherContributions, + }, + }); const handleSubmit = (data: TFormSchema) => { console.log({ data }); diff --git a/src/components/securities/shares/steps/documents.tsx b/src/components/securities/shares/steps/documents.tsx index 3a8311b38..3fdcad092 100644 --- a/src/components/securities/shares/steps/documents.tsx +++ b/src/components/securities/shares/steps/documents.tsx @@ -26,6 +26,7 @@ export const Documents = () => { const { value } = useAddShareFormValues(); const { reset } = useStepper(); const [documentsList, setDocumentsList] = useState([]); + const isEdit = Boolean(value.id); const { mutateAsync: handleBucketUpload } = api.bucket.create.useMutation(); const { mutateAsync: addShareMutation } = api.securities.addShares.useMutation({ @@ -40,6 +41,21 @@ export const Documents = () => { } }, }); + const { mutateAsync: updateShareMutation } = + api.securities.updateShares.useMutation({ + onSuccess: ({ success, message }) => { + invariant(session, "session not found"); + if (success) { + toast.success(message ?? "Successfully updated the share."); + router.refresh(); + reset(); + } else { + toast.error( + message ?? "Failed updating the share. Please try again.", + ); + } + }, + }); const handleComplete = async () => { invariant(session, "session not found"); const uploadedDocuments: { name: string; bucketId: string }[] = []; @@ -57,7 +73,16 @@ export const Documents = () => { }); uploadedDocuments.push({ bucketId, name: docName }); } - await addShareMutation({ ...value, documents: uploadedDocuments }); + if (isEdit) { + invariant(value.id, "share id is required for update"); + await updateShareMutation({ + ...value, + id: value.id, + documents: uploadedDocuments, + }); + } else { + await addShareMutation({ ...value, documents: uploadedDocuments }); + } }; return (
@@ -83,6 +108,14 @@ export const Documents = () => { You can submit the form to proceed. + ) : isEdit ? ( + + No new documents + + Existing documents will be kept. Upload more only if needed, then + update the share. + + ) : ( 0 document uploaded @@ -96,10 +129,10 @@ export const Documents = () => { Back diff --git a/src/components/securities/shares/steps/general-details.tsx b/src/components/securities/shares/steps/general-details.tsx index b03be7edb..e7cf71dc8 100644 --- a/src/components/securities/shares/steps/general-details.tsx +++ b/src/components/securities/shares/steps/general-details.tsx @@ -74,11 +74,21 @@ interface GeneralDetailsProps { } export const GeneralDetails = ({ shareClasses = [] }: GeneralDetailsProps) => { + const { next } = useStepper(); + const { setValue, value } = useAddShareFormValues(); const form: UseFormReturn = useForm({ resolver: zodResolver(formSchema), + defaultValues: { + certificateId: value.certificateId, + shareClassId: value.shareClassId, + status: value.status, + quantity: value.quantity, + cliffYears: value.cliffYears, + vestingYears: value.vestingYears, + companyLegends: value.companyLegends ?? [], + pricePerShare: value.pricePerShare, + }, }); - const { next } = useStepper(); - const { setValue } = useAddShareFormValues(); const status = Object.values(SecuritiesStatusEnum); const companyLegends = Object.values(ShareLegendsEnum); diff --git a/src/components/securities/shares/steps/relevant-dates.tsx b/src/components/securities/shares/steps/relevant-dates.tsx index e1553f3d4..992087b9d 100644 --- a/src/components/securities/shares/steps/relevant-dates.tsx +++ b/src/components/securities/shares/steps/relevant-dates.tsx @@ -30,9 +30,17 @@ const formSchema = z.object({ type TFormSchema = z.infer; export const RelevantDates = () => { - const form = useForm({ resolver: zodResolver(formSchema) }); const { next } = useStepper(); - const { setValue } = useAddShareFormValues(); + const { setValue, value } = useAddShareFormValues(); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + issueDate: value.issueDate, + vestingStartDate: value.vestingStartDate, + boardApprovalDate: value.boardApprovalDate, + rule144Date: value.rule144Date, + }, + }); const handleSubmit = (data: TFormSchema) => { setValue(data); diff --git a/src/components/stakeholder/stakeholder-table.tsx b/src/components/stakeholder/stakeholder-table.tsx index 5d72cacb8..a4a81dd1d 100644 --- a/src/components/stakeholder/stakeholder-table.tsx +++ b/src/components/stakeholder/stakeholder-table.tsx @@ -9,6 +9,7 @@ import { SortButton } from "@/components/ui/data-table/data-table-buttons"; import { DataTableContent } from "@/components/ui/data-table/data-table-content"; import { DataTableHeader } from "@/components/ui/data-table/data-table-header"; import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination"; +import { api } from "@/trpc/react"; import type { RouterOutputs } from "@/trpc/shared"; import { RiMore2Fill } from "@remixicon/react"; import { @@ -24,7 +25,9 @@ import { getSortedRowModel, useReactTable, } from "@tanstack/react-table"; +import { useRouter } from "next/navigation"; import React from "react"; +import { toast } from "sonner"; import { Allow } from "../rbac/allow"; import { Button } from "../ui/button"; import { @@ -185,6 +188,37 @@ export const columns: ColumnDef[] = [ enableHiding: false, cell: ({ row }) => { const singleStakeholder = row.original; + // eslint-disable-next-line react-hooks/rules-of-hooks + const router = useRouter(); + const deleteStakeholderMutation = + // eslint-disable-next-line react-hooks/rules-of-hooks + api.stakeholder.deleteStakeholder.useMutation({ + onSuccess: ({ success, message }) => { + if (success) { + toast.success(message); + } else { + toast.error(message); + } + router.refresh(); + }, + onError: () => { + toast.error("Failed removing the investor. Please try again."); + }, + }); + + const handleDeleteStakeholder = async () => { + if ( + !window.confirm( + `Remove ${singleStakeholder.name}? This can't be undone.`, + ) + ) { + return; + } + await deleteStakeholderMutation.mutateAsync({ + id: singleStakeholder.id, + }); + }; + return ( @@ -210,6 +244,15 @@ export const columns: ColumnDef[] = [ Edit + + + + Delete + + ); diff --git a/src/pdf-templates/captable-template.tsx b/src/pdf-templates/captable-template.tsx new file mode 100644 index 000000000..06c1ed3a1 --- /dev/null +++ b/src/pdf-templates/captable-template.tsx @@ -0,0 +1,487 @@ +import { dayjsExt } from "@/common/dayjs"; +import type { CapTable } from "@/server/captable"; + +import { + Document, + Font, + Page, + StyleSheet, + Text, + View, +} from "@react-pdf/renderer"; + +Font.register({ + family: "Oswald", + src: "https://fonts.gstatic.com/s/oswald/v13/Y_TKV6o8WovbUd3m_X9aAA.ttf", +}); + +const styles = StyleSheet.create({ + body: { + paddingTop: 35, + paddingBottom: 65, + paddingHorizontal: 35, + }, + headerContainer: { + flexDirection: "row", + width: "100%", + alignItems: "center", + justifyContent: "space-between", + }, + headerText: { + fontSize: 16, + textAlign: "right", + fontFamily: "Oswald", + }, + companyName: { + fontSize: 14, + }, + muted: { + fontSize: 9, + color: "#71717a", + }, + divider: { + width: "100%", + height: 1, + backgroundColor: "#e4e4e7", + margin: "10 0", + }, + sectionTitle: { + fontSize: 12, + marginTop: 14, + marginBottom: 6, + }, + summaryContainer: { + flexDirection: "row", + width: "100%", + }, + summaryColumn: { + flex: 1, + paddingRight: 10, + }, + summaryLabel: { + fontSize: 9, + color: "#71717a", + marginBottom: 2, + }, + summaryValue: { + fontSize: 12, + }, + table: { + display: "flex", + width: "auto", + }, + tableRow: { + flexDirection: "row", + borderBottomWidth: 0.5, + borderBottomColor: "#e4e4e7", + paddingVertical: 4, + }, + totalRow: { + flexDirection: "row", + paddingVertical: 4, + borderTopWidth: 1, + borderTopColor: "#a1a1aa", + }, +}); + +const formatter = new Intl.NumberFormat("en-US"); +const money = (value: number) => `$ ${formatter.format(value)}`; + +type CellProps = { + width: string; + value: string; + fontSize: number; + align?: "left" | "right"; + bold?: boolean; +}; + +const Cell = ({ width, value, fontSize, align = "left", bold }: CellProps) => ( + + + {value} + + +); + +export interface CapTableTemplateProps { + company: { + name: string; + streetAddress: string; + city: string; + state: string; + zipcode: string; + country: string; + }; + capTable: CapTable; + generatedAt: Date; +} + +export function CapTableTemplate({ + company, + capTable, + generatedAt, +}: CapTableTemplateProps) { + const { shareClasses, rows, pool, totals, convertibles, summary } = capTable; + + // Matrix column widths: stakeholder, relationship, one per class, options, FD, % + const matrixFontSize = shareClasses.length > 6 ? 7 : 9; + const nameWidth = 18; + const relationshipWidth = 12; + const numericColumns = shareClasses.length + 3; + const numericWidth = (100 - nameWidth - relationshipWidth) / numericColumns; + const nw = `${nameWidth}%`; + const rw = `${relationshipWidth}%`; + const cw = `${numericWidth}%`; + + const generatedLine = `Generated on ${dayjsExt(generatedAt).format("ll")}`; + + return ( + + {/* Page 1 — summary */} + + + + {company.name} + + {company.streetAddress}, {company.city}, {company.state}{" "} + {company.zipcode}, {company.country} + + + + Cap table + {generatedLine} + + + + + + + + Fully-diluted shares + + {formatter.format(summary.fullyDilutedShares)} + + + + Amount raised + + {money(summary.amountRaised)} + + + + Stakeholders + {summary.stakeholderCount} + + + + Share classes + + + + + + + + + {shareClasses.map((shareClass) => ( + + + + + + + + ))} + {pool.reserved > 0 && ( + + + + + + + + )} + + + {convertibles.safes.length + convertibles.notes.length > 0 && ( + <> + + Convertible instruments (not yet converted) + + + + + + + + + + {[ + ...convertibles.safes.map((safe) => ({ + ...safe, + kind: "SAFE", + })), + ...convertibles.notes.map((note) => ({ + ...note, + kind: "Convertible note", + })), + ].map((instrument) => ( + + + + + + + + ))} + + + + + + + )} + + + {/* Page 2 — fully-diluted ownership matrix */} + + + {company.name} + + Ownership by stakeholder + {generatedLine} + + + + + + + + + + {shareClasses.map((shareClass) => ( + + ))} + + + + + {rows.map((row) => ( + + + + {shareClasses.map((shareClass) => ( + + ))} + + + + ))} + + {pool.available > 0 && ( + + + + {shareClasses.map((shareClass) => ( + + ))} + + + + )} + + + + + {shareClasses.map((shareClass) => ( + + ))} + + + + + + + ); +} diff --git a/src/providers/add-share-form-provider.tsx b/src/providers/add-share-form-provider.tsx index ad15b585d..d2a46cf8f 100644 --- a/src/providers/add-share-form-provider.tsx +++ b/src/providers/add-share-form-provider.tsx @@ -13,6 +13,8 @@ type TFormValue = TypeZodAddShareMutationSchema; interface AddShareFormProviderProps { children: ReactNode; + // When provided, seeds the form for editing an existing share. + defaultValues?: Partial; } const AddShareFormProviderContext = createContext<{ @@ -20,13 +22,16 @@ const AddShareFormProviderContext = createContext<{ setValue: Dispatch>; } | null>(null); -export function AddShareFormProvider({ children }: AddShareFormProviderProps) { +export function AddShareFormProvider({ + children, + defaultValues, +}: AddShareFormProviderProps) { const [value, setValue] = useReducer( (data: TFormValue, partialData: Partial) => ({ ...data, ...partialData, }), - {} as TFormValue, + (defaultValues ?? {}) as TFormValue, ); return ( diff --git a/src/server/captable.ts b/src/server/captable.ts new file mode 100644 index 000000000..077cd66ff --- /dev/null +++ b/src/server/captable.ts @@ -0,0 +1,333 @@ +import { + ConvertibleStatusEnum, + OptionStatusEnum, + SafeStatusEnum, + type StakeholderRelationshipEnum, + type StakeholderTypeEnum, +} from "@prisma/client"; +import { db } from "./db"; + +export const toPercent = (quantity: number, total: number) => + total > 0 ? Math.round((quantity / total) * 10000) / 100 : 0; + +export type CapTableShareClass = { + id: string; + name: string; + idx: number; + prefix: string; + authorized: number; + issued: number; + raised: number; +}; + +export type CapTableRow = { + stakeholderId: string; + name: string; + type: StakeholderTypeEnum; + relationship: StakeholderRelationshipEnum; + sharesByClassId: Record; + sharesTotal: number; + optionsTotal: number; + fullyDilutedTotal: number; + fdPercent: number; + capitalInvested: number; +}; + +export type CapTablePool = { + reserved: number; + granted: number; + available: number; + fdPercent: number; +}; + +export type CapTableConvertible = { + id: string; + publicId: string; + stakeholderName: string; + capital: number; + status: SafeStatusEnum | ConvertibleStatusEnum; + issueDate: Date; +}; + +export type CapTable = { + shareClasses: CapTableShareClass[]; + rows: CapTableRow[]; + pool: CapTablePool; + totals: { + sharesTotal: number; + optionsTotal: number; + fullyDilutedTotal: number; + capitalInvested: number; + }; + convertibles: { + safes: CapTableConvertible[]; + notes: CapTableConvertible[]; + totalCapital: number; + }; + summary: { + fullyDilutedShares: number; + amountRaised: number; + sharesCapital: number; + safeCapital: number; + noteCapital: number; + stakeholderCount: number; + isEmpty: boolean; + }; +}; + +export const getCapTable = async (companyId: string): Promise => { + const [ + shareClasses, + shares, + options, + equityPlans, + safes, + convertibleNotes, + stakeholderCount, + ] = await Promise.all([ + db.shareClass.findMany({ + where: { companyId }, + select: { + id: true, + name: true, + idx: true, + prefix: true, + initialSharesAuthorized: true, + }, + orderBy: { idx: "asc" }, + }), + db.share.findMany({ + where: { companyId }, + select: { + quantity: true, + capitalContribution: true, + shareClassId: true, + stakeholder: { + select: { + id: true, + name: true, + stakeholderType: true, + currentRelationship: true, + }, + }, + }, + }), + db.option.findMany({ + where: { + companyId, + status: { + notIn: [OptionStatusEnum.CANCELLED, OptionStatusEnum.EXPIRED], + }, + }, + select: { + quantity: true, + equityPlanId: true, + stakeholder: { + select: { + id: true, + name: true, + stakeholderType: true, + currentRelationship: true, + }, + }, + }, + }), + db.equityPlan.findMany({ + where: { companyId }, + select: { id: true, initialSharesReserved: true }, + }), + db.safe.findMany({ + where: { + companyId, + status: { notIn: [SafeStatusEnum.CANCELLED, SafeStatusEnum.EXPIRED] }, + }, + select: { + id: true, + publicId: true, + capital: true, + status: true, + issueDate: true, + stakeholder: { select: { name: true } }, + }, + orderBy: { issueDate: "asc" }, + }), + db.convertibleNote.findMany({ + where: { + companyId, + status: { + notIn: [ + ConvertibleStatusEnum.CANCELLED, + ConvertibleStatusEnum.EXPIRED, + ], + }, + }, + select: { + id: true, + publicId: true, + capital: true, + status: true, + issueDate: true, + stakeholder: { select: { name: true } }, + }, + orderBy: { issueDate: "asc" }, + }), + db.stakeholder.count({ where: { companyId } }), + ]); + + const issuedByClass = new Map(); + const raisedByClass = new Map(); + const grantedByPlan = new Map(); + + type Holding = Omit; + const holdingsByStakeholder = new Map(); + + const holdingFor = (stakeholder: { + id: string; + name: string; + stakeholderType: StakeholderTypeEnum; + currentRelationship: StakeholderRelationshipEnum; + }): Holding => { + let holding = holdingsByStakeholder.get(stakeholder.id); + + if (!holding) { + holding = { + stakeholderId: stakeholder.id, + name: stakeholder.name, + type: stakeholder.stakeholderType, + relationship: stakeholder.currentRelationship, + sharesByClassId: {}, + sharesTotal: 0, + optionsTotal: 0, + capitalInvested: 0, + }; + holdingsByStakeholder.set(stakeholder.id, holding); + } + + return holding; + }; + + let issuedShares = 0; + let sharesCapital = 0; + + for (const share of shares) { + const raised = share.capitalContribution ?? 0; + issuedShares += share.quantity; + sharesCapital += raised; + + issuedByClass.set( + share.shareClassId, + (issuedByClass.get(share.shareClassId) ?? 0) + share.quantity, + ); + raisedByClass.set( + share.shareClassId, + (raisedByClass.get(share.shareClassId) ?? 0) + raised, + ); + + const holding = holdingFor(share.stakeholder); + holding.sharesByClassId[share.shareClassId] = + (holding.sharesByClassId[share.shareClassId] ?? 0) + share.quantity; + holding.sharesTotal += share.quantity; + holding.capitalInvested += raised; + } + + let grantedOptions = 0; + + for (const option of options) { + grantedOptions += option.quantity; + + grantedByPlan.set( + option.equityPlanId, + (grantedByPlan.get(option.equityPlanId) ?? 0) + option.quantity, + ); + + holdingFor(option.stakeholder).optionsTotal += option.quantity; + } + + // Shares reserved for equity plans but not granted as options yet + let poolReserved = 0; + let poolAvailable = 0; + + for (const plan of equityPlans) { + const reserved = Number(plan.initialSharesReserved); + poolReserved += reserved; + poolAvailable += Math.max(0, reserved - (grantedByPlan.get(plan.id) ?? 0)); + } + + const fullyDilutedShares = issuedShares + grantedOptions + poolAvailable; + + const safeCapital = safes.reduce((sum, safe) => sum + safe.capital, 0); + const noteCapital = convertibleNotes.reduce( + (sum, note) => sum + note.capital, + 0, + ); + const amountRaised = sharesCapital + safeCapital + noteCapital; + + const rows: CapTableRow[] = Array.from(holdingsByStakeholder.values()) + .map((holding) => { + const fullyDilutedTotal = holding.sharesTotal + holding.optionsTotal; + + return { + ...holding, + fullyDilutedTotal, + fdPercent: toPercent(fullyDilutedTotal, fullyDilutedShares), + }; + }) + .filter((row) => row.fullyDilutedTotal > 0) + .sort((a, b) => b.fullyDilutedTotal - a.fullyDilutedTotal); + + const toConvertible = (instrument: { + id: string; + publicId: string; + capital: number; + status: SafeStatusEnum | ConvertibleStatusEnum; + issueDate: Date; + stakeholder: { name: string }; + }): CapTableConvertible => ({ + id: instrument.id, + publicId: instrument.publicId, + stakeholderName: instrument.stakeholder.name, + capital: instrument.capital, + status: instrument.status, + issueDate: instrument.issueDate, + }); + + return { + shareClasses: shareClasses.map((shareClass) => ({ + id: shareClass.id, + name: shareClass.name, + idx: shareClass.idx, + prefix: shareClass.prefix, + authorized: Number(shareClass.initialSharesAuthorized), + issued: issuedByClass.get(shareClass.id) ?? 0, + raised: raisedByClass.get(shareClass.id) ?? 0, + })), + rows, + pool: { + reserved: poolReserved, + granted: grantedOptions, + available: poolAvailable, + fdPercent: toPercent(poolAvailable, fullyDilutedShares), + }, + totals: { + sharesTotal: issuedShares, + optionsTotal: grantedOptions, + fullyDilutedTotal: fullyDilutedShares, + capitalInvested: sharesCapital, + }, + convertibles: { + safes: safes.map(toConvertible), + notes: convertibleNotes.map(toConvertible), + totalCapital: safeCapital + noteCapital, + }, + summary: { + fullyDilutedShares, + amountRaised, + sharesCapital, + safeCapital, + noteCapital, + stakeholderCount, + // No equity issued, reserved or raised yet — nothing meaningful to chart + isEmpty: fullyDilutedShares === 0 && amountRaised === 0, + }, + }; +}; diff --git a/src/server/overview.ts b/src/server/overview.ts new file mode 100644 index 000000000..dd592b280 --- /dev/null +++ b/src/server/overview.ts @@ -0,0 +1,113 @@ +import { getCapTable, toPercent } from "./captable"; + +export type OwnershipSlice = { + key: string; + value: number; // percentage on a fully-diluted basis +}; + +export type ShareClassSummary = { + id: string; + name: string; + authorized: number; + diluted: number; + ownership: number; // percentage on a fully-diluted basis + raised: number; +}; + +export type OverviewData = { + amountRaised: number; + fullyDilutedShares: number; + stakeholderCount: number; + ownershipByStakeholder: OwnershipSlice[]; + ownershipByShareClass: OwnershipSlice[]; + summary: ShareClassSummary[]; + totalRaised: number; + isEmpty: boolean; +}; + +// Cap the number of donut slices; the long tail is grouped into "Others" +const MAX_STAKEHOLDER_SLICES = 6; + +export const getOverviewData = async ( + companyId: string, +): Promise => { + const capTable = await getCapTable(companyId); + const { fullyDilutedShares } = capTable.summary; + + const ownershipByStakeholder: OwnershipSlice[] = capTable.rows + .slice(0, MAX_STAKEHOLDER_SLICES) + .map((row) => ({ + key: row.name, + value: toPercent(row.fullyDilutedTotal, fullyDilutedShares), + })); + + const othersQuantity = capTable.rows + .slice(MAX_STAKEHOLDER_SLICES) + .reduce((sum, row) => sum + row.fullyDilutedTotal, 0); + + if (othersQuantity > 0) { + ownershipByStakeholder.push({ + key: "Others", + value: toPercent(othersQuantity, fullyDilutedShares), + }); + } + + if (capTable.pool.available > 0) { + ownershipByStakeholder.push({ + key: "Equity plan", + value: capTable.pool.fdPercent, + }); + } + + const ownershipByShareClass: OwnershipSlice[] = capTable.shareClasses + .map((shareClass) => ({ + key: shareClass.name, + value: toPercent(shareClass.issued, fullyDilutedShares), + })) + .filter((slice) => slice.value > 0); + + const stockPlanShares = + capTable.totals.optionsTotal + capTable.pool.available; + + if (stockPlanShares > 0) { + ownershipByShareClass.push({ + key: "Stock plan", + value: toPercent(stockPlanShares, fullyDilutedShares), + }); + } + + const summary: ShareClassSummary[] = capTable.shareClasses.map( + (shareClass) => ({ + id: shareClass.id, + name: shareClass.name, + authorized: shareClass.authorized, + diluted: shareClass.issued, + ownership: toPercent(shareClass.issued, fullyDilutedShares), + raised: shareClass.raised, + }), + ); + + if (stockPlanShares > 0) { + summary.push({ + id: "stock-plan", + name: "Stock plan", + authorized: capTable.pool.reserved, + diluted: stockPlanShares, + ownership: toPercent(stockPlanShares, fullyDilutedShares), + raised: 0, + }); + } + + const totalRaised = summary.reduce((sum, row) => sum + row.raised, 0); + + return { + amountRaised: capTable.summary.amountRaised, + fullyDilutedShares, + stakeholderCount: capTable.summary.stakeholderCount, + ownershipByStakeholder, + ownershipByShareClass, + summary, + totalRaised, + isEmpty: capTable.summary.isEmpty, + }; +}; diff --git a/src/server/reports.ts b/src/server/reports.ts new file mode 100644 index 000000000..e1ea828dc --- /dev/null +++ b/src/server/reports.ts @@ -0,0 +1,298 @@ +import { dayjsExt } from "@/common/dayjs"; +import { CapTableTemplate } from "@/pdf-templates/captable-template"; +import { renderToBuffer } from "@react-pdf/renderer"; +import Papa from "papaparse"; +import { type CapTable, getCapTable } from "./captable"; +import { db } from "./db"; + +export const REPORT_TYPES = [ + "captable-summary-pdf", + "captable-csv", + "securities-ledger-csv", + "stakeholders-csv", +] as const; + +export type TReportType = (typeof REPORT_TYPES)[number]; + +export const isReportType = (type: string): type is TReportType => + (REPORT_TYPES as readonly string[]).includes(type); + +type GeneratedReport = { + body: Buffer | string; + contentType: string; + filename: string; +}; + +const stamp = () => dayjsExt().format("YYYY-MM-DD"); +const date = (value: Date | null | undefined) => + value ? dayjsExt(value).format("YYYY-MM-DD") : ""; + +// Column headers for the matrix CSV; duplicate class names get the +// share class prefix and index appended so columns stay distinguishable +const shareClassHeaders = (shareClasses: CapTable["shareClasses"]) => { + const nameCounts = new Map(); + + for (const shareClass of shareClasses) { + nameCounts.set(shareClass.name, (nameCounts.get(shareClass.name) ?? 0) + 1); + } + + return shareClasses.map((shareClass) => + (nameCounts.get(shareClass.name) ?? 0) > 1 + ? `${shareClass.name} (${shareClass.prefix}-${shareClass.idx})` + : shareClass.name, + ); +}; + +const generateCapTablePdf = async ( + companyId: string, +): Promise => { + const [capTable, company] = await Promise.all([ + getCapTable(companyId), + db.company.findUniqueOrThrow({ + where: { id: companyId }, + select: { + name: true, + streetAddress: true, + city: true, + state: true, + zipcode: true, + country: true, + }, + }), + ]); + + const body = await renderToBuffer( + CapTableTemplate({ company, capTable, generatedAt: new Date() }), + ); + + return { + body, + contentType: "application/pdf", + filename: `captable-summary-${stamp()}.pdf`, + }; +}; + +const generateCapTableCsv = async ( + companyId: string, +): Promise => { + const capTable = await getCapTable(companyId); + const { shareClasses, rows, pool, totals, summary } = capTable; + + const fields = [ + "Stakeholder", + "Type", + "Relationship", + ...shareClassHeaders(shareClasses), + "Options", + "Fully diluted shares", + "Fully diluted %", + "Capital invested", + ]; + + const data = rows.map((row) => [ + row.name, + row.type, + row.relationship, + ...shareClasses.map( + (shareClass) => row.sharesByClassId[shareClass.id] ?? 0, + ), + row.optionsTotal, + row.fullyDilutedTotal, + row.fdPercent, + row.capitalInvested, + ]); + + if (pool.available > 0) { + data.push([ + "Equity plan pool (available)", + "", + "", + ...shareClasses.map(() => 0), + 0, + pool.available, + pool.fdPercent, + 0, + ]); + } + + data.push([ + "Total", + "", + "", + ...shareClasses.map((shareClass) => shareClass.issued), + totals.optionsTotal, + summary.fullyDilutedShares, + summary.fullyDilutedShares > 0 ? 100 : 0, + totals.capitalInvested, + ]); + + return { + body: Papa.unparse({ fields, data }), + contentType: "text/csv", + filename: `captable-${stamp()}.csv`, + }; +}; + +const generateSecuritiesLedgerCsv = async ( + companyId: string, +): Promise => { + const [shares, options] = await Promise.all([ + db.share.findMany({ + where: { companyId }, + select: { + certificateId: true, + quantity: true, + pricePerShare: true, + capitalContribution: true, + status: true, + issueDate: true, + boardApprovalDate: true, + vestingStartDate: true, + stakeholder: { select: { name: true } }, + shareClass: { select: { name: true } }, + }, + orderBy: { issueDate: "asc" }, + }), + db.option.findMany({ + where: { companyId }, + select: { + grantId: true, + quantity: true, + exercisePrice: true, + type: true, + status: true, + issueDate: true, + boardApprovalDate: true, + vestingStartDate: true, + expirationDate: true, + stakeholder: { select: { name: true } }, + equityPlan: { select: { name: true } }, + }, + orderBy: { issueDate: "asc" }, + }), + ]); + + const fields = [ + "Security", + "ID", + "Stakeholder", + "Share class / Plan", + "Quantity", + "Price per share", + "Exercise price", + "Capital contribution", + "Type", + "Status", + "Issue date", + "Board approval date", + "Vesting start date", + "Expiration date", + ]; + + const data = [ + ...shares.map((share) => [ + "Share", + share.certificateId, + share.stakeholder.name, + share.shareClass.name, + share.quantity, + share.pricePerShare ?? "", + "", + share.capitalContribution ?? "", + "", + share.status, + date(share.issueDate), + date(share.boardApprovalDate), + date(share.vestingStartDate), + "", + ]), + ...options.map((option) => [ + "Option", + option.grantId, + option.stakeholder.name, + option.equityPlan.name, + option.quantity, + "", + option.exercisePrice, + "", + option.type, + option.status, + date(option.issueDate), + date(option.boardApprovalDate), + date(option.vestingStartDate), + date(option.expirationDate), + ]), + ]; + + return { + body: Papa.unparse({ fields, data }), + contentType: "text/csv", + filename: `securities-ledger-${stamp()}.csv`, + }; +}; + +const generateStakeholdersCsv = async ( + companyId: string, +): Promise => { + const stakeholders = await db.stakeholder.findMany({ + where: { companyId }, + select: { + name: true, + email: true, + institutionName: true, + stakeholderType: true, + currentRelationship: true, + streetAddress: true, + city: true, + state: true, + zipcode: true, + country: true, + createdAt: true, + }, + orderBy: { createdAt: "asc" }, + }); + + const fields = [ + "Name", + "Email", + "Institution", + "Type", + "Relationship", + "Street address", + "City", + "State", + "Zip code", + "Country", + "Added on", + ]; + + const data = stakeholders.map((stakeholder) => [ + stakeholder.name, + stakeholder.email, + stakeholder.institutionName ?? "", + stakeholder.stakeholderType, + stakeholder.currentRelationship, + stakeholder.streetAddress ?? "", + stakeholder.city ?? "", + stakeholder.state ?? "", + stakeholder.zipcode ?? "", + stakeholder.country, + date(stakeholder.createdAt), + ]); + + return { + body: Papa.unparse({ fields, data }), + contentType: "text/csv", + filename: `stakeholders-${stamp()}.csv`, + }; +}; + +export const REPORTS: Record< + TReportType, + { generate: (companyId: string) => Promise } +> = { + "captable-summary-pdf": { generate: generateCapTablePdf }, + "captable-csv": { generate: generateCapTableCsv }, + "securities-ledger-csv": { generate: generateSecuritiesLedgerCsv }, + "stakeholders-csv": { generate: generateStakeholdersCsv }, +}; diff --git a/src/trpc/routers/securities-router/procedures/get-shares.ts b/src/trpc/routers/securities-router/procedures/get-shares.ts index 678647051..2bed453b9 100644 --- a/src/trpc/routers/securities-router/procedures/get-shares.ts +++ b/src/trpc/routers/securities-router/procedures/get-shares.ts @@ -12,6 +12,8 @@ export const getSharesProcedure = withAuth.query( }, select: { id: true, + stakeholderId: true, + shareClassId: true, certificateId: true, quantity: true, pricePerShare: true, diff --git a/src/trpc/routers/securities-router/procedures/update-share.ts b/src/trpc/routers/securities-router/procedures/update-share.ts new file mode 100644 index 000000000..687f2db6d --- /dev/null +++ b/src/trpc/routers/securities-router/procedures/update-share.ts @@ -0,0 +1,96 @@ +import { generatePublicId } from "@/common/id"; +import { Audit } from "@/server/audit"; +import { checkMembership } from "@/server/auth"; +import { withAuth } from "@/trpc/api/trpc"; +import { ZodUpdateShareMutationSchema } from "../schema"; + +export const updateShareProcedure = withAuth + .input(ZodUpdateShareMutationSchema) + .mutation(async ({ ctx, input }) => { + const { userAgent, requestIp } = ctx; + + try { + const user = ctx.session.user; + const documents = input.documents; + + await ctx.db.$transaction(async (tx) => { + const { companyId } = await checkMembership({ + session: ctx.session, + tx, + }); + + const data = { + stakeholderId: input.stakeholderId, + shareClassId: input.shareClassId, + status: input.status, + certificateId: input.certificateId, + quantity: input.quantity, + pricePerShare: input.pricePerShare, + capitalContribution: input.capitalContribution, + ipContribution: input.ipContribution, + debtCancelled: input.debtCancelled, + otherContributions: input.otherContributions, + cliffYears: input.cliffYears, + vestingYears: input.vestingYears, + companyLegends: input.companyLegends, + issueDate: new Date(input.issueDate), + rule144Date: new Date(input.rule144Date), + vestingStartDate: new Date(input.vestingStartDate), + boardApprovalDate: new Date(input.boardApprovalDate), + }; + + const share = await tx.share.update({ + where: { + id: input.id, + companyId, + }, + data, + select: { id: true }, + }); + + // Only attach newly uploaded documents; existing ones are left untouched. + if (documents.length) { + const bulkDocuments = documents.map((doc) => ({ + companyId, + uploaderId: user.memberId, + publicId: generatePublicId(), + name: doc.name, + bucketId: doc.bucketId, + shareId: share.id, + })); + + await tx.document.createMany({ + data: bulkDocuments, + skipDuplicates: true, + }); + } + + await Audit.create( + { + action: "share.updated", + companyId: user.companyId, + actor: { type: "user", id: user.id }, + context: { + userAgent, + requestIp, + }, + target: [{ type: "share", id: share.id }], + summary: `${user.name} updated share for stakeholder ${input.stakeholderId}`, + }, + tx, + ); + }); + + return { + success: true, + message: "🎉 Successfully updated the share", + }; + } catch (error) { + console.error("Error updating share: ", error); + return { + success: false, + message: + "Failed to update the share. Please use a unique Certificate Id.", + }; + } + }); diff --git a/src/trpc/routers/securities-router/router.ts b/src/trpc/routers/securities-router/router.ts index cf6d1ab1b..bca41010a 100644 --- a/src/trpc/routers/securities-router/router.ts +++ b/src/trpc/routers/securities-router/router.ts @@ -5,6 +5,7 @@ import { deleteOptionProcedure } from "./procedures/delete-option"; import { deleteShareProcedure } from "./procedures/delete-share"; import { getOptionsProcedure } from "./procedures/get-options"; import { getSharesProcedure } from "./procedures/get-shares"; +import { updateShareProcedure } from "./procedures/update-share"; export const securitiesRouter = createTRPCRouter({ getOptions: getOptionsProcedure, @@ -12,5 +13,6 @@ export const securitiesRouter = createTRPCRouter({ deleteOption: deleteOptionProcedure, getShares: getSharesProcedure, addShares: addShareProcedure, + updateShares: updateShareProcedure, deleteShare: deleteShareProcedure, }); diff --git a/src/trpc/routers/securities-router/schema.ts b/src/trpc/routers/securities-router/schema.ts index 0bc188601..d9a242c34 100644 --- a/src/trpc/routers/securities-router/schema.ts +++ b/src/trpc/routers/securities-router/schema.ts @@ -83,3 +83,11 @@ export const ZodDeleteShareMutationSchema = z.object({ export type TypeZodDeleteShareMutationSchema = z.infer< typeof ZodDeleteShareMutationSchema >; + +export const ZodUpdateShareMutationSchema = ZodAddShareMutationSchema.extend({ + id: z.string(), +}); + +export type TypeZodUpdateShareMutationSchema = z.infer< + typeof ZodUpdateShareMutationSchema +>; diff --git a/src/trpc/routers/stakeholder-router/procedures/delete-stakeholder.ts b/src/trpc/routers/stakeholder-router/procedures/delete-stakeholder.ts new file mode 100644 index 000000000..cd3c9fd25 --- /dev/null +++ b/src/trpc/routers/stakeholder-router/procedures/delete-stakeholder.ts @@ -0,0 +1,93 @@ +import { Audit } from "@/server/audit"; +import { checkMembership } from "@/server/auth"; +import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc"; +import { + type TypeZodDeleteStakeholderMutationSchema, + ZodDeleteStakeholderMutationSchema, +} from "../schema"; + +export const deleteStakeholderProcedure = withAuth + .input(ZodDeleteStakeholderMutationSchema) + .mutation(async (args) => { + return await deleteStakeholderHandler(args); + }); + +interface deleteStakeholderHandlerOptions { + input: TypeZodDeleteStakeholderMutationSchema; + ctx: withAuthTrpcContextType; +} + +export async function deleteStakeholderHandler({ + ctx: { db, session, requestIp, userAgent }, + input, +}: deleteStakeholderHandlerOptions) { + const user = session.user; + const { id: stakeholderId } = input; + + try { + return await db.$transaction(async (tx) => { + const { companyId } = await checkMembership({ session, tx }); + + // There are no DB-level foreign keys (Prisma relationMode = "prisma"), so a + // delete will not cascade. Refuse to remove an investor who still holds + // securities, otherwise those rows would be orphaned and break the cap table. + const [shares, options, safes, convertibleNotes, investments] = + await Promise.all([ + tx.share.count({ where: { stakeholderId, companyId } }), + tx.option.count({ where: { stakeholderId, companyId } }), + tx.safe.count({ where: { stakeholderId, companyId } }), + tx.convertibleNote.count({ where: { stakeholderId, companyId } }), + tx.investment.count({ where: { stakeholderId, companyId } }), + ]); + + if (shares + options + safes + convertibleNotes + investments > 0) { + return { + success: false, + message: + "This investor still holds securities (shares, options, SAFEs, notes or investments). Remove those first, then delete the investor.", + }; + } + + // Clean up access-grant links that point at this stakeholder. + await tx.dataRoomRecipient.deleteMany({ where: { stakeholderId } }); + await tx.updateRecipient.deleteMany({ where: { stakeholderId } }); + + const stakeholder = await tx.stakeholder.delete({ + where: { + id: stakeholderId, + companyId, + }, + select: { + id: true, + name: true, + }, + }); + + await Audit.create( + { + action: "stakeholder.deleted", + companyId, + actor: { type: "user", id: user.id }, + context: { + requestIp, + userAgent, + }, + target: [{ type: "stakeholder", id: stakeholder.id }], + summary: `${user.name} removed stakeholder ${stakeholder.name}`, + }, + tx, + ); + + return { + success: true, + message: "Successfully removed the stakeholder", + }; + }); + } catch (err) { + console.error(err); + return { + success: false, + message: "Oops, something went wrong while removing the stakeholder.", + }; + } +} diff --git a/src/trpc/routers/stakeholder-router/router.ts b/src/trpc/routers/stakeholder-router/router.ts index 99140008b..1b97e68b0 100644 --- a/src/trpc/routers/stakeholder-router/router.ts +++ b/src/trpc/routers/stakeholder-router/router.ts @@ -1,6 +1,7 @@ import { createTRPCRouter } from "@/trpc/api/trpc"; import { addStakeholdersProcedure } from "./procedures/add-stakeholders"; +import { deleteStakeholderProcedure } from "./procedures/delete-stakeholder"; import { getStakeholdersProcedure } from "./procedures/get-stakeholders"; import { updateStakeholderProcedure } from "./procedures/update-stakeholder"; @@ -8,4 +9,5 @@ export const stakeholderRouter = createTRPCRouter({ addStakeholders: addStakeholdersProcedure, getStakeholders: getStakeholdersProcedure, updateStakeholder: updateStakeholderProcedure, + deleteStakeholder: deleteStakeholderProcedure, }); diff --git a/src/trpc/routers/stakeholder-router/schema.ts b/src/trpc/routers/stakeholder-router/schema.ts index c1f1d596e..1943075db 100644 --- a/src/trpc/routers/stakeholder-router/schema.ts +++ b/src/trpc/routers/stakeholder-router/schema.ts @@ -40,3 +40,11 @@ export const ZodUpdateStakeholderMutationSchema = export type UpdateStakeholderMutationType = z.infer< typeof ZodUpdateStakeholderMutationSchema >; + +export const ZodDeleteStakeholderMutationSchema = z.object({ + id: z.string(), +}); + +export type TypeZodDeleteStakeholderMutationSchema = z.infer< + typeof ZodDeleteStakeholderMutationSchema +>;