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
+>;