Read the cap table from Hanzo Cloud instead of a second copy of it

The cap table is served at /v1/captable, org-scoped from the caller's own
bearer. This app kept its own Postgres copy of the same entities, so the two
could disagree about who owns what — the reason to move rather than keep both.

The tRPC procedures for share classes, equity plans, shares, options, SAFEs and
stakeholders now call one client (src/server/captable-api.ts) whose normalizers
read either spelling the surface uses and degrade to an honest empty value
rather than throwing inside a render. Each procedure keeps the shape it already
returned, so no component changed for the move.

The bearer rides the encrypted next-auth JWT and is read server-side only; it is
never put on `session`, which is served to the browser verbatim.

Paperwork stays local — buckets, uploaders, e-sign, data rooms are this app's.
Document.shareId/optionId/safeId/convertibleNoteId keep their columns and now
hold the id Hanzo Cloud assigned, so they are no longer foreign keys. Creates
answer no body, so the new row is found by the natural key each write already
enforces unique: certificateId, grantId, publicId.

The public REST routes for shares and stakeholders are removed. They were a
second write path over the same data, they authenticate with an app-issued API
key that carries no IAM principal to present upstream, and one of them declared
the stakeholder path — shadowing the real stakeholder PATCH, so PATCH on a share
never existed. api.hanzo.ai/v1/captable is the public cap-table API.

Two surfaces are left honest rather than faked: equityPlan.update refuses,
because the backend serves no update route for plans; company.getCompany stays
local, because /v1/captable/company carries six fields and that read feeds a
form needing fourteen.
This commit is contained in:
zooqueen
2026-08-14 20:53:16 -07:00
parent 70d599218c
commit 7e9ed42299
54 changed files with 2058 additions and 2773 deletions
@@ -0,0 +1,81 @@
-- The cap table is served by Hanzo Cloud at /v1/captable. These tables held a
-- second copy of it; the columns that pointed at them are kept and now hold the
-- id Hanzo Cloud assigned, so a document stays attached to its security.
-- DropTable
DROP TABLE IF EXISTS "Investment";
-- DropTable
DROP TABLE IF EXISTS "ConvertibleNote";
-- DropTable
DROP TABLE IF EXISTS "Safe";
-- DropTable
DROP TABLE IF EXISTS "Option";
-- DropTable
DROP TABLE IF EXISTS "Share";
-- DropTable
DROP TABLE IF EXISTS "EquityPlan";
-- DropTable
DROP TABLE IF EXISTS "ShareClass";
-- DropTable
DROP TABLE IF EXISTS "Stakeholder";
-- DropEnum
DROP TYPE IF EXISTS "ConvertibleInterestPaymentScheduleEnum";
-- DropEnum
DROP TYPE IF EXISTS "ConvertibleInterestAccrualEnum";
-- DropEnum
DROP TYPE IF EXISTS "ConvertibleInterestMethodEnum";
-- DropEnum
DROP TYPE IF EXISTS "ConvertibleTypeEnum";
-- DropEnum
DROP TYPE IF EXISTS "ConvertibleStatusEnum";
-- DropEnum
DROP TYPE IF EXISTS "SafeTemplateEnum";
-- DropEnum
DROP TYPE IF EXISTS "SafeStatusEnum";
-- DropEnum
DROP TYPE IF EXISTS "SafeTypeEnum";
-- DropEnum
DROP TYPE IF EXISTS "OptionStatusEnum";
-- DropEnum
DROP TYPE IF EXISTS "OptionTypeEnum";
-- DropEnum
DROP TYPE IF EXISTS "ShareLegendsEnum";
-- DropEnum
DROP TYPE IF EXISTS "SecuritiesStatusEnum";
-- DropEnum
DROP TYPE IF EXISTS "CancellationBehaviorEnum";
-- DropEnum
DROP TYPE IF EXISTS "ConversionRightsEnum";
-- DropEnum
DROP TYPE IF EXISTS "SharePrefixEnum";
-- DropEnum
DROP TYPE IF EXISTS "ShareTypeEnum";
-- DropEnum
DROP TYPE IF EXISTS "StakeholderRelationshipEnum";
-- DropEnum
DROP TYPE IF EXISTS "StakeholderTypeEnum";
+17 -423
View File
@@ -139,24 +139,16 @@ model Company {
zipcode String
country String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
updates Update[]
users Member[]
audits Audit[]
shareClasses ShareClass[]
equityPlans EquityPlan[]
documents Document[]
templates Template[]
stakeholders Stakeholder[]
investments Investment[]
shares Share[]
options Option[]
safes Safe[]
convertibleNotes ConvertibleNote[]
dataRooms DataRoom[]
eSignAudits EsignAudit[]
customRoles CustomRole[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
updates Update[]
users Member[]
audits Audit[]
documents Document[]
templates Template[]
dataRooms DataRoom[]
eSignAudits EsignAudit[]
customRoles CustomRole[]
BankAccount BankAccount[]
@@ -252,59 +244,6 @@ model CustomRole {
@@index([companyId])
}
enum StakeholderTypeEnum {
INDIVIDUAL
INSTITUTION
}
enum StakeholderRelationshipEnum {
ADVISOR
BOARD_MEMBER
CONSULTANT
EMPLOYEE
EX_ADVISOR
EX_CONSULTANT
EX_EMPLOYEE
EXECUTIVE
FOUNDER
INVESTOR
NON_US_EMPLOYEE
OFFICER
OTHER
}
model Stakeholder {
id String @id @default(cuid())
name String
email String @unique
institutionName String?
stakeholderType StakeholderTypeEnum @default(INDIVIDUAL)
currentRelationship StakeholderRelationshipEnum @default(EMPLOYEE)
taxId String?
streetAddress String?
city String?
state String?
zipcode String?
country String @default("US")
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
investments Investment[]
shares Share[]
options Option[]
safes Safe[]
convertibleNotes ConvertibleNote[]
updates UpdateRecipient[]
dataRooms DataRoomRecipient[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
}
model Audit {
id String @id @default(cuid())
companyId String
@@ -319,88 +258,6 @@ model Audit {
@@index([companyId])
}
enum ShareTypeEnum {
COMMON
PREFERRED
}
enum SharePrefixEnum {
CS // Common Shares
PS // Preferred Shares
}
enum ConversionRightsEnum {
CONVERTS_TO_FUTURE_ROUND
CONVERTS_TO_SHARE_CLASS_ID
}
// Based on OCF format
// https://open-cap-table-coalition.github.io/Open-Cap-Format-OCF/schema_markdown/schema/objects/StockClass/
model ShareClass {
id String @id @default(cuid())
idx Int // auto-generated, auto-incremented based on company
name String
classType ShareTypeEnum @default(COMMON)
prefix SharePrefixEnum @default(CS)
initialSharesAuthorized BigInt
boardApprovalDate DateTime
stockholderApprovalDate DateTime
votesPerShare Int
parValue Float
pricePerShare Float
seniority Int
// Conversion Rights
conversionRights ConversionRightsEnum @default(CONVERTS_TO_FUTURE_ROUND)
convertsToShareClassId String?
liquidationPreferenceMultiple Float
participationCapMultiple Float
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
equityPlans EquityPlan[]
investments Investment[]
shares Share[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([companyId, idx])
@@index([companyId])
}
enum CancellationBehaviorEnum {
RETIRE
RETURN_TO_POOL
HOLD_AS_CAPITAL_STOCK
DEFINED_PER_PLAN_SECURITY
}
model EquityPlan {
id String @id @default(cuid())
name String
boardApprovalDate DateTime
planEffectiveDate DateTime?
initialSharesReserved BigInt
defaultCancellatonBehavior CancellationBehaviorEnum
comments String?
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
shareClassId String
shareClass ShareClass @relation(fields: [shareClassId], references: [id], onDelete: Cascade)
options Option[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shareClassId])
@@index([companyId])
}
model Bucket {
id String @id @default(cuid())
name String
@@ -430,17 +287,12 @@ model Document {
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
shareId String?
share Share? @relation(fields: [shareId], references: [id], onDelete: SetNull)
optionId String?
option Option? @relation(fields: [optionId], references: [id], onDelete: SetNull)
safeId String?
safe Safe? @relation(fields: [safeId], references: [id], onDelete: SetNull)
// The security these describe lives in Hanzo Cloud (/v1/captable), so each of
// these holds the id it assigned rather than referencing a local row.
shareId String?
optionId String?
safeId String?
convertibleNoteId String?
convertibleNote ConvertibleNote? @relation(fields: [convertibleNoteId], references: [id], onDelete: SetNull)
documentShares DocumentShare[]
dataRooms DataRoomDocument[]
@@ -501,8 +353,8 @@ model DataRoomRecipient {
memberId String?
member Member? @relation(fields: [memberId], references: [id], onDelete: SetNull)
// A cap-table stakeholder id from Hanzo Cloud, not a local row.
stakeholderId String?
stakeholder Stakeholder? @relation(fields: [stakeholderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -524,8 +376,8 @@ model UpdateRecipient {
memberId String?
member Member? @relation(fields: [memberId], references: [id], onDelete: SetNull)
// A cap-table stakeholder id from Hanzo Cloud, not a local row.
stakeholderId String?
stakeholder Stakeholder? @relation(fields: [stakeholderId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -664,264 +516,6 @@ model EsignRecipient {
@@index([templateId])
}
enum SecuritiesStatusEnum {
ACTIVE
DRAFT
SIGNED
PENDING
}
enum ShareLegendsEnum {
US_SECURITIES_ACT // US Securities Act of 1933
SALE_AND_ROFR // Sale and Right of first refusal
TRANSFER_RESTRICTIONS // Bylaw transfer restrictions
}
model Share {
id String @id @default(cuid())
status SecuritiesStatusEnum @default(DRAFT)
certificateId String
quantity Int // Number of shares
pricePerShare Float?
capitalContribution Float? // Total amount of money contributed/invested
ipContribution Float? // Value of the intellectual property contributed
debtCancelled Float? // Amount of debt cancelled
otherContributions Float? // Other contributions
cliffYears Int @default(0) // 0 means immediate vesting, 1 means vesting starts after 1 year
vestingYears Int @default(0) // 0 means immediate vesting, 1 means vesting over 1 year
companyLegends ShareLegendsEnum[] @default([])
issueDate DateTime
rule144Date DateTime?
vestingStartDate DateTime?
boardApprovalDate DateTime
stakeholderId String
stakeholder Stakeholder @relation(fields: [stakeholderId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
shareClassId String
shareClass ShareClass @relation(fields: [shareClassId], references: [id], onDelete: Cascade)
documents Document[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([companyId, certificateId])
@@index([companyId])
@@index([shareClassId])
@@index([stakeholderId])
}
enum OptionTypeEnum {
ISO // Incentive Stock Options
NSO // Non-satutory Stock Options
RSU // Restricted Stock Units
}
enum OptionStatusEnum {
DRAFT
ACTIVE
EXERCISED
EXPIRED
CANCELLED
}
model Option {
id String @id @default(cuid())
grantId String
quantity Int
exercisePrice Float
type OptionTypeEnum
status OptionStatusEnum @default(DRAFT)
cliffYears Int @default(0) // 0 means immediate vesting, 1 means vesting starts after 1 year
vestingYears Int @default(0) // 0 means immediate vesting, 1 means vesting over 1 year
issueDate DateTime
expirationDate DateTime
vestingStartDate DateTime
boardApprovalDate DateTime
rule144Date DateTime
documents Document[]
stakeholderId String
stakeholder Stakeholder @relation(fields: [stakeholderId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
equityPlanId String
equityPlan EquityPlan @relation(fields: [equityPlanId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([companyId, grantId])
@@index([companyId])
@@index([equityPlanId])
@@index([stakeholderId])
}
model Investment {
id String @id @default(cuid())
amount Float // Amount of money invested
shares BigInt // Number of shares issued to the investor at the time of investment
date DateTime
comments String?
shareClassId String
shareClass ShareClass @relation(fields: [shareClassId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
// Investors => StakeholderRelationshipEnum["INVESTOR"]
stakeholderId String
stakeholder Stakeholder @relation(fields: [stakeholderId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([companyId])
@@index([stakeholderId])
@@index([shareClassId])
}
enum SafeTypeEnum {
PRE_MONEY
POST_MONEY
}
enum SafeStatusEnum {
DRAFT
ACTIVE
PENDING
EXPIRED
CANCELLED
}
// YC Standard Safe
enum SafeTemplateEnum {
POST_MONEY_CAP @map("Valuation Cap, no Discount")
POST_MONEY_DISCOUNT @map("Discount, no Valuation Cap")
POST_MONEY_MFN @map("MFN, no Valuation Cap, no Discount")
POST_MONEY_CAP_WITH_PRO_RATA @map("Valuation Cap, no Discount, include Pro Rata Rights")
POST_MONEY_DISCOUNT_WITH_PRO_RATA @map("Discount, no Valuation Cap, include Pro Rata Rights")
POST_MONEY_MFN_WITH_PRO_RATA @map("MFN, no Valuation Cap, no Discount, include Pro Rata Rights")
CUSTOM @map("Custom")
}
model Safe {
id String @id @default(cuid())
publicId String // eg. SAFE-01
type SafeTypeEnum @default(POST_MONEY)
status SafeStatusEnum @default(DRAFT)
capital Float // Amount of money invested
safeTemplate SafeTemplateEnum?
safeId String?
valuationCap Float?
discountRate Float?
mfn Boolean @default(false) // Most Favored Nation
proRata Boolean @default(false) // Pro Rata Rights
additionalTerms String?
documents Document[]
stakeholderId String
stakeholder Stakeholder @relation(fields: [stakeholderId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
issueDate DateTime
boardApprovalDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([publicId, companyId])
@@index([companyId])
@@index([stakeholderId])
}
enum ConvertibleStatusEnum {
DRAFT
ACTIVE
PENDING
EXPIRED
CANCELLED
}
enum ConvertibleTypeEnum {
CCD // Compulsory Convertible Debenture
OCD // Optionally Convertible Debenture
NOTE // Simple Convertible note
}
enum ConvertibleInterestMethodEnum {
SIMPLE
COMPOUND
}
enum ConvertibleInterestAccrualEnum {
DAILY
MONTHLY
SEMI_ANNUALLY
ANNUALLY
YEARLY
CONTINUOUSLY
}
enum ConvertibleInterestPaymentScheduleEnum {
DEFERRED
PAY_AT_MATURITY
}
model ConvertibleNote {
id String @id @default(cuid())
publicId String // eg. CN-01
status ConvertibleStatusEnum @default(DRAFT)
type ConvertibleTypeEnum @default(NOTE)
capital Float // Amount of money invested
conversionCap Float?
discountRate Float?
mfn Boolean? // Most Favored Nation
additionalTerms String?
interestRate Float?
interestMethod ConvertibleInterestMethodEnum?
interestAccrual ConvertibleInterestAccrualEnum?
interestPaymentSchedule ConvertibleInterestPaymentScheduleEnum?
documents Document[]
stakeholderId String
stakeholder Stakeholder @relation(fields: [stakeholderId], references: [id], onDelete: Cascade)
companyId String
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
issueDate DateTime
boardApprovalDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([publicId, companyId])
@@index([companyId])
@@index([stakeholderId])
}
enum UpdateStatusEnum {
DRAFT
PUBLIC
+15 -17
View File
@@ -22,27 +22,25 @@ const seed = async () => {
// const answer = true;
const answer = inquiry.answer as boolean;
if (answer) {
await nuke();
console.log("Seeding database".underline.cyan);
return db.$transaction(async () => {
await seedCompanies();
await seedTeam();
});
} else {
if (!answer) {
throw new Error("Seeding aborted");
}
await nuke();
console.log("Seeding database".underline.cyan);
return db.$transaction(async () => {
await seedCompanies();
await seedTeam();
});
};
const nuke = async () => {
const nuke = () => {
console.log("🚀 Nuking database records".yellow);
return db.$transaction(async (db) => {
await db.user.deleteMany();
await db.member.deleteMany();
await db.company.deleteMany();
await db.shareClass.deleteMany();
await db.equityPlan.deleteMany();
await db.document.deleteMany();
await db.bucket.deleteMany();
await db.audit.deleteMany();
@@ -54,12 +52,12 @@ await seed()
.then(async () => {
console.log("✅ Database seeding completed".green);
console.log(
`💌 We have created four admin accounts for you. Please login with one of these emails:\n`
"💌 We have created four admin accounts for you. Please login with one of these emails:\n"
.cyan,
`[email protected]\n`.underline.yellow,
`[email protected]\n`.underline.yellow,
`[email protected]\n`.underline.yellow,
`[email protected]\n`.underline.yellow,
"[email protected]\n".underline.yellow,
"[email protected]\n".underline.yellow,
"[email protected]\n".underline.yellow,
"[email protected]\n".underline.yellow,
);
await db.$disconnect();
})
@@ -1,10 +1,9 @@
import EmptyState from "@/components/common/empty-state";
import Tldr from "@/components/common/tldr";
import { Card } from "@/components/ui/card";
import { withServerComponentSession } from "@/server/auth";
import { db } from "@/server/db";
import type { EquityPlanMutationType } from "@/trpc/routers/equity-plan/schema";
import type { ShareClassMutationType } from "@/trpc/routers/share-class/schema";
import { api } from "@/trpc/server";
import { RiAddFill, RiPieChart2Line } from "@remixicon/react";
import type { Metadata } from "next";
import { CreateEquityPlanButton } from "./create-equity-plan-button";
@@ -14,32 +13,16 @@ export const metadata: Metadata = {
title: "Equity plans",
};
const getEquityPlans = async (companyId: string) => {
return await db.equityPlan.findMany({
where: { companyId },
});
};
const getShareClasses = async (companyId: string) => {
return await db.shareClass.findMany({
where: { companyId },
});
};
const EquityPlanPage = async () => {
const session = await withServerComponentSession();
const companyId = session?.user?.companyId;
let equityPlans: EquityPlanMutationType[] = [];
// Both lists are the cap table's, so both come from the one procedure that
// reads it — the page holds no second query of its own.
const [plans, classes] = await Promise.all([
api.equityPlan.getPlans.query(),
api.shareClass.get.query(),
]);
if (companyId) {
equityPlans = (await getEquityPlans(
companyId,
)) as unknown as EquityPlanMutationType[];
}
const shareClasses: ShareClassMutationType[] = (await getShareClasses(
companyId,
)) as unknown as ShareClassMutationType[];
const equityPlans = plans.data as unknown as EquityPlanMutationType[];
const shareClasses = classes as unknown as ShareClassMutationType[];
if (equityPlans.length === 0) {
return (
@@ -1,8 +1,7 @@
import EmptyState from "@/components/common/empty-state";
import { Card } from "@/components/ui/card";
import { withServerComponentSession } from "@/server/auth";
import { db } from "@/server/db";
import type { ShareClassMutationType } from "@/trpc/routers/share-class/schema";
import { api } from "@/trpc/server";
import { RiPieChart2Line } from "@remixicon/react";
import type { Metadata } from "next";
import { CreateShareButton } from "./create-share-class-button";
@@ -12,22 +11,9 @@ export const metadata: Metadata = {
title: "Share classes",
};
const getShareClasses = async (companyId: string) => {
return await db.shareClass.findMany({
where: { companyId },
});
};
const SharesPage = async () => {
const session = await withServerComponentSession();
const companyId = session?.user?.companyId;
let shareClasses: ShareClassMutationType[] = [];
if (companyId) {
shareClasses = (await getShareClasses(
companyId,
)) as unknown as ShareClassMutationType[];
}
const shareClasses =
(await api.shareClass.get.query()) as unknown as ShareClassMutationType[];
if (shareClasses.length === 0) {
return (
@@ -12,6 +12,10 @@ import type { ShareClassMutationType } from "@/trpc/routers/share-class/schema";
import { EditShareClassButton } from "./edit-share-class-button";
const formatter = new Intl.NumberFormat("en-US");
/** A date the cap table reports, or an em-dash when it reports none. */
const localDate = (value: Date | string | null | undefined) =>
value ? new Date(value).toLocaleDateString("en-US") : "—";
type ShareClassTableProps = {
shareClasses: ShareClassMutationType[];
};
@@ -38,12 +42,8 @@ const ShareClassTable = ({ shareClasses }: ShareClassTableProps) => {
<TableCell>
{formatter.format(klass.initialSharesAuthorized)}
</TableCell>
<TableCell>{`${new Date(
klass.boardApprovalDate,
).toLocaleDateString("en-US")}`}</TableCell>
<TableCell>{`${new Date(
klass.stockholderApprovalDate,
).toLocaleDateString("en-US")}`}</TableCell>
<TableCell>{localDate(klass.boardApprovalDate)}</TableCell>
<TableCell>{localDate(klass.stockholderApprovalDate)}</TableCell>
<TableCell>
<EditShareClassButton
shareClass={{
@@ -11,7 +11,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { camelCase } from "@/lib/utils";
import { StakeholderTypeEnum } from "@/prisma/enums";
import { StakeholderTypeEnum } from "@/server/captable-api";
import { api } from "@/trpc/react";
import { ZodAddStakeholderMutationSchema } from "@/trpc/routers/stakeholder-router/schema";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -22,7 +22,7 @@ import { camelCase } from "@/lib/utils";
import {
StakeholderRelationshipEnum,
StakeholderTypeEnum,
} from "@/prisma/enums";
} from "@/server/captable-api";
import { api } from "@/trpc/react";
import type { RouterOutputs } from "@/trpc/shared";
import { useRouter } from "next/navigation";
+1 -1
View File
@@ -21,8 +21,8 @@ import {
import { StepperModalFooter, StepperPrev } from "@/components/ui/stepper";
import Uploader from "@/components/ui/uploader";
import { toTitleCase } from "@/lib/string";
import { SafeTemplateEnum } from "@/prisma/enums";
import { useFormValueState } from "@/providers/form-value-provider";
import { SafeTemplateEnum } from "@/server/captable-api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import type { FileWithPath } from "react-dropzone";
@@ -24,8 +24,8 @@ import {
useStepper,
} from "@/components/ui/stepper";
import { toTitleCase } from "@/lib/string";
import { OptionStatusEnum, OptionTypeEnum } from "@/prisma/enums";
import { useStockOptionFormValues } from "@/providers/stock-option-form-provider";
import { OptionStatusEnum, OptionTypeEnum } from "@/server/captable-api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { NumericFormat } from "react-number-format";
+1 -1
View File
@@ -1,4 +1,4 @@
import { SecuritiesStatusEnum } from "@prisma/client";
import { SecuritiesStatusEnum } from "@/server/captable-api";
import { capitalize } from "lodash-es";
export const statusValues = Object.keys(SecuritiesStatusEnum).map((item) => ({
@@ -231,7 +231,9 @@ export const columns: ColumnDef<Share[number]>[] = [
),
cell: ({ row }) => (
<div className="text-center">
{dayjsExt(row.original.boardApprovalDate).format("DD/MM/YYYY")}
{row.original.boardApprovalDate
? dayjsExt(row.original.boardApprovalDate).format("DD/MM/YYYY")
: "—"}
</div>
),
},
@@ -32,8 +32,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { SecuritiesStatusEnum, ShareLegendsEnum } from "@/prisma/enums";
import { useAddShareFormValues } from "@/providers/add-share-form-provider";
import { SecuritiesStatusEnum, ShareLegendsEnum } from "@/server/captable-api";
import type { RouterOutputs } from "@/trpc/shared";
import { zodResolver } from "@hookform/resolvers/zod";
import { RiAddFill } from "@remixicon/react";
-4
View File
@@ -1,8 +1,6 @@
import { PublicAPI } from "./hono";
import { middlewareServices } from "./middlewares/services";
import { registerCompanyRoutes } from "./routes/company";
import { registerShareRoutes } from "./routes/share";
import { registerStakeholderRoutes } from "./routes/stakeholder";
const api = PublicAPI();
@@ -10,7 +8,5 @@ api.use("*", middlewareServices());
// Register RESTful routes
registerCompanyRoutes(api);
registerShareRoutes(api);
registerStakeholderRoutes(api);
export default api;
-110
View File
@@ -1,110 +0,0 @@
import { z } from "@hono/zod-openapi";
import {
CreateShareSchema,
type CreateShareSchemaType,
} from "../../schema/shares";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ResponseSchema = z.object({
message: z.string(),
data: CreateShareSchema,
});
const ParamsSchema = z.object({
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
export const create = withAuthApiV1
.createRoute({
method: "post",
path: "/v1/{companyId}/shares",
summary: "Create shares",
description: "Issue shares to a stakeholder in a company.",
tags: ["Shares"],
middleware: [authMiddleware()],
request: {
params: ParamsSchema,
body: {
content: {
"application/json": {
schema: CreateShareSchema,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Confirmation of shares issued with relevant details.",
},
},
})
.handler(async (c) => {
const { db, audit, client } = c.get("services");
const { membership } = c.get("session");
const { requestIp, userAgent } = client;
const body = c.req.valid("json");
const share = await db.$transaction(async (tx) => {
const share = await tx.share.create({
data: { ...body, companyId: membership.companyId },
});
// if (documents && documents.length > 0) {
// const bulkDocuments = documents.map((doc) => ({
// companyId: input.companyId,
// uploaderId: input.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.created",
companyId: membership.companyId,
actor: { type: "user", id: membership.userId },
context: {
userAgent,
requestIp,
},
target: [{ type: "share", id: share.id }],
summary: `${membership.user.name} added share for stakeholder ${share.stakeholderId}`,
},
tx,
);
return share;
});
const data: CreateShareSchemaType = {
...share,
issueDate: share.issueDate.toISOString(),
boardApprovalDate: share.boardApprovalDate.toISOString(),
rule144Date: share.rule144Date?.toISOString(),
vestingStartDate: share.vestingStartDate?.toISOString(),
};
return c.json({ message: "Share successfully created.", data }, 200);
});
-111
View File
@@ -1,111 +0,0 @@
import { z } from "@hono/zod-openapi";
import { ApiError } from "../../error";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ParamsSchema = z.object({
id: z.string().openapi({
param: {
name: "id",
in: "path",
},
description: "Share ID",
type: "string",
example: "clyabgufg004u5tbtnz0r4cax",
}),
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
const ResponseSchema = z.object({
message: z.string().openapi({
description:
"A text providing details about the API request result, including success, error, or warning messages.",
}),
});
export const _delete = withAuthApiV1
.createRoute({
method: "delete",
path: "/v1/{companyId}/shares/{id}",
summary: "Delete a share",
description: "Remove an issued share by its ID.",
tags: ["Shares"],
middleware: [authMiddleware()],
request: { params: ParamsSchema },
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Confirmation that the issued share has been removed.",
},
},
})
.handler(async (c) => {
const { db, audit, client } = c.get("services");
const { membership } = c.get("session");
const { requestIp, userAgent } = client as {
requestIp: string;
userAgent: string;
};
const { id } = c.req.valid("param");
await db.$transaction(async (tx) => {
const share = await tx.share.findUnique({
where: {
id,
companyId: membership.companyId,
},
select: {
id: true,
stakeholderId: true,
},
});
if (!share) {
throw new ApiError({
code: "NOT_FOUND",
message: `Share with ID ${id} not found`,
});
}
await tx.share.delete({
where: {
id: share.id,
},
});
await audit.create(
{
action: "share.deleted",
companyId: membership.companyId,
actor: { type: "user", id: membership.userId },
context: {
userAgent,
requestIp,
},
target: [{ type: "share", id }],
summary: `${membership.user.name} Deleted the share for stakeholder ${share.stakeholderId}`,
},
tx,
);
});
return c.json(
{
message: "Share deleted successfully",
},
200,
);
});
-76
View File
@@ -1,76 +0,0 @@
import { z } from "@hono/zod-openapi";
import {
PaginationQuerySchema,
PaginationResponseSchema,
} from "../../schema/pagination";
import { ShareSchema } from "../../schema/shares";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ResponseSchema = z.object({
data: z.array(ShareSchema),
meta: PaginationResponseSchema,
});
const ParamsSchema = z.object({
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
export const getMany = withAuthApiV1
.createRoute({
summary: "List shares",
description: "Retrieve a list of issued shares for the company.",
tags: ["Shares"],
method: "get",
path: "/v1/{companyId}/shares",
middleware: [authMiddleware()],
request: {
params: ParamsSchema,
query: PaginationQuerySchema,
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "A list of issued shares with their details.",
},
},
})
.handler(async (c) => {
const { membership } = c.get("session");
const { db } = c.get("services");
const query = c.req.valid("query");
const [data, meta] = await db.share
.paginate({ where: { companyId: membership.companyId } })
.withCursor({
limit: query.limit,
after: query.cursor,
});
const response: z.infer<typeof ResponseSchema> = {
meta,
data: data.map((i) => ({
...i,
createdAt: i.createdAt.toISOString(),
updatedAt: i.updatedAt.toISOString(),
issueDate: i.issueDate.toISOString(),
rule144Date: i.rule144Date?.toISOString(),
vestingStartDate: i.vestingStartDate?.toISOString(),
boardApprovalDate: i.boardApprovalDate?.toISOString(),
})),
};
return c.json(response, 200);
});
-89
View File
@@ -1,89 +0,0 @@
import { z } from "@hono/zod-openapi";
import { ApiError } from "../../error";
import { ShareSchema, type ShareSchemaType } from "../../schema/shares";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ParamsSchema = z.object({
id: z.string().openapi({
param: {
name: "id",
in: "path",
},
description: "Share ID",
type: "string",
example: "clyabgufg004u5tbtnz0r4cax",
}),
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
const ResponseSchema = z.object({
data: ShareSchema,
});
export const getOne = withAuthApiV1
.createRoute({
summary: "Get a share",
description: "Fetch a single issued share record by its ID.",
tags: ["Shares"],
method: "get",
path: "/v1/{companyId}/shares/{id}",
middleware: [authMiddleware()],
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Details of the requested issued share.",
},
},
})
.handler(async (c) => {
const { db } = c.get("services");
const { membership } = c.get("session");
const { id } = c.req.valid("param");
const share = await db.share.findUnique({
where: {
id,
companyId: membership.companyId,
},
});
if (!share) {
throw new ApiError({
code: "NOT_FOUND",
message: `No share with the provided Id ${id}`,
});
}
const data: ShareSchemaType = {
...share,
createdAt: share.createdAt.toISOString(),
updatedAt: share.updatedAt.toISOString(),
issueDate: share.issueDate.toISOString(),
rule144Date: share.rule144Date?.toISOString(),
vestingStartDate: share.vestingStartDate?.toISOString(),
boardApprovalDate: share.boardApprovalDate?.toISOString(),
};
return c.json(
{
data,
},
200,
);
});
-14
View File
@@ -1,14 +0,0 @@
import type { PublicAPI } from "@/server/api/hono";
import { create } from "./create";
import { _delete } from "./delete";
import { getMany } from "./getMany";
import { getOne } from "./getOne";
import { update } from "./update";
export const registerShareRoutes = (api: PublicAPI) => {
api.openapi(_delete.route, _delete.handler);
api.openapi(getOne.route, getOne.handler);
api.openapi(getMany.route, getMany.handler);
api.openapi(create.route, create.handler);
api.openapi(update.route, update.handler);
};
-132
View File
@@ -1,132 +0,0 @@
import { z } from "@hono/zod-openapi";
import { ApiError } from "../../error";
import {
ShareSchema,
type ShareSchemaType,
UpdateShareSchema,
} from "../../schema/shares";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ParamsSchema = z.object({
id: z.string().openapi({
param: {
name: "id",
in: "path",
},
description: "Share ID",
type: "string",
example: "clyabgufg004u5tbtnz0r4cax",
}),
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
const ResponseSchema = z.object({
message: z.string(),
data: ShareSchema,
});
export const update = withAuthApiV1
.createRoute({
summary: "Update Issued Shares",
description: "Update details of an issued share by its ID.",
tags: ["Shares"],
method: "patch",
path: "/v1/{companyId}/stakeholders/{id}",
middleware: [authMiddleware()],
request: {
params: ParamsSchema,
body: {
content: {
"application/json": {
schema: UpdateShareSchema,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Confirmation of updated issued share details.",
},
},
})
.handler(async (c) => {
const { db, audit, client } = c.get("services");
const { membership } = c.get("session");
const { requestIp, userAgent } = client;
const { id } = c.req.valid("param");
const body = c.req.valid("json");
const updatedShare = await db.$transaction(async (tx) => {
const share = await db.share.findUnique({
where: {
id,
companyId: membership.companyId,
},
});
if (!share) {
throw new ApiError({
code: "NOT_FOUND",
message: `No share with the provided Id ${id}`,
});
}
const updatedShare = await tx.share.update({
where: {
id: share.id,
},
data: body,
});
await audit.create(
{
action: "share.updated",
companyId: membership.companyId,
actor: { type: "user", id: membership.userId },
context: {
userAgent: userAgent,
requestIp: requestIp,
},
target: [{ type: "share", id: share.id }],
summary: `${membership.user.name} updated share the share ID ${updatedShare.id}`,
},
tx,
);
return updatedShare;
});
const data: ShareSchemaType = {
...updatedShare,
createdAt: updatedShare.createdAt.toISOString(),
updatedAt: updatedShare.updatedAt.toISOString(),
issueDate: updatedShare.issueDate.toISOString(),
rule144Date: updatedShare.rule144Date?.toISOString(),
vestingStartDate: updatedShare.vestingStartDate?.toISOString(),
boardApprovalDate: updatedShare.boardApprovalDate?.toISOString(),
};
return c.json(
{
message: "Stakeholder updated successfully",
data,
},
200,
);
});
-109
View File
@@ -1,109 +0,0 @@
import { z } from "@hono/zod-openapi";
import {
CreateStakeholderSchema,
StakeholderSchema,
} from "../../schema/stakeholder";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ResponseSchema = z.object({
message: z.string(),
data: z.array(StakeholderSchema.pick({ id: true, name: true })),
});
const ParamsSchema = z.object({
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
export const create = withAuthApiV1
.createRoute({
method: "post",
path: "/v1/{companyId}/stakeholders",
summary: "Create stakeholders",
description: "Add one or more stakeholder accounts to a company.",
tags: ["Stakeholder"],
middleware: [authMiddleware()],
request: {
params: ParamsSchema,
body: {
content: {
"application/json": {
schema: CreateStakeholderSchema,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description:
"Confirmation of stakeholder created with relevant details.",
},
},
})
.handler(async (c) => {
const { db, audit, client } = c.get("services");
const { membership } = c.get("session");
const { requestIp, userAgent } = client as {
requestIp: string;
userAgent: string;
};
const body = c.req.valid("json");
const stakeholders = await db.$transaction(async (tx) => {
const inputDataWithCompanyId = body.map((stakeholder) => ({
...stakeholder,
companyId: membership.companyId,
}));
const addedStakeholders = await tx.stakeholder.createManyAndReturn({
data: inputDataWithCompanyId,
select: {
id: true,
name: true,
},
});
const auditPromises = addedStakeholders.map((stakeholder) =>
audit.create(
{
action: "stakeholder.added",
companyId: membership.companyId,
actor: { type: "user", id: membership.userId },
context: {
requestIp,
userAgent,
},
target: [{ type: "stakeholder", id: stakeholder.id }],
summary: `${membership.user.name} added the stakholder in the company : ${stakeholder.name}`,
},
tx,
),
);
await Promise.all(auditPromises);
return addedStakeholders;
});
const data: z.infer<typeof ResponseSchema>["data"] = stakeholders;
return c.json(
{
data,
message: "Stakeholders successfully created.",
},
200,
);
});
-105
View File
@@ -1,105 +0,0 @@
import { z } from "@hono/zod-openapi";
import { ApiError } from "../../error";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ParamsSchema = z.object({
id: z.string().openapi({
param: {
name: "id",
in: "path",
},
description: "Stakeholder ID",
type: "string",
example: "clyabgufg004u5tbtnz0r4cax",
}),
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
const ResponseSchema = z.object({
message: z.string(),
});
export const _delete = withAuthApiV1
.createRoute({
summary: "Delete a stakeholder",
description: "Remove a stakeholder from a company by ID.",
tags: ["Stakeholder"],
method: "delete",
path: "/v1/{companyId}/stakeholders/{id}",
middleware: [authMiddleware()],
request: { params: ParamsSchema },
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Confirmation that the stakeholder has been removed.",
},
},
})
.handler(async (c) => {
const { db, audit, client } = c.get("services");
const { membership } = c.get("session");
const { requestIp, userAgent } = client;
const { id } = c.req.valid("param");
await db.$transaction(async (tx) => {
const stakeholder = await tx.stakeholder.findUnique({
where: {
id,
companyId: membership.companyId,
},
select: {
id: true,
companyId: true,
name: true,
},
});
if (!stakeholder) {
throw new ApiError({
code: "NOT_FOUND",
message: "No stakeholder with the provided Id",
});
}
await tx.stakeholder.delete({
where: {
id: stakeholder.id,
},
});
await audit.create(
{
action: "stakeholder.deleted",
companyId: membership.companyId,
actor: { type: "user", id: membership.userId },
context: {
requestIp,
userAgent,
},
target: [{ type: "stakeholder", id }],
summary: `${membership.user.name} deleted the stakeholder ${stakeholder.name} - ${stakeholder.id}`,
},
tx,
);
});
return c.json(
{
message: "Stakeholder deleted successfully",
},
200,
);
});
@@ -1,72 +0,0 @@
import { z } from "@hono/zod-openapi";
import {
PaginationQuerySchema,
PaginationResponseSchema,
} from "../../schema/pagination";
import { StakeholderSchema } from "../../schema/stakeholder";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ResponseSchema = z.object({
data: z.array(StakeholderSchema),
meta: PaginationResponseSchema,
});
const ParamsSchema = z.object({
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
export const getMany = withAuthApiV1
.createRoute({
summary: "List stakeholders",
description:
"Retrieve a paginated list of all stakeholders in the company.",
tags: ["Stakeholder"],
method: "get",
path: "/v1/{companyId}/stakeholders",
middleware: [authMiddleware()],
request: {
query: PaginationQuerySchema,
params: ParamsSchema,
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "A list of stakeholders in a company with their details.",
},
},
})
.handler(async (c) => {
const { membership } = c.get("session");
const { db } = c.get("services");
const query = c.req.valid("query");
const [data, meta] = await db.stakeholder
.paginate({ where: { companyId: membership.companyId } })
.withCursor({
limit: query.limit,
after: query.cursor,
});
const response: z.infer<typeof ResponseSchema> = {
data: data.map((stakeholder) => ({
...stakeholder,
createdAt: stakeholder.createdAt.toISOString(),
updatedAt: stakeholder.updatedAt.toISOString(),
})),
meta,
};
return c.json(response, 200);
});
@@ -1,87 +0,0 @@
import { z } from "@hono/zod-openapi";
import { ApiError } from "../../error";
import {
StakeholderSchema,
type TStakeholderSchema,
} from "../../schema/stakeholder";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ParamsSchema = z.object({
id: z.string().openapi({
param: {
name: "id",
in: "path",
},
description: "Stakeholder ID",
type: "string",
example: "clyabgufg004u5tbtnz0r4cax",
}),
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
const ResponseSchema = z.object({
data: StakeholderSchema,
});
export const getOne = withAuthApiV1
.createRoute({
summary: "Get a stakeholder",
description: "Fetch details of a single stakeholder by their ID.",
tags: ["Stakeholder"],
method: "get",
path: "/v1/{companyId}/stakeholders/{id}",
middleware: [authMiddleware()],
request: {
params: ParamsSchema,
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Details of the requested stakeholder.",
},
},
})
.handler(async (c) => {
const { db } = c.get("services");
const { membership } = c.get("session");
const { id } = c.req.valid("param");
const stakeholder = await db.stakeholder.findUnique({
where: {
id,
companyId: membership.companyId,
},
});
if (!stakeholder) {
throw new ApiError({
code: "NOT_FOUND",
message: "No stakeholder with the provided Id",
});
}
const data: TStakeholderSchema = {
...stakeholder,
createdAt: stakeholder.createdAt.toISOString(),
updatedAt: stakeholder.updatedAt.toISOString(),
};
return c.json(
{
data,
},
200,
);
});
@@ -1,14 +0,0 @@
import type { PublicAPI } from "@/server/api/hono";
import { create } from "./create";
import { _delete } from "./delete";
import { getMany } from "./getMany";
import { getOne } from "./getOne";
import { update } from "./update";
export const registerStakeholderRoutes = (api: PublicAPI) => {
api.openapi(getOne.route, getOne.handler);
api.openapi(update.route, update.handler);
api.openapi(_delete.route, _delete.handler);
api.openapi(create.route, create.handler);
api.openapi(getMany.route, getMany.handler);
};
-135
View File
@@ -1,135 +0,0 @@
import { z } from "@hono/zod-openapi";
import { ApiError } from "../../error";
import {
StakeholderSchema,
type TStakeholderSchema,
type TUpdateStakeholderSchema,
UpdateStakeholderSchema,
} from "../../schema/stakeholder";
import { authMiddleware, withAuthApiV1 } from "../../utils/endpoint-creator";
const ParamsSchema = z.object({
id: z.string().openapi({
param: {
name: "id",
in: "path",
},
description: "Stakeholder ID",
type: "string",
example: "clyabgufg004u5tbtnz0r4cax",
}),
companyId: z.string().openapi({
param: {
name: "companyId",
in: "path",
},
description: "Company ID",
type: "string",
example: "clxwbok580000i7nge8nm1ry0",
}),
});
const ResponseSchema = z.object({
message: z.string(),
data: StakeholderSchema,
});
export const update = withAuthApiV1
.createRoute({
summary: "Update a stakeholder",
description: "Modify the details of a stakeholder by their ID.",
tags: ["Stakeholder"],
method: "patch",
path: "/v1/{companyId}/stakeholders/{id}",
middleware: [authMiddleware()],
request: {
params: ParamsSchema,
body: {
content: {
"application/json": {
schema: UpdateStakeholderSchema,
},
},
},
},
responses: {
200: {
content: {
"application/json": {
schema: ResponseSchema,
},
},
description: "Confirmation of updated stakeholder details.",
},
},
})
.handler(async (c) => {
const { id } = c.req.valid("param");
const { db, audit, client } = c.get("services");
const { membership } = c.get("session");
const { requestIp, userAgent } = client as {
requestIp: string;
userAgent: string;
};
const body = await c.req.json<TUpdateStakeholderSchema>();
const updatedStakeHolder = await db.$transaction(async (tx) => {
const stakeholder = await tx.stakeholder.findUnique({
where: {
id,
companyId: membership.companyId,
},
select: {
id: true,
companyId: true,
name: true,
},
});
if (!stakeholder) {
throw new ApiError({
code: "NOT_FOUND",
message: "No stakeholder with the provided Id",
});
}
const updatedStakeHolder = await tx.stakeholder.update({
where: {
id: stakeholder.id,
},
data: body,
});
await audit.create(
{
action: "stakeholder.updated",
companyId: membership.companyId,
actor: { type: "user", id: membership.userId },
context: {
requestIp,
userAgent,
},
target: [{ type: "stakeholder", id: stakeholder.id }],
summary: `${membership.user.name} updated the stakeholder details in the company : ${updatedStakeHolder.name}`,
},
tx,
);
return updatedStakeHolder;
});
const data: TStakeholderSchema = {
...updatedStakeHolder,
createdAt: updatedStakeHolder.createdAt.toISOString(),
updatedAt: updatedStakeHolder.updatedAt.toISOString(),
};
return c.json(
{
message: "Stakeholder updated successfully",
data,
},
200,
);
});
-153
View File
@@ -1,153 +0,0 @@
import { SecuritiesStatusEnum, ShareLegendsEnum } from "@/prisma/enums";
import { z } from "@hono/zod-openapi";
const ShareLegendsArr = Object.values(ShareLegendsEnum) as [
ShareLegendsEnum,
...ShareLegendsEnum[],
];
const SecuritiesStatusArr = Object.values(SecuritiesStatusEnum) as [
SecuritiesStatusEnum,
...SecuritiesStatusEnum[],
];
export const ShareSchema = z
.object({
id: z.string().cuid().nullish().openapi({
description: "Share ID",
example: "clyvb2s8d0000f1ngd72y2cxw",
}),
status: z.enum(SecuritiesStatusArr).openapi({
description: "Security Status",
example: "DRAFT",
}),
certificateId: z.string().openapi({
description: "Certificate ID",
example: "123",
}),
quantity: z.number().openapi({
description: "Quantity of Shares",
example: 5000,
}),
pricePerShare: z.number().nullish().openapi({
description: "Price Per Share",
example: 1.25,
}),
capitalContribution: z.number().nullish().openapi({
description: "Total amount of money contributed",
example: 250000,
}),
ipContribution: z.number().nullish().openapi({
description: "Value of the intellectual property contributed",
example: 0,
}),
debtCancelled: z.number().nullish().openapi({
description: "Amount of debt cancelled",
example: 0,
}),
otherContributions: z.number().nullish().openapi({
description: "Other contributions",
example: 0,
}),
cliffYears: z.number().openapi({
description: "Cliff Years",
example: 1,
}),
vestingYears: z.number().openapi({
description: "Vesting Years",
example: 4,
}),
companyLegends: z
.enum(ShareLegendsArr)
.array()
.openapi({
description: "Company Legends",
example: ["US_SECURITIES_ACT", "SALE_AND_ROFR"],
}),
issueDate: z.string().datetime().openapi({
description: "Issued Date",
example: "2024-01-01T00:00:00.000Z",
}),
rule144Date: z.string().datetime().nullish().openapi({
description: "Rule 144 Date",
example: "2024-01-01T00:00:00.000Z",
}),
vestingStartDate: z.string().datetime().nullish().openapi({
description: "Vesting Start Date",
example: "2024-01-01T00:00:00.000Z",
}),
boardApprovalDate: z.string().datetime().openapi({
description: "Board Approval Date",
example: "2024-01-01T00:00:00.000Z",
}),
stakeholderId: z.string().cuid().openapi({
description: "StakeHolder ID",
example: "cly13ipa40000i7ng42mv4x7b",
}),
companyId: z.string().cuid().optional().openapi({
description: "Company ID",
example: "clyvb28ak0000f1ngcn2i0p2m",
}),
shareClassId: z.string().cuid().openapi({
description: "ShareClass ID",
example: "clyvb2d8v0000f1ng1stpa38s",
}),
createdAt: z.string().datetime().nullish().openapi({
description: "Share Created at",
example: "2024-01-01T00:00:00.000Z",
}),
updatedAt: z.string().datetime().nullish().openapi({
description: "Share Updated at",
example: "2024-01-01T00:00:00.000Z",
}),
})
.openapi("Shares");
export const CreateShareSchema = ShareSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
companyId: true,
}).openapi({
description: "Issue shares to a stakeholder in a company.",
});
export const UpdateShareSchema = ShareSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
})
.partial()
.refine(
(data) => {
return Object.values(data).some((value) => value !== undefined);
},
{
message: "At least one field must be provided to update.",
},
)
.openapi({
description: "Update a share by ID",
});
export type ShareSchemaType = z.infer<typeof ShareSchema>;
export type CreateShareSchemaType = z.infer<typeof CreateShareSchema>;
export type UpdateShareSchemaType = z.infer<typeof UpdateShareSchema>;
-111
View File
@@ -1,111 +0,0 @@
import {
StakeholderRelationshipEnum,
StakeholderTypeEnum,
} from "@/prisma/enums";
import { z } from "@hono/zod-openapi";
const StakeholderTypeArray = Object.values(StakeholderTypeEnum) as [
StakeholderTypeEnum,
...StakeholderTypeEnum[],
];
const StakeholderRelationshipArray = Object.values(
StakeholderRelationshipEnum,
) as [StakeholderRelationshipEnum, ...StakeholderRelationshipEnum[]];
export const StakeholderSchema = z
.object({
id: z.string().cuid().openapi({
description: "Stakeholder ID",
example: "cly13ipa40000i7ng42mv4x7b",
}),
name: z.string().openapi({
description: "Stakeholder name",
example: "John Doe",
}),
email: z.string().email().openapi({
description: "Stakeholder email",
example: "[email protected]",
}),
institutionName: z.string().nullish().openapi({
description: "Institution name",
example: "ACME Corp",
}),
stakeholderType: z.enum(StakeholderTypeArray).openapi({
description: "Stakeholder type",
example: "INDIVIDUAL",
}),
currentRelationship: z.enum(StakeholderRelationshipArray).openapi({
description: "Current relationship with the company",
example: "EMPLOYEE",
}),
streetAddress: z.string().nullish().openapi({
description: "Street address",
example: "123 Main St",
}),
city: z.string().nullish().openapi({
description: "City",
example: "San Francisco",
}),
state: z.string().nullish().openapi({
description: "State",
example: "CA",
}),
zipcode: z.string().nullish().openapi({
description: "Zip code",
example: "94105",
}),
country: z.string().optional().openapi({
description: "Country",
example: "USA",
}),
createdAt: z.string().date().openapi({
description: "Date the stakeholder was created",
example: "2022-01-01T00:00:00Z",
}),
updatedAt: z.string().date().openapi({
description: "Date the stakeholder was last updated",
example: "2022-01-01T00:00:00Z",
}),
})
.openapi("Stakeholder");
export const CreateStakeholderSchema = z.array(
StakeholderSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
}),
);
export const UpdateStakeholderSchema = StakeholderSchema.omit({
id: true,
createdAt: true,
updatedAt: true,
})
.partial()
.refine(
(data) => {
return Object.values(data).some((value) => value !== undefined);
},
{
message: "At least one field must be provided to update.",
},
)
.openapi({
description: "Update a stakeholder by ID",
});
export type TStakeholderSchema = z.infer<typeof StakeholderSchema>;
export type TCreateStakeholderSchema = z.infer<typeof CreateStakeholderSchema>;
export type TUpdateStakeholderSchema = z.infer<typeof UpdateStakeholderSchema>;
+63 -6
View File
@@ -12,6 +12,7 @@ import {
import { env } from "@/env";
import type { MemberStatusEnum } from "@/prisma/enums";
import { type TPrismaOrTransaction, db } from "@/server/db";
import { getToken } from "next-auth/jwt";
import type { OAuthConfig } from "next-auth/providers/oauth";
import { cache } from "react";
@@ -46,7 +47,6 @@ function HanzoIAMProvider(): OAuthConfig<any> {
name: profile.displayName || profile.name || profile.preferred_username,
email: profile.email,
image: profile.avatar || profile.picture,
organization: profile.owner || profile.organization || profile.org,
};
},
allowDangerousEmailAccountLinking: true,
@@ -78,6 +78,11 @@ declare module "next-auth/jwt" {
companyPublicId: string;
status: MemberStatusEnum | "";
organization?: string;
// The IAM token the cap-table backend authenticates with. It lives here and
// only here: the JWT is an encrypted httpOnly cookie, while `session` is
// served to the browser verbatim at /api/auth/session.
hanzoToken?: string;
hanzoTokenExpires?: number;
}
}
@@ -111,11 +116,26 @@ export const authOptions: NextAuthOptions = {
return session;
},
async jwt({ token, user, trigger }) {
// Persist IAM organization claim from initial sign-in
const orgUser = user as { organization?: string } | undefined;
if (orgUser?.organization) {
token.organization = orgUser.organization;
async jwt({ token, account, profile, trigger }) {
// `account` is present on the sign-in pass only, so this is the one moment
// the IAM access token is on offer. The cap-table backend reads the tenant
// off this token's own claims, so keeping it is what makes the cap table
// reachable at all.
if (account?.access_token) {
token.hanzoToken = account.access_token;
token.hanzoTokenExpires = account.expires_at
? account.expires_at * 1000
: undefined;
}
// The org claim rides the verified id_token. It is not a User column, and
// anything profile() returns is spread straight into prisma.user.create —
// an unknown key there fails the insert and every first sign-in with it.
const claims = profile as
| { owner?: string; organization?: string; org?: string }
| undefined;
if (claims) {
token.organization = claims.owner ?? claims.organization ?? claims.org;
}
if (trigger) {
const member = await db.member.findFirst({
@@ -178,6 +198,43 @@ export const authOptions: NextAuthOptions = {
},
};
/** Split a `Cookie:` header into the name→value map `getToken` reads. */
const cookieJar = (header: string): Record<string, string> => {
const jar: Record<string, string> = {};
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
jar[part.slice(0, eq).trim()] = decodeURIComponent(
part.slice(eq + 1).trim(),
);
}
return jar;
};
/**
* The IAM access token this session signed in with, for calling the cap-table
* backend as the user. Empty when the session predates the token being kept, or
* when the token has outlived its own expiry — the caller then refuses the call
* with something a person can act on rather than passing a dead bearer upstream.
*
* Read from the cookie alone. `getToken` will otherwise accept an `Authorization`
* header as the session, and this app serves a public API whose callers send one.
*/
export async function hanzoAccessToken(headers: Headers): Promise<string> {
const token = await getToken({
req: {
cookies: cookieJar(headers.get("cookie") ?? ""),
headers: new Headers(),
} as never,
secret: env.NEXTAUTH_SECRET,
});
if (!token?.hanzoToken) return "";
if (token.hanzoTokenExpires && token.hanzoTokenExpires <= Date.now())
return "";
return token.hanzoToken;
}
export const getServerAuthSession = () => getServerSession(authOptions);
export const getServerComponentAuthSession = cache(() =>
+449
View File
@@ -0,0 +1,449 @@
import { describe, expect, it } from "vitest";
import {
CaptableApiError,
type Share,
captableFailure,
date,
normalizeConvertible,
normalizeEquityPlan,
normalizeOption,
normalizeSafe,
normalizeShare,
normalizeShareClass,
normalizeStakeholder,
normalizeSummary,
rows,
} from "./captable-api";
/**
* The backend is another team's service reached over HTTP, so every shape here
* is a claim rather than a guarantee. These pin the two properties that matter:
* a documented payload survives intact, and an undocumented one degrades to an
* honest empty value instead of throwing inside a React render.
*/
const GARBAGE: unknown[] = [
undefined,
null,
"",
0,
false,
"not json",
[],
{},
{ data: null },
{ data: "nope" },
[null, 1, "x"],
{ totals: "nope", byStakeholder: {} },
];
describe("rows", () => {
it("unwraps a bare array (stakeholders, classes)", () => {
expect(rows([{ id: "a" }, { id: "b" }])).toEqual([
{ id: "a" },
{ id: "b" },
]);
});
it("unwraps the { data } envelope (shares, options, safes, rounds)", () => {
expect(rows({ data: [{ id: "a" }] })).toEqual([{ id: "a" }]);
});
it("unwraps items and rows envelopes", () => {
expect(rows({ items: [{ id: "a" }] })).toEqual([{ id: "a" }]);
expect(rows({ rows: [{ id: "a" }] })).toEqual([{ id: "a" }]);
});
it("drops entries that are not objects rather than passing them on", () => {
expect(rows([{ id: "a" }, null, 7, "x", ["nested"]])).toEqual([
{ id: "a" },
]);
});
it("answers an empty list for anything it does not recognise", () => {
for (const payload of GARBAGE) expect(rows(payload)).toEqual([]);
});
});
describe("date", () => {
it("reads an ISO date and an ISO datetime", () => {
expect(date("2024-03-01")?.toISOString()).toBe("2024-03-01T00:00:00.000Z");
expect(date("2024-03-01T12:30:00.000Z")?.toISOString()).toBe(
"2024-03-01T12:30:00.000Z",
);
});
it("is null when absent or unparseable — never an Invalid Date", () => {
for (const v of [undefined, null, "", "not a date", {}, []]) {
expect(date(v)).toBeNull();
}
});
});
describe("normalizeStakeholder", () => {
it("reads the documented camelCase row", () => {
expect(
normalizeStakeholder({
id: "st_1",
name: "Ada Lovelace",
email: "[email protected]",
institutionName: "Analytical Engines",
stakeholderType: "INSTITUTION",
currentRelationship: "INVESTOR",
taxId: "12-345",
streetAddress: "1 Byron St",
city: "London",
state: "LDN",
zipcode: "N1",
country: "GB",
createdAt: "2024-03-01T00:00:00.000Z",
}),
).toMatchObject({
id: "st_1",
name: "Ada Lovelace",
institutionName: "Analytical Engines",
stakeholderType: "INSTITUTION",
currentRelationship: "INVESTOR",
country: "GB",
});
});
it("reads the same row in snake_case", () => {
const r = normalizeStakeholder({
id: "st_1",
institution_name: "Analytical Engines",
stakeholder_type: "INSTITUTION",
current_relationship: "INVESTOR",
street_address: "1 Byron St",
tax_id: "12-345",
created_at: "2024-03-01T00:00:00.000Z",
});
expect(r.institutionName).toBe("Analytical Engines");
expect(r.stakeholderType).toBe("INSTITUTION");
expect(r.currentRelationship).toBe("INVESTOR");
expect(r.streetAddress).toBe("1 Byron St");
expect(r.taxId).toBe("12-345");
expect(r.createdAt).toBeInstanceOf(Date);
});
it("carries the joined company name the pickers label with", () => {
expect(normalizeStakeholder({ companyName: "Acme" }).companyName).toBe(
"Acme",
);
expect(normalizeStakeholder({ company_name: "Acme" }).companyName).toBe(
"Acme",
);
});
it("falls back to the vocabulary default when the wire word is not one", () => {
const r = normalizeStakeholder({
stakeholderType: "PARTNERSHIP",
currentRelationship: "CHAIRMAN",
});
expect(r.stakeholderType).toBe("INDIVIDUAL");
expect(r.currentRelationship).toBe("EMPLOYEE");
});
it("degrades an empty row to empty strings and nulls", () => {
const r = normalizeStakeholder({});
expect(r).toEqual({
id: "",
name: "",
email: "",
institutionName: "",
stakeholderType: "INDIVIDUAL",
currentRelationship: "EMPLOYEE",
taxId: "",
streetAddress: "",
city: "",
state: "",
zipcode: "",
country: "US",
companyName: "",
createdAt: null,
updatedAt: null,
});
});
});
describe("normalizeShareClass", () => {
it("reads the documented row in both spellings", () => {
const camel = normalizeShareClass({
id: "sc_1",
idx: 1,
name: "Common",
classType: "COMMON",
initialSharesAuthorized: 10_000_000,
votesPerShare: 1,
parValue: 0.0001,
pricePerShare: 1.25,
conversionRights: "CONVERTS_TO_FUTURE_ROUND",
liquidationPreferenceMultiple: 1,
participationCapMultiple: 0,
boardApprovalDate: "2024-01-01",
});
expect(camel.initialSharesAuthorized).toBe(10_000_000);
expect(camel.pricePerShare).toBe(1.25);
expect(camel.boardApprovalDate).toBeInstanceOf(Date);
const snake = normalizeShareClass({
initial_shares_authorized: 10_000_000,
class_type: "PREFERRED",
price_per_share: 1.25,
converts_to_share_class_id: "sc_0",
liquidation_preference_multiple: 2,
});
expect(snake.initialSharesAuthorized).toBe(10_000_000);
expect(snake.classType).toBe("PREFERRED");
expect(snake.convertsToShareClassId).toBe("sc_0");
expect(snake.liquidationPreferenceMultiple).toBe(2);
});
it("does not turn an absent number into NaN", () => {
const r = normalizeShareClass({ initialSharesAuthorized: "not a number" });
expect(r.initialSharesAuthorized).toBe(0);
expect(Number.isNaN(r.initialSharesAuthorized)).toBe(false);
});
it("keeps an unset conversion target null rather than an empty string", () => {
expect(normalizeShareClass({}).convertsToShareClassId).toBeNull();
});
it("carries the joined company name", () => {
expect(normalizeShareClass({ companyName: "Acme" }).companyName).toBe(
"Acme",
);
});
it("is null for the approval dates the list route does not select", () => {
// listShareClasses selects neither; the UI must show absent, not 1970.
const r = normalizeShareClass({ id: "sc_1", name: "Common" });
expect(r.boardApprovalDate).toBeNull();
expect(r.stockholderApprovalDate).toBeNull();
});
});
describe("normalizeShare", () => {
it("reads the documented row and splits out the joined names", () => {
const r = normalizeShare({
id: "sh_1",
certificateId: "CS-1",
quantity: 1000,
pricePerShare: 0.5,
capitalContribution: 500,
status: "ACTIVE",
issueDate: "2024-02-02",
stakeholderName: "Ada Lovelace",
stakeholderId: "st_1",
shareClassName: "Common",
shareClassType: "COMMON",
shareClassId: "sc_1",
});
expect(r).toMatchObject({
certificateId: "CS-1",
quantity: 1000,
stakeholderName: "Ada Lovelace",
shareClassType: "COMMON",
status: "ACTIVE",
});
expect(r.issueDate).toBeInstanceOf(Date);
});
it("distinguishes a money field the backend omits from one it reports as 0", () => {
// The columns are nullable, so "not reported" must not read as "zero".
expect(normalizeShare({}).capitalContribution).toBeNull();
expect(normalizeShare({ capitalContribution: 0 }).capitalContribution).toBe(
0,
);
});
it("always answers an array for companyLegends", () => {
expect(normalizeShare({}).companyLegends).toEqual([]);
expect(
normalizeShare({ companyLegends: "US_SECURITIES_ACT" }).companyLegends,
).toEqual([]);
expect(
normalizeShare({ company_legends: ["US_SECURITIES_ACT"] }).companyLegends,
).toEqual(["US_SECURITIES_ACT"]);
});
it("degrades an empty row without throwing", () => {
const r: Share = normalizeShare({});
expect(r.id).toBe("");
expect(r.status).toBe("DRAFT");
expect(r.quantity).toBe(0);
expect(r.issueDate).toBeNull();
});
});
describe("normalizeOption, normalizeSafe, normalizeConvertible, normalizeEquityPlan", () => {
it("reads the documented rows", () => {
expect(
normalizeOption({
id: "op_1",
grantId: "GR-1",
quantity: 500,
exercisePrice: 0.1,
type: "ISO",
status: "ACTIVE",
stakeholderName: "Ada",
equityPlanName: "2024 Plan",
}),
).toMatchObject({
grantId: "GR-1",
quantity: 500,
equityPlanName: "2024 Plan",
});
expect(
normalizeSafe({
id: "sa_1",
publicId: "SAFE-01",
type: "POST_MONEY",
capital: 100_000,
valuationCap: 5_000_000,
mfn: true,
pro_rata: "true",
stakeholder_name: "Ada",
}),
).toMatchObject({
publicId: "SAFE-01",
capital: 100_000,
valuationCap: 5_000_000,
mfn: true,
proRata: true,
stakeholderName: "Ada",
});
expect(
normalizeConvertible({
id: "cn_1",
public_id: "CN-01",
type: "NOTE",
capital: 250_000,
conversion_cap: 8_000_000,
interest_rate: 5,
}),
).toMatchObject({
publicId: "CN-01",
capital: 250_000,
conversionCap: 8_000_000,
interestRate: 5,
});
expect(
normalizeEquityPlan({
id: "ep_1",
name: "2024 Plan",
initial_shares_reserved: 1_000_000,
share_class_id: "sc_1",
}),
).toMatchObject({
name: "2024 Plan",
initialSharesReserved: 1_000_000,
shareClassId: "sc_1",
});
});
it("degrades every empty row to its documented default", () => {
expect(normalizeOption({}).type).toBe("ISO");
expect(normalizeOption({}).status).toBe("DRAFT");
expect(normalizeSafe({}).type).toBe("POST_MONEY");
expect(normalizeSafe({}).mfn).toBe(false);
expect(normalizeSafe({}).valuationCap).toBeNull();
expect(normalizeConvertible({}).type).toBe("NOTE");
expect(normalizeEquityPlan({}).comments).toBeNull();
});
});
describe("normalizeSummary", () => {
it("reads the computed cap table the backend publishes", () => {
const summary = normalizeSummary({
company: { id: "co_1", name: "Acme" },
totals: {
outstandingShares: 10_000,
grantedOptions: 2_000,
fullyDilutedShares: 12_000,
stakeholders: 3,
shareClasses: 2,
},
byStakeholder: [
{
stakeholderId: "st_1",
name: "Ada",
shares: 6_000,
options: 0,
fullyDiluted: 6_000,
ownershipPct: 50,
},
],
byShareClass: [
{
shareClassId: "sc_1",
name: "Common",
classType: "COMMON",
authorized: 20_000,
issued: 10_000,
},
],
convertibles: {
safes: { count: 2, capital: 300_000 },
notes: { count: 1, capital: 250_000 },
},
rounds: { count: 1, totalRaised: 550_000 },
});
expect(summary.totals.fullyDilutedShares).toBe(12_000);
expect(summary.byStakeholder).toHaveLength(1);
expect(summary.byStakeholder[0]?.ownershipPct).toBe(50);
expect(summary.byShareClass[0]?.authorized).toBe(20_000);
expect(summary.convertibles.safes.capital).toBe(300_000);
expect(summary.rounds.totalRaised).toBe(550_000);
});
it("answers a zeroed cap table for any payload it cannot read", () => {
for (const payload of GARBAGE) {
const s = normalizeSummary(payload);
expect(s.totals.fullyDilutedShares).toBe(0);
expect(s.byStakeholder).toEqual([]);
expect(s.byShareClass).toEqual([]);
expect(s.convertibles.notes.capital).toBe(0);
expect(s.rounds.count).toBe(0);
expect(s.company.name).toBe("");
}
});
it("keeps the rows it can read when a neighbouring one is malformed", () => {
const s = normalizeSummary({
totals: { outstandingShares: 10 },
byStakeholder: [null, { name: "Ada", fullyDiluted: 5 }, "x"],
});
expect(s.totals.outstandingShares).toBe(10);
expect(s.totals.grantedOptions).toBe(0);
expect(s.byStakeholder).toHaveLength(3);
expect(s.byStakeholder[1]).toMatchObject({ name: "Ada", fullyDiluted: 5 });
expect(s.byStakeholder[0]).toMatchObject({ name: "", fullyDiluted: 0 });
});
});
describe("captableFailure", () => {
it("names the session when the backend refused the principal", () => {
for (const status of [401, 403]) {
expect(
captableFailure(new CaptableApiError(status, "forbidden")),
).toMatch(/Sign in again/);
}
});
it("passes the backend's own reason through for anything else", () => {
expect(
captableFailure(new CaptableApiError(400, "certificateId already taken")),
).toBe("certificateId already taken");
});
it("does not leak an unexpected error's text to the user", () => {
expect(captableFailure(new TypeError("fetch failed: ECONNREFUSED"))).toBe(
"Oops, something went wrong. Please try again later.",
);
});
});
+812
View File
@@ -0,0 +1,812 @@
/**
* The cap table lives in Hanzo Cloud. Every read and write below is one call to
* `/v1/captable`, which resolves the tenant from the bearer's own claims — so a
* company or org id is never sent and never accepted.
*
* Transport is plain REST: raw JSON in, real HTTP status out. List shapes are
* inconsistent across the surface — stakeholders and classes answer a bare
* array, shares/options/safes/convertibles/rounds/investments wrap in `{ data }`
* — so one `rows` unwrapper tolerates both, and every normalizer reads camelCase
* and snake_case and degrades to an honest empty value rather than throwing.
*
* Verbs are per route, measured against the live surface rather than assumed:
* the collections take GET and POST; `company` takes GET and PUT (POST is 404);
* stakeholders, shares, options, safes and convertibles take DELETE on `/{id}`;
* stakeholders and classes take PATCH on `/{id}`, plans do not.
*
* The cap-table MATH is the backend's — `summary` is read, never recomputed.
*/
const DEFAULT_BASE = "https://api.hanzo.ai";
/** In-cluster this is `http://cloud.hanzo.svc:8000`. */
export const captableApiBase = (): string =>
(process.env.CAPTABLE_API_URL || "").trim().replace(/\/+$/, "") ||
DEFAULT_BASE;
/** A refusal from the cap-table backend, carrying the status it refused with. */
export class CaptableApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "CaptableApiError";
this.status = status;
}
}
// ── Pure helpers (honest defaults, never throw) ──────────────────────────────
const str = (v: unknown): string =>
typeof v === "string" ? v : v == null ? "" : String(v);
const bool = (v: unknown): boolean => v === true || v === "true" || v === 1;
const num = (v: unknown): number => {
const n =
typeof v === "number" ? v : typeof v === "string" ? Number(v) : Number.NaN;
return Number.isFinite(n) ? n : 0;
};
/** A wire value that is in the vocabulary, or the default when it is not. */
const oneOf = <T extends string>(
v: unknown,
vocabulary: Record<string, T>,
fallback: T,
): T => {
const s = typeof v === "string" ? v : "";
return (Object.values(vocabulary) as string[]).includes(s)
? (s as T)
: fallback;
};
const asRecord = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
/** A wire date as a `Date`, or null when the field is absent or unparseable. */
export const date = (v: unknown): Date | null => {
const s = str(v);
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
};
/** Unwrap a list response: a bare array, or a `{ data | items | rows }` envelope. */
export const rows = (payload: unknown): Record<string, unknown>[] => {
const keep = (xs: unknown[]) =>
xs.filter((x) => x && typeof x === "object" && !Array.isArray(x)) as Record<
string,
unknown
>[];
if (Array.isArray(payload)) return keep(payload);
const p = asRecord(payload);
for (const k of ["data", "items", "rows"]) {
if (Array.isArray(p[k])) return keep(p[k] as unknown[]);
}
return [];
};
// ── Vocabulary ───────────────────────────────────────────────────────────────
//
// The words the cap-table backend accepts. They were Prisma enums while the cap
// table was a set of local tables; they belong to the contract now, so they live
// beside it. The object shape is what `z.nativeEnum` and `Object.values` read.
export const StakeholderTypeEnum = {
INDIVIDUAL: "INDIVIDUAL",
INSTITUTION: "INSTITUTION",
} as const;
export type StakeholderTypeEnum =
(typeof StakeholderTypeEnum)[keyof typeof StakeholderTypeEnum];
export const StakeholderRelationshipEnum = {
ADVISOR: "ADVISOR",
BOARD_MEMBER: "BOARD_MEMBER",
CONSULTANT: "CONSULTANT",
EMPLOYEE: "EMPLOYEE",
EX_ADVISOR: "EX_ADVISOR",
EX_CONSULTANT: "EX_CONSULTANT",
EX_EMPLOYEE: "EX_EMPLOYEE",
EXECUTIVE: "EXECUTIVE",
FOUNDER: "FOUNDER",
INVESTOR: "INVESTOR",
NON_US_EMPLOYEE: "NON_US_EMPLOYEE",
OFFICER: "OFFICER",
OTHER: "OTHER",
} as const;
export type StakeholderRelationshipEnum =
(typeof StakeholderRelationshipEnum)[keyof typeof StakeholderRelationshipEnum];
export const SecuritiesStatusEnum = {
ACTIVE: "ACTIVE",
DRAFT: "DRAFT",
SIGNED: "SIGNED",
PENDING: "PENDING",
} as const;
export type SecuritiesStatusEnum =
(typeof SecuritiesStatusEnum)[keyof typeof SecuritiesStatusEnum];
export const ShareLegendsEnum = {
US_SECURITIES_ACT: "US_SECURITIES_ACT",
SALE_AND_ROFR: "SALE_AND_ROFR",
TRANSFER_RESTRICTIONS: "TRANSFER_RESTRICTIONS",
} as const;
export type ShareLegendsEnum =
(typeof ShareLegendsEnum)[keyof typeof ShareLegendsEnum];
export const OptionTypeEnum = { ISO: "ISO", NSO: "NSO", RSU: "RSU" } as const;
export type OptionTypeEnum =
(typeof OptionTypeEnum)[keyof typeof OptionTypeEnum];
export const OptionStatusEnum = {
DRAFT: "DRAFT",
ACTIVE: "ACTIVE",
EXERCISED: "EXERCISED",
EXPIRED: "EXPIRED",
CANCELLED: "CANCELLED",
} as const;
export type OptionStatusEnum =
(typeof OptionStatusEnum)[keyof typeof OptionStatusEnum];
/** The YC standard SAFEs this app ships a PDF for, plus a customer's own. */
export const SafeTemplateEnum = {
POST_MONEY_CAP: "POST_MONEY_CAP",
POST_MONEY_DISCOUNT: "POST_MONEY_DISCOUNT",
POST_MONEY_MFN: "POST_MONEY_MFN",
POST_MONEY_CAP_WITH_PRO_RATA: "POST_MONEY_CAP_WITH_PRO_RATA",
POST_MONEY_DISCOUNT_WITH_PRO_RATA: "POST_MONEY_DISCOUNT_WITH_PRO_RATA",
POST_MONEY_MFN_WITH_PRO_RATA: "POST_MONEY_MFN_WITH_PRO_RATA",
CUSTOM: "CUSTOM",
} as const;
export type SafeTemplateEnum =
(typeof SafeTemplateEnum)[keyof typeof SafeTemplateEnum];
// ── Contract (mirrors the console client at hanzoai/console) ─────────────────
export interface Company {
id: string;
name: string;
publicId: string;
incorporationType: string;
incorporationCountry: string;
incorporationState: string;
}
export interface Stakeholder {
id: string;
name: string;
email: string;
institutionName: string;
stakeholderType: StakeholderTypeEnum;
currentRelationship: StakeholderRelationshipEnum;
taxId: string;
streetAddress: string;
city: string;
state: string;
zipcode: string;
country: string;
companyName: string;
createdAt: Date | null;
updatedAt: Date | null;
}
export interface ShareClass {
id: string;
idx: number;
name: string;
classType: string;
prefix: string;
initialSharesAuthorized: number;
boardApprovalDate: Date | null;
stockholderApprovalDate: Date | null;
votesPerShare: number;
parValue: number;
pricePerShare: number;
seniority: number;
conversionRights: string;
convertsToShareClassId: string | null;
liquidationPreferenceMultiple: number;
participationCapMultiple: number;
companyName: string;
}
export interface EquityPlan {
id: string;
name: string;
initialSharesReserved: number;
shareClassId: string;
boardApprovalDate: Date | null;
planEffectiveDate: Date | null;
defaultCancellatonBehavior: string;
comments: string | null;
createdAt: Date | null;
}
export interface Share {
id: string;
certificateId: string;
quantity: number;
pricePerShare: number | null;
capitalContribution: number | null;
ipContribution: number | null;
debtCancelled: number | null;
otherContributions: number | null;
cliffYears: number;
vestingYears: number;
companyLegends: string[];
status: string;
issueDate: Date | null;
rule144Date: Date | null;
vestingStartDate: Date | null;
boardApprovalDate: Date | null;
stakeholderId: string;
stakeholderName: string;
shareClassId: string;
shareClassName: string;
shareClassType: string;
}
export interface OptionGrant {
id: string;
grantId: string;
quantity: number;
exercisePrice: number;
type: string;
status: string;
cliffYears: number;
vestingYears: number;
issueDate: Date | null;
expirationDate: Date | null;
vestingStartDate: Date | null;
boardApprovalDate: Date | null;
rule144Date: Date | null;
stakeholderId: string;
stakeholderName: string;
equityPlanId: string;
equityPlanName: string;
}
export interface Safe {
id: string;
publicId: string;
type: string;
status: string;
capital: number;
safeTemplate: string | null;
valuationCap: number | null;
discountRate: number | null;
mfn: boolean;
proRata: boolean;
additionalTerms: string | null;
issueDate: Date | null;
boardApprovalDate: Date | null;
stakeholderId: string;
stakeholderName: string;
}
export interface Convertible {
id: string;
publicId: string;
type: string;
status: string;
capital: number;
conversionCap: number | null;
discountRate: number | null;
interestRate: number | null;
additionalTerms: string | null;
issueDate: Date | null;
boardApprovalDate: Date | null;
stakeholderId: string;
stakeholderName: string;
}
export interface Round {
id: string;
name: string;
roundType: string;
status: string;
targetAmount: number;
raisedAmount: number;
preMoneyValuation: number;
pricePerShare: number;
shareClassId: string;
closeDate: Date | null;
createdAt: Date | null;
}
export interface Investment {
id: string;
roundId: string;
amount: number;
shares: number;
date: Date | null;
comments: string | null;
stakeholderId: string;
stakeholderName: string;
}
/** The computed cap table (`GET /v1/captable/summary`). */
export interface CapTableSummary {
company: { id: string; name: string };
totals: {
outstandingShares: number;
grantedOptions: number;
fullyDilutedShares: number;
stakeholders: number;
shareClasses: number;
};
byStakeholder: {
stakeholderId: string;
name: string;
shares: number;
options: number;
fullyDiluted: number;
ownershipPct: number;
}[];
byShareClass: {
shareClassId: string;
name: string;
classType: string;
authorized: number;
issued: number;
}[];
convertibles: {
safes: { count: number; capital: number };
notes: { count: number; capital: number };
};
rounds: { count: number; totalRaised: number };
}
// ── Normalizers (pure; defensive) ────────────────────────────────────────────
//
// Every field is read from both spellings the surface uses. A field the backend
// does not carry lands on the empty value its column allows — null where the
// value is optional, 0 or [] where it is not — so a missing measurement is never
// dressed up as a measured one.
const nullable = (v: unknown): number | null =>
v === null || v === undefined || v === "" ? null : num(v);
const text = (v: unknown): string | null => {
const s = str(v);
return s === "" ? null : s;
};
export const normalizeCompany = (payload: unknown): Company => {
const r = asRecord(payload);
return {
id: str(r.id),
name: str(r.name),
publicId: str(r.publicId ?? r.public_id),
incorporationType: str(r.incorporationType ?? r.incorporation_type),
incorporationCountry: str(
r.incorporationCountry ?? r.incorporation_country,
),
incorporationState: str(r.incorporationState ?? r.incorporation_state),
};
};
export const normalizeStakeholder = (
r: Record<string, unknown>,
): Stakeholder => ({
id: str(r.id),
name: str(r.name),
email: str(r.email),
institutionName: str(r.institutionName ?? r.institution_name),
stakeholderType: oneOf(
r.stakeholderType ?? r.stakeholder_type,
StakeholderTypeEnum,
"INDIVIDUAL",
),
currentRelationship: oneOf(
r.currentRelationship ?? r.current_relationship,
StakeholderRelationshipEnum,
"EMPLOYEE",
),
taxId: str(r.taxId ?? r.tax_id),
streetAddress: str(r.streetAddress ?? r.street_address),
city: str(r.city),
state: str(r.state),
zipcode: str(r.zipcode),
country: str(r.country) || "US",
companyName: str(r.companyName ?? r.company_name),
createdAt: date(r.createdAt ?? r.created_at),
updatedAt: date(r.updatedAt ?? r.updated_at),
});
export const normalizeShareClass = (
r: Record<string, unknown>,
): ShareClass => ({
id: str(r.id),
idx: num(r.idx),
name: str(r.name),
classType: str(r.classType ?? r.class_type) || "COMMON",
prefix: str(r.prefix) || "CS",
initialSharesAuthorized: num(
r.initialSharesAuthorized ?? r.initial_shares_authorized,
),
boardApprovalDate: date(r.boardApprovalDate ?? r.board_approval_date),
stockholderApprovalDate: date(
r.stockholderApprovalDate ?? r.stockholder_approval_date,
),
votesPerShare: num(r.votesPerShare ?? r.votes_per_share),
parValue: num(r.parValue ?? r.par_value),
pricePerShare: num(r.pricePerShare ?? r.price_per_share),
seniority: num(r.seniority),
conversionRights:
str(r.conversionRights ?? r.conversion_rights) ||
"CONVERTS_TO_FUTURE_ROUND",
convertsToShareClassId: text(
r.convertsToShareClassId ?? r.converts_to_share_class_id,
),
liquidationPreferenceMultiple: num(
r.liquidationPreferenceMultiple ?? r.liquidation_preference_multiple,
),
participationCapMultiple: num(
r.participationCapMultiple ?? r.participation_cap_multiple,
),
companyName: str(r.companyName ?? r.company_name),
});
export const normalizeEquityPlan = (
r: Record<string, unknown>,
): EquityPlan => ({
id: str(r.id),
name: str(r.name),
initialSharesReserved: num(
r.initialSharesReserved ?? r.initial_shares_reserved,
),
shareClassId: str(r.shareClassId ?? r.share_class_id),
boardApprovalDate: date(r.boardApprovalDate ?? r.board_approval_date),
planEffectiveDate: date(r.planEffectiveDate ?? r.plan_effective_date),
defaultCancellatonBehavior:
str(r.defaultCancellatonBehavior ?? r.default_cancellaton_behavior) ||
"RETURN_TO_POOL",
comments: text(r.comments),
createdAt: date(r.createdAt ?? r.created_at),
});
export const normalizeShare = (r: Record<string, unknown>): Share => ({
id: str(r.id),
certificateId: str(r.certificateId ?? r.certificate_id),
quantity: num(r.quantity),
pricePerShare: nullable(r.pricePerShare ?? r.price_per_share),
capitalContribution: nullable(
r.capitalContribution ?? r.capital_contribution,
),
ipContribution: nullable(r.ipContribution ?? r.ip_contribution),
debtCancelled: nullable(r.debtCancelled ?? r.debt_cancelled),
otherContributions: nullable(r.otherContributions ?? r.other_contributions),
cliffYears: num(r.cliffYears ?? r.cliff_years),
vestingYears: num(r.vestingYears ?? r.vesting_years),
companyLegends: Array.isArray(r.companyLegends ?? r.company_legends)
? ((r.companyLegends ?? r.company_legends) as unknown[]).map(str)
: [],
status: str(r.status) || "DRAFT",
issueDate: date(r.issueDate ?? r.issue_date),
rule144Date: date(r.rule144Date ?? r.rule144_date),
vestingStartDate: date(r.vestingStartDate ?? r.vesting_start_date),
boardApprovalDate: date(r.boardApprovalDate ?? r.board_approval_date),
stakeholderId: str(r.stakeholderId ?? r.stakeholder_id),
stakeholderName: str(r.stakeholderName ?? r.stakeholder_name),
shareClassId: str(r.shareClassId ?? r.share_class_id),
shareClassName: str(r.shareClassName ?? r.share_class_name),
shareClassType: str(r.shareClassType ?? r.share_class_type),
});
export const normalizeOption = (r: Record<string, unknown>): OptionGrant => ({
id: str(r.id),
grantId: str(r.grantId ?? r.grant_id),
quantity: num(r.quantity),
exercisePrice: num(r.exercisePrice ?? r.exercise_price),
type: str(r.type) || "ISO",
status: str(r.status) || "DRAFT",
cliffYears: num(r.cliffYears ?? r.cliff_years),
vestingYears: num(r.vestingYears ?? r.vesting_years),
issueDate: date(r.issueDate ?? r.issue_date),
expirationDate: date(r.expirationDate ?? r.expiration_date),
vestingStartDate: date(r.vestingStartDate ?? r.vesting_start_date),
boardApprovalDate: date(r.boardApprovalDate ?? r.board_approval_date),
rule144Date: date(r.rule144Date ?? r.rule144_date),
stakeholderId: str(r.stakeholderId ?? r.stakeholder_id),
stakeholderName: str(r.stakeholderName ?? r.stakeholder_name),
equityPlanId: str(r.equityPlanId ?? r.equity_plan_id),
equityPlanName: str(r.equityPlanName ?? r.equity_plan_name),
});
export const normalizeSafe = (r: Record<string, unknown>): Safe => ({
id: str(r.id),
publicId: str(r.publicId ?? r.public_id),
type: str(r.type) || "POST_MONEY",
status: str(r.status) || "DRAFT",
capital: num(r.capital),
safeTemplate: text(r.safeTemplate ?? r.safe_template),
valuationCap: nullable(r.valuationCap ?? r.valuation_cap),
discountRate: nullable(r.discountRate ?? r.discount_rate),
mfn: bool(r.mfn),
proRata: bool(r.proRata ?? r.pro_rata),
additionalTerms: text(r.additionalTerms ?? r.additional_terms),
issueDate: date(r.issueDate ?? r.issue_date),
boardApprovalDate: date(r.boardApprovalDate ?? r.board_approval_date),
stakeholderId: str(r.stakeholderId ?? r.stakeholder_id),
stakeholderName: str(r.stakeholderName ?? r.stakeholder_name),
});
export const normalizeConvertible = (
r: Record<string, unknown>,
): Convertible => ({
id: str(r.id),
publicId: str(r.publicId ?? r.public_id),
type: str(r.type) || "NOTE",
status: str(r.status) || "DRAFT",
capital: num(r.capital),
conversionCap: nullable(r.conversionCap ?? r.conversion_cap),
discountRate: nullable(r.discountRate ?? r.discount_rate),
interestRate: nullable(r.interestRate ?? r.interest_rate),
additionalTerms: text(r.additionalTerms ?? r.additional_terms),
issueDate: date(r.issueDate ?? r.issue_date),
boardApprovalDate: date(r.boardApprovalDate ?? r.board_approval_date),
stakeholderId: str(r.stakeholderId ?? r.stakeholder_id),
stakeholderName: str(r.stakeholderName ?? r.stakeholder_name),
});
export const normalizeRound = (r: Record<string, unknown>): Round => ({
id: str(r.id),
name: str(r.name),
roundType: str(r.roundType ?? r.round_type) || "PRICED",
status: str(r.status) || "OPEN",
targetAmount: num(r.targetAmount ?? r.target_amount),
raisedAmount: num(r.raisedAmount ?? r.raised_amount),
preMoneyValuation: num(r.preMoneyValuation ?? r.pre_money_valuation),
pricePerShare: num(r.pricePerShare ?? r.price_per_share),
shareClassId: str(r.shareClassId ?? r.share_class_id),
closeDate: date(r.closeDate ?? r.close_date),
createdAt: date(r.createdAt ?? r.created_at),
});
export const normalizeInvestment = (
r: Record<string, unknown>,
): Investment => ({
id: str(r.id),
roundId: str(r.roundId ?? r.round_id),
amount: num(r.amount),
shares: num(r.shares),
date: date(r.date),
comments: text(r.comments),
stakeholderId: str(r.stakeholderId ?? r.stakeholder_id),
stakeholderName: str(r.stakeholderName ?? r.stakeholder_name),
});
export function normalizeSummary(payload: unknown): CapTableSummary {
const p = asRecord(payload);
const totals = asRecord(p.totals);
const conv = asRecord(p.convertibles);
const safes = asRecord(conv.safes);
const notes = asRecord(conv.notes);
const rnd = asRecord(p.rounds);
const company = asRecord(p.company);
return {
company: { id: str(company.id), name: str(company.name) },
totals: {
outstandingShares: num(totals.outstandingShares),
grantedOptions: num(totals.grantedOptions),
fullyDilutedShares: num(totals.fullyDilutedShares),
stakeholders: num(totals.stakeholders),
shareClasses: num(totals.shareClasses),
},
byStakeholder: (Array.isArray(p.byStakeholder) ? p.byStakeholder : []).map(
(raw) => {
const r = asRecord(raw);
return {
stakeholderId: str(r.stakeholderId),
name: str(r.name),
shares: num(r.shares),
options: num(r.options),
fullyDiluted: num(r.fullyDiluted),
ownershipPct: num(r.ownershipPct),
};
},
),
byShareClass: (Array.isArray(p.byShareClass) ? p.byShareClass : []).map(
(raw) => {
const r = asRecord(raw);
return {
shareClassId: str(r.shareClassId),
name: str(r.name),
classType: str(r.classType),
authorized: num(r.authorized),
issued: num(r.issued),
};
},
),
convertibles: {
safes: { count: num(safes.count), capital: num(safes.capital) },
notes: { count: num(notes.count), capital: num(notes.capital) },
},
rounds: { count: num(rnd.count), totalRaised: num(rnd.totalRaised) },
};
}
// ── Transport ────────────────────────────────────────────────────────────────
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
/** The reason the backend gave, or the status line when it gave none. */
const reasonOf = (status: number, body: string): string => {
try {
const p = asRecord(JSON.parse(body));
const reason = str(p.error || p.message || p.msg);
if (reason) return reason;
} catch {
// A non-JSON body is the reason itself.
}
return body.trim().slice(0, 200) || `cap-table backend returned ${status}`;
};
async function call(
bearer: string,
method: Method,
path: string,
body?: unknown,
): Promise<unknown> {
if (!bearer) {
throw new CaptableApiError(
401,
"No Hanzo access token on this session. Sign in again.",
);
}
const res = await fetch(`${captableApiBase()}/v1/captable/${path}`, {
method,
headers: {
authorization: `Bearer ${bearer}`,
"content-type": "application/json",
accept: "application/json",
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
cache: "no-store",
});
const raw = await res.text();
if (!res.ok)
throw new CaptableApiError(res.status, reasonOf(res.status, raw));
if (!raw) return null;
try {
return JSON.parse(raw) as unknown;
} catch {
throw new CaptableApiError(
502,
`cap-table backend answered ${res.status} with a body that is not JSON`,
);
}
}
const seg = (id: string): string => encodeURIComponent(id);
/**
* What to tell the person whose cap-table write did not land. A refusal carries
* the backend's own reason, because "something went wrong" sends them nowhere.
*/
export const captableFailure = (error: unknown): string => {
if (!(error instanceof CaptableApiError)) {
return "Oops, something went wrong. Please try again later.";
}
return error.status === 401 || error.status === 403
? "Your Hanzo session cannot reach the cap table. Sign in again."
: error.message;
};
/**
* The cap-table surface as the signed-in user. The bearer decides the tenant,
* so two callers holding two tokens can never read each other's cap table.
*/
export function captableApi(bearer: string) {
const get = (path: string) => call(bearer, "GET", path);
const post = (path: string, body: unknown) =>
call(bearer, "POST", path, body);
return {
summary: (): Promise<CapTableSummary> =>
get("summary").then(normalizeSummary),
company: {
get: (): Promise<Company> => get("company").then(normalizeCompany),
update: (body: Record<string, unknown>): Promise<void> =>
call(bearer, "PUT", "company", body).then(() => undefined),
},
stakeholders: {
list: (): Promise<Stakeholder[]> =>
get("stakeholders").then((p) => rows(p).map(normalizeStakeholder)),
add: (body: unknown): Promise<void> =>
post("stakeholders", body).then(() => undefined),
update: (id: string, body: Record<string, unknown>): Promise<void> =>
call(bearer, "PATCH", `stakeholders/${seg(id)}`, body).then(
() => undefined,
),
remove: (id: string): Promise<void> =>
call(bearer, "DELETE", `stakeholders/${seg(id)}`).then(() => undefined),
},
classes: {
list: (): Promise<ShareClass[]> =>
get("classes").then((p) => rows(p).map(normalizeShareClass)),
add: (body: Record<string, unknown>): Promise<void> =>
post("classes", body).then(() => undefined),
update: (id: string, body: Record<string, unknown>): Promise<void> =>
call(bearer, "PATCH", `classes/${seg(id)}`, body).then(() => undefined),
},
plans: {
list: (): Promise<EquityPlan[]> =>
get("plans").then((p) => rows(p).map(normalizeEquityPlan)),
add: (body: Record<string, unknown>): Promise<void> =>
post("plans", body).then(() => undefined),
},
shares: {
list: (): Promise<Share[]> =>
get("shares").then((p) => rows(p).map(normalizeShare)),
add: (body: Record<string, unknown>): Promise<void> =>
post("shares", body).then(() => undefined),
transfer: (body: Record<string, unknown>): Promise<void> =>
post("shares/transfer", body).then(() => undefined),
remove: (id: string): Promise<void> =>
call(bearer, "DELETE", `shares/${seg(id)}`).then(() => undefined),
},
options: {
list: (): Promise<OptionGrant[]> =>
get("options").then((p) => rows(p).map(normalizeOption)),
add: (body: Record<string, unknown>): Promise<void> =>
post("options", body).then(() => undefined),
remove: (id: string): Promise<void> =>
call(bearer, "DELETE", `options/${seg(id)}`).then(() => undefined),
},
safes: {
list: (): Promise<Safe[]> =>
get("safes").then((p) => rows(p).map(normalizeSafe)),
add: (body: Record<string, unknown>): Promise<void> =>
post("safes", body).then(() => undefined),
remove: (id: string): Promise<void> =>
call(bearer, "DELETE", `safes/${seg(id)}`).then(() => undefined),
},
convertibles: {
list: (): Promise<Convertible[]> =>
get("convertibles").then((p) => rows(p).map(normalizeConvertible)),
add: (body: Record<string, unknown>): Promise<void> =>
post("convertibles", body).then(() => undefined),
remove: (id: string): Promise<void> =>
call(bearer, "DELETE", `convertibles/${seg(id)}`).then(() => undefined),
},
rounds: {
list: (): Promise<Round[]> =>
get("rounds").then((p) => rows(p).map(normalizeRound)),
add: (body: Record<string, unknown>): Promise<void> =>
post("rounds", body).then(() => undefined),
close: (id: string): Promise<void> =>
post(`rounds/${seg(id)}/close`, {}).then(() => undefined),
addInvestment: (
id: string,
body: Record<string, unknown>,
): Promise<void> =>
post(`rounds/${seg(id)}/investments`, body).then(() => undefined),
},
investments: {
list: (): Promise<Investment[]> =>
get("investments").then((p) => rows(p).map(normalizeInvestment)),
},
};
}
export type CaptableApi = ReturnType<typeof captableApi>;
+68
View File
@@ -0,0 +1,68 @@
import type { TPrismaOrTransaction } from "@/server/db";
/**
* A security's paperwork is this app's own — S3 buckets, member uploaders, data
* rooms, e-sign — so it stays here while the security it describes lives in
* Hanzo Cloud. The link column holds the id the backend assigned, which is why
* it is a plain column and no longer a foreign key.
*/
export type SecurityLink =
| "shareId"
| "optionId"
| "safeId"
| "convertibleNoteId";
const SELECT = {
id: true,
name: true,
uploader: { select: { user: { select: { name: true, image: true } } } },
bucket: { select: { key: true, mimeType: true, size: true } },
} as const;
export interface SecurityDocument {
id: string;
name: string;
uploader: { user: { name: string | null; image: string | null } } | null;
bucket: { key: string; mimeType: string; size: number };
}
/** The documents attached to each of `ids`, keyed by security id. */
export async function documentsBySecurity(
db: TPrismaOrTransaction,
link: SecurityLink,
ids: string[],
): Promise<Map<string, SecurityDocument[]>> {
const byId = new Map<string, SecurityDocument[]>();
if (ids.length === 0) return byId;
const where =
link === "shareId"
? { shareId: { in: ids } }
: link === "optionId"
? { optionId: { in: ids } }
: link === "safeId"
? { safeId: { in: ids } }
: { convertibleNoteId: { in: ids } };
const rows = await db.document.findMany({
where,
select: {
...SELECT,
shareId: true,
optionId: true,
safeId: true,
convertibleNoteId: true,
},
});
for (const row of rows) {
const { shareId, optionId, safeId, convertibleNoteId, ...doc } = row;
const key = { shareId, optionId, safeId, convertibleNoteId }[link];
if (!key) continue;
const list = byId.get(key);
if (list) list.push(doc);
else byId.set(key, [doc]);
}
return byId;
}
+10 -2
View File
@@ -18,7 +18,8 @@ import {
checkAccessControlMembership,
getPermissions,
} from "@/lib/rbac/access-control";
import { getServerAuthSession } from "@/server/auth";
import { getServerAuthSession, hanzoAccessToken } from "@/server/auth";
import { captableApi } from "@/server/captable-api";
import { db } from "@/server/db";
import * as Sentry from "@sentry/nextjs";
@@ -39,11 +40,18 @@ interface Meta {
* @see https://trpc.io/docs/server/context
*/
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await getServerAuthSession();
const [session, bearer] = await Promise.all([
getServerAuthSession(),
hanzoAccessToken(opts.headers),
]);
return {
db,
session,
// The cap table is read and written as this user against Hanzo Cloud, which
// derives the tenant from the token itself. Server-side only — it is on the
// context, never on the session.
captable: captableApi(bearer),
requestIp: getIp(opts.headers),
userAgent: getUserAgent(opts.headers),
...opts,
+21 -24
View File
@@ -3,39 +3,36 @@ import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
export const commonRouter = createTRPCRouter({
getContacts: withAuth.query(async ({ ctx }) => {
const { db, session } = ctx;
const { db, captable, session } = ctx;
const user = session.user;
const companyId = user.companyId;
const contacts = [] as ShareContactType[];
const members = await db.member.findMany({
where: {
companyId,
},
const [members, stakeholders] = await Promise.all([
db.member.findMany({
where: {
companyId,
},
include: {
user: {
select: {
email: true,
name: true,
image: true,
include: {
user: {
select: {
email: true,
name: true,
image: true,
},
},
},
},
});
const stakeholders = await db.stakeholder.findMany({
where: {
companyId,
},
});
}),
captable.stakeholders.list(),
]);
(members || []).map((member) =>
contacts.push({
id: member.id,
image: member.user.image!,
email: member.user.email!,
value: member.user.email!,
name: member.user.name!,
image: member.user.image ?? undefined,
email: member.user.email ?? "",
value: member.user.email ?? "",
name: member.user.name ?? "",
type: "member",
}),
);
@@ -45,7 +42,7 @@ export const commonRouter = createTRPCRouter({
email: stakeholder.email,
value: stakeholder.email,
name: stakeholder.name,
institutionName: stakeholder.institutionName!,
institutionName: stakeholder.institutionName,
type: "stakeholder",
}),
);
@@ -5,6 +5,10 @@ import { ZodOnboardingMutationSchema } from "../onboarding-router/schema";
import { ZodSwitchCompanyMutationSchema } from "./schema";
export const companyRouter = createTRPCRouter({
// Stays local: `GET /v1/captable/company` carries id, name, publicId and the
// three incorporation fields, while this read also feeds the settings form the
// member's title and the company's logo, website, address, city, state,
// zipcode, country and incorporationDate. Moving it needs those on that route.
getCompany: withAuth.query(async ({ ctx }) => {
const user = ctx.session.user;
const companyId = user.companyId;
+43 -107
View File
@@ -1,129 +1,65 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
import { EquityPlanMutationSchema } from "./schema";
/** The wire body an equity plan is created with. */
const body = (input: Record<string, unknown>) => ({
name: input.name,
boardApprovalDate: (input.boardApprovalDate as Date).toISOString(),
planEffectiveDate: input.planEffectiveDate
? (input.planEffectiveDate as Date).toISOString()
: null,
initialSharesReserved: input.initialSharesReserved,
shareClassId: input.shareClassId,
defaultCancellatonBehavior: input.defaultCancellatonBehavior,
comments: input.comments,
});
export const equityPlanRouter = createTRPCRouter({
getPlans: withAuth.query(async ({ ctx }) => {
const { db, session } = ctx;
const data = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
const data = await tx.equityPlan.findMany({
where: {
companyId,
},
orderBy: {
createdAt: "desc",
},
});
return data;
});
return { data };
}),
getPlans: withAuth.query(async ({ ctx }) => ({
data: await ctx.captable.plans.list(),
})),
create: withAuth
.input(EquityPlanMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp, session } = ctx;
const { userAgent, requestIp, captable, db, session } = ctx;
try {
await ctx.db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
await captable.plans.add(body(input));
const data = {
const { companyId } = await checkMembership({ tx: db, session });
await Audit.create(
{
action: "equityPlan.created",
companyId,
name: input.name,
planEffectiveDate: input.planEffectiveDate
? new Date(input.planEffectiveDate)
: null,
boardApprovalDate: new Date(input.boardApprovalDate),
initialSharesReserved: input.initialSharesReserved,
shareClassId: input.shareClassId,
comments: input.comments,
defaultCancellatonBehavior: input.defaultCancellatonBehavior,
};
await tx.equityPlan.create({ data });
await Audit.create(
{
action: "equityPlan.created",
companyId,
actor: { type: "user", id: ctx.session.user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "company", id: companyId }],
summary: `${ctx.session.user.name} created an equity plan - ${input.name}`,
},
tx,
);
});
actor: { type: "user", id: session.user.id },
context: { requestIp, userAgent },
target: [{ type: "company", id: companyId }],
summary: `${session.user.name} created an equity plan - ${input.name}`,
},
db,
);
return { success: true, message: "Equity plan created successfully." };
} catch (error) {
console.error("Error creating an equity plan:", error);
return {
success: false,
message: "Oops, something went wrong. Please try again later.",
};
return { success: false, message: captableFailure(error) };
}
}),
update: withAuth
.input(EquityPlanMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { userAgent, requestIp, session } = ctx;
await ctx.db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ tx, session });
const data = {
name: input.name,
planEffectiveDate: input.planEffectiveDate
? new Date(input.planEffectiveDate)
: null,
boardApprovalDate: new Date(input.boardApprovalDate),
initialSharesReserved: input.initialSharesReserved,
shareClassId: input.shareClassId,
comments: input.comments,
defaultCancellatonBehavior: input.defaultCancellatonBehavior,
};
await tx.equityPlan.update({
where: { id: input.id },
data,
});
await Audit.create(
{
action: "equityPlan.updated",
companyId,
actor: { type: "user", id: ctx.session.user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "company", id: companyId }],
summary: `${ctx.session.user.name} updated an equity plan - ${input.name}`,
},
tx,
);
});
return { success: true, message: "Equity plan updated successfully." };
} catch (error) {
console.error("Error updating an equity plan:", error);
return {
success: false,
message: "Oops, something went wrong. Please try again later.",
};
}
}),
// The cap-table backend serves GET and POST on /v1/captable/plans and nothing
// on /v1/captable/plans/{id} — measured, PUT/PATCH/POST all 404 there while
// the sibling classes route answers PATCH. Amending a plan needs
// `PATCH /v1/captable/plans/{id}`.
update: withAuth.input(EquityPlanMutationSchema).mutation(({ input }) => {
console.error("equityPlan.update: no upstream route", { id: input.id });
return {
success: false,
message:
"Editing an equity plan is not available yet. Hanzo Cloud serves no update route for plans.",
};
}),
});
@@ -1,69 +1,61 @@
import { generatePublicId } from "@/common/id";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { ZodAddExistingSafeMutationSchema } from "../schema";
import { safeBody } from "../wire";
export const addExistingSafeProcedure = withAuth
.input(ZodAddExistingSafeMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp, session } = ctx;
const user = ctx.session.user;
const { userAgent, requestIp, captable, db, session } = ctx;
const user = session.user;
const { documents, ...rest } = input;
try {
await ctx.db.$transaction(async (tx) => {
const { companyId, memberId } = await checkMembership({ session, tx });
const publicId = generatePublicId();
await captable.safes.add(safeBody(publicId, rest));
const safe = await tx.safe.create({
data: {
publicId: generatePublicId(),
companyId,
...rest,
issueDate: new Date(input.issueDate),
boardApprovalDate: new Date(input.boardApprovalDate),
},
});
const bulkDocuments = documents.map((doc) => ({
companyId,
uploaderId: memberId,
publicId: generatePublicId(),
name: doc.name,
bucketId: doc.bucketId,
safeId: safe.id,
}));
await tx.document.createMany({
data: bulkDocuments,
skipDuplicates: true,
});
await Audit.create(
{
action: "safe.imported",
companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "company", id: companyId }],
summary: `${user.name} imported existing SAFEs.`,
},
tx,
);
const { companyId, memberId } = await checkMembership({
tx: db,
session,
});
return {
success: true,
message: "SAFEs imported for the stakeholder.",
};
if (documents.length > 0) {
// The create answers with no body; the public id is unique per company.
const safe = (await captable.safes.list()).find(
(s) => s.publicId === publicId,
);
await db.document.createMany({
data: documents.map((doc) => ({
companyId,
uploaderId: memberId,
publicId: generatePublicId(),
name: doc.name,
bucketId: doc.bucketId,
safeId: safe?.id ?? null,
})),
skipDuplicates: true,
});
}
await Audit.create(
{
action: "safe.imported",
companyId,
actor: { type: "user", id: user.id },
context: { userAgent, requestIp },
target: [{ type: "company", id: companyId }],
summary: `${user.name} imported existing SAFEs.`,
},
db,
);
return { success: true, message: "SAFEs imported for the stakeholder." };
} catch (error) {
console.error("Error adding existing SAFEs:", error);
return {
success: false,
message: "Something went wrong.",
};
return { success: false, message: captableFailure(error) };
}
});
+16 -48
View File
@@ -6,22 +6,27 @@ import { invariant } from "@/lib/error";
import { TAG } from "@/lib/tags";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import type { Prisma } from "@prisma/client";
import { createBucketHandler } from "../../bucket-router/procedures/create-bucket";
import { createTemplateHandler } from "../../template-router/procedures/create-template";
import { ZodCreateSafeMutationSchema } from "../schema";
import { safeBody } from "../wire";
export const createSafeProcedure = withAuth
.input(ZodCreateSafeMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp, session } = ctx;
const user = ctx.session.user;
const { userAgent, requestIp, session, captable, db } = ctx;
const user = session.user;
const safeTemplate = input.safeTemplate;
const { orderedDelivery, recipients, ...inputRest } = input;
try {
// The SAFE lands upstream first. The bucket, template and audit below are
// this app's own rows, so a refusal up there leaves nothing behind here.
await captable.safes.add(safeBody(generatePublicId(), inputRest));
let uploadData: Awaited<ReturnType<typeof uploadFile>> | null = null;
let document: { name: string; bucketId: string } | null = null;
@@ -43,19 +48,13 @@ export const createSafeProcedure = withAuth
uploadData = await uploadFile(
file,
{
identifier: "templates",
keyPrefix: "new-safes",
},
{ identifier: "templates", keyPrefix: "new-safes" },
"privateBucket",
);
}
const { template } = await ctx.db.$transaction(async (tx) => {
const { companyId, memberId } = await checkMembership({
session,
tx,
});
const { template } = await db.$transaction(async (tx) => {
const { companyId, memberId } = await checkMembership({ session, tx });
if (uploadData) {
const { fileUrl: _fileUrl, ...rest } = uploadData;
@@ -96,42 +95,14 @@ export const createSafeProcedure = withAuth
},
});
type SafeCreateBody = Prisma.Args<typeof ctx.db.safe, "create">["data"];
let safeData: null | SafeCreateBody;
if (inputRest.safeTemplate === "CUSTOM") {
const { document, ...rest } = inputRest;
safeData = {
...rest,
publicId: generatePublicId(),
companyId,
boardApprovalDate: new Date(rest.boardApprovalDate),
issueDate: new Date(rest.issueDate),
};
} else {
safeData = {
...inputRest,
publicId: generatePublicId(),
companyId,
boardApprovalDate: new Date(inputRest.boardApprovalDate),
issueDate: new Date(inputRest.issueDate),
};
}
await tx.safe.create({
data: safeData,
});
await Audit.create(
{
action: "safe.created",
companyId: user.companyId,
actor: { type: "user", id: ctx.session.user.id },
companyId,
actor: { type: "user", id: user.id },
context: { requestIp, userAgent },
target: [{ type: "company", id: user.companyId }],
summary: `${ctx.session.user.name} created a new SAFE agreement with YC template.`,
target: [{ type: "company", id: companyId }],
summary: `${user.name} created a new SAFE agreement with YC template.`,
},
tx,
);
@@ -146,9 +117,6 @@ export const createSafeProcedure = withAuth
};
} catch (error) {
console.error("Error creating safe:", error);
return {
success: false as const,
message: "Oops ! Something went wrong. Please try again later",
};
return { success: false as const, message: captableFailure(error) };
}
});
+30 -65
View File
@@ -1,75 +1,40 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc";
import {
type TypeZodDeleteSafesMutationSchema,
ZodDeleteSafesMutationSchema,
} from "../schema";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { ZodDeleteSafesMutationSchema } from "../schema";
export const deleteSafeProcedure = withAuth
.input(ZodDeleteSafesMutationSchema)
.mutation(async (args) => {
return await deleteSafeHandler(args);
});
.mutation(
async ({ ctx: { db, captable, session, requestIp, userAgent }, input }) => {
const user = session.user;
const { safeId } = input;
interface deleteSafeHandlerOptions {
input: TypeZodDeleteSafesMutationSchema;
ctx: withAuthTrpcContextType;
}
try {
const safe = (await captable.safes.list()).find((s) => s.id === safeId);
export async function deleteSafeHandler({
ctx: { db, session, requestIp, userAgent },
input,
}: deleteSafeHandlerOptions) {
const user = session.user;
const { safeId } = input;
try {
await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ tx, session });
await captable.safes.remove(safeId);
const safe = await tx.safe.delete({
where: {
id: safeId,
companyId,
},
select: {
id: true,
stakeholder: {
select: {
id: true,
name: true,
},
const { companyId } = await checkMembership({ tx: db, session });
await Audit.create(
{
action: "safe.deleted",
companyId,
actor: { type: "user", id: user.id },
context: { requestIp, userAgent },
target: [{ type: "company", id: companyId }],
summary: `${user.name} deleted safe agreement of stakholder ${
safe?.stakeholderName ?? safeId
}`,
},
company: {
select: {
name: true,
},
},
},
});
db,
);
await Audit.create(
{
action: "safe.deleted",
companyId,
actor: { type: "user", id: session.user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "company", id: companyId }],
summary: `${user.name} deleted safe agreement of stakholder ${safe.stakeholder.name}`,
},
tx,
);
});
return { success: true };
} catch (err) {
console.error(err);
return {
success: false,
message: "Oops, something went wrong while deleting option.",
};
}
}
return { success: true };
} catch (error) {
console.error(error);
return { success: false, message: captableFailure(error) };
}
},
);
+13 -54
View File
@@ -1,61 +1,20 @@
import { checkMembership } from "@/server/auth";
import { documentsBySecurity } from "@/server/captable-documents";
import { withAuth } from "@/trpc/api/trpc";
export const getSafesProcedure = withAuth.query(
async ({ ctx: { db, session } }) => {
const data = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ tx, session });
const data = await tx.safe.findMany({
where: {
companyId,
},
select: {
id: true,
publicId: true,
type: true,
status: true,
capital: true,
safeTemplate: true,
valuationCap: true,
discountRate: true,
mfn: true,
proRata: true,
additionalTerms: true,
issueDate: true,
boardApprovalDate: true,
stakeholder: {
select: {
name: true,
},
},
documents: {
select: {
id: true,
name: true,
uploader: {
select: {
user: {
select: {
name: true,
image: true,
},
},
},
},
bucket: {
select: {
key: true,
mimeType: true,
size: true,
},
},
},
},
},
});
async ({ ctx: { db, captable } }) => {
const safes = await captable.safes.list();
const documents = await documentsBySecurity(
db,
"safeId",
safes.map((safe) => safe.id),
);
return data;
});
const data = safes.map(({ stakeholderName, ...safe }) => ({
...safe,
stakeholder: { name: stakeholderName },
documents: documents.get(safe.id) ?? [],
}));
return { data };
},
+1 -1
View File
@@ -1,4 +1,4 @@
import { SafeTemplateEnum } from "@/prisma/enums";
import { SafeTemplateEnum } from "@/server/captable-api";
import { z } from "zod";
import { ZodTemplateFieldRecipientSchema } from "../template-router/schema";
+30
View File
@@ -0,0 +1,30 @@
/**
* The body `POST /v1/captable/safes` accepts. `type` and `status` are required
* there and were never collected by either SAFE form — they came from the column
* defaults while a SAFE was a local row, so they are stated here instead.
*
* The app-only fields (`safeId`, `safeTemplate`, `document`, `recipients`) are
* deliberately absent: they describe this app's e-sign paperwork, not the SAFE.
*/
export interface SafeTerms {
stakeholderId: string;
capital: number;
valuationCap: number;
discountRate?: number;
proRata: boolean;
issueDate: string;
boardApprovalDate: string;
}
export const safeBody = (publicId: string, terms: SafeTerms) => ({
publicId,
stakeholderId: terms.stakeholderId,
type: "POST_MONEY",
status: "DRAFT",
capital: terms.capital,
valuationCap: terms.valuationCap,
discountRate: terms.discountRate,
proRata: terms.proRata,
issueDate: terms.issueDate,
boardApprovalDate: terms.boardApprovalDate,
});
@@ -1,80 +1,71 @@
import { generatePublicId } from "@/common/id";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { ZodAddOptionMutationSchema } from "../schema";
export const addOptionProcedure = withAuth
.input(ZodAddOptionMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp } = ctx;
const { userAgent, requestIp, captable, db, session } = ctx;
const user = session.user;
try {
const user = ctx.session.user;
const documents = input.documents;
await captable.options.add({
stakeholderId: input.stakeholderId,
equityPlanId: input.equityPlanId,
notes: input.notes,
grantId: input.grantId,
quantity: input.quantity,
exercisePrice: input.exercisePrice,
type: input.type,
status: input.status,
cliffYears: input.cliffYears,
vestingYears: input.vestingYears,
issueDate: input.issueDate,
expirationDate: input.expirationDate,
vestingStartDate: input.vestingStartDate,
boardApprovalDate: input.boardApprovalDate,
rule144Date: input.rule144Date,
});
await ctx.db.$transaction(async (tx) => {
const { companyId } = await checkMembership({
tx,
session: ctx.session,
});
const { companyId } = await checkMembership({ tx: db, session });
const data = {
companyId,
stakeholderId: input.stakeholderId,
equityPlanId: input.equityPlanId,
notes: input.notes,
grantId: input.grantId,
quantity: input.quantity,
exercisePrice: input.exercisePrice,
type: input.type,
status: input.status,
cliffYears: input.cliffYears,
vestingYears: input.vestingYears,
issueDate: new Date(input.issueDate),
expirationDate: new Date(input.expirationDate),
vestingStartDate: new Date(input.vestingStartDate),
boardApprovalDate: new Date(input.boardApprovalDate),
rule144Date: new Date(input.rule144Date),
};
if (input.documents.length > 0) {
// The create answers with no body; the grant id is unique per company.
const option = (await captable.options.list()).find(
(o) => o.grantId === input.grantId,
);
const option = await tx.option.create({ data });
const bulkDocuments = documents.map((doc) => ({
companyId,
uploaderId: user.memberId,
publicId: generatePublicId(),
name: doc.name,
bucketId: doc.bucketId,
optionId: option.id,
}));
await tx.document.createMany({
data: bulkDocuments,
await db.document.createMany({
data: input.documents.map((doc) => ({
companyId,
uploaderId: user.memberId,
publicId: generatePublicId(),
name: doc.name,
bucketId: doc.bucketId,
optionId: option?.id ?? null,
})),
skipDuplicates: true,
});
}
await Audit.create(
{
action: "option.created",
companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "company", id: companyId }],
summary: `${user.name} added stock option for stakeholder ${input.stakeholderId}`,
},
tx,
);
});
await Audit.create(
{
action: "option.created",
companyId,
actor: { type: "user", id: user.id },
context: { userAgent, requestIp },
target: [{ type: "company", id: companyId }],
summary: `${user.name} added stock option for stakeholder ${input.stakeholderId}`,
},
db,
);
return { success: true, message: "🎉 Successfully added an option" };
} catch (error) {
console.error("Error adding options:", error);
return {
success: false,
message: "Please use unique Grant Id.",
};
return { success: false, message: captableFailure(error) };
}
});
@@ -1,87 +1,74 @@
import { generatePublicId } from "@/common/id";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { ZodAddShareMutationSchema } from "../schema";
export const addShareProcedure = withAuth
.input(ZodAddShareMutationSchema)
.mutation(async ({ ctx, input }) => {
console.log({ input }, "#############");
const { userAgent, requestIp } = ctx;
const { userAgent, requestIp, captable, db, session } = ctx;
const user = session.user;
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 = {
companyId,
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.create({ data });
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.created",
companyId: user.companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "share", id: share.id }],
summary: `${user.name} added share for stakeholder ${input.stakeholderId}`,
},
tx,
);
await captable.shares.add({
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: input.issueDate,
rule144Date: input.rule144Date,
vestingStartDate: input.vestingStartDate,
boardApprovalDate: input.boardApprovalDate,
});
return {
success: true,
message: "🎉 Successfully added a share",
};
const { companyId } = await checkMembership({ tx: db, session });
if (input.documents.length > 0) {
// The create answers with no body, so the new row is found by the
// certificate id — which the backend enforces unique per company.
const share = (await captable.shares.list()).find(
(s) => s.certificateId === input.certificateId,
);
await db.document.createMany({
data: input.documents.map((doc) => ({
companyId,
uploaderId: user.memberId,
publicId: generatePublicId(),
name: doc.name,
bucketId: doc.bucketId,
shareId: share?.id ?? null,
})),
skipDuplicates: true,
});
}
await Audit.create(
{
action: "share.created",
companyId,
actor: { type: "user", id: user.id },
context: { userAgent, requestIp },
target: [{ type: "share", id: input.certificateId }],
summary: `${user.name} added share for stakeholder ${input.stakeholderId}`,
},
db,
);
return { success: true, message: "🎉 Successfully added a share" };
} catch (error) {
console.error("Error adding shares: ", error);
return {
success: false,
message: "Please use unique Certificate Id.",
};
return { success: false, message: captableFailure(error) };
}
});
@@ -1,75 +1,42 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc";
import {
type TypeZodDeleteOptionMutationSchema,
ZodDeleteOptionMutationSchema,
} from "../schema";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { ZodDeleteOptionMutationSchema } from "../schema";
export const deleteOptionProcedure = withAuth
.input(ZodDeleteOptionMutationSchema)
.mutation(async (args) => {
return await deleteOptionHandler(args);
});
.mutation(
async ({ ctx: { db, captable, session, requestIp, userAgent }, input }) => {
const user = session.user;
const { optionId } = input;
interface deleteOptionHandlerOptions {
input: TypeZodDeleteOptionMutationSchema;
ctx: withAuthTrpcContextType;
}
try {
const option = (await captable.options.list()).find(
(o) => o.id === optionId,
);
export async function deleteOptionHandler({
ctx: { db, session, requestIp, userAgent },
input,
}: deleteOptionHandlerOptions) {
const user = session.user;
const { optionId } = input;
try {
await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
await captable.options.remove(optionId);
const option = await tx.option.delete({
where: {
id: optionId,
companyId,
},
select: {
id: true,
stakeholder: {
select: {
id: true,
name: true,
},
const { companyId } = await checkMembership({ tx: db, session });
await Audit.create(
{
action: "option.deleted",
companyId,
actor: { type: "user", id: user.id },
context: { requestIp, userAgent },
target: [{ type: "option", id: optionId }],
summary: `${user.name} deleted stock option of stakholder ${
option?.stakeholderName ?? optionId
}`,
},
company: {
select: {
name: true,
},
},
},
});
db,
);
await Audit.create(
{
action: "option.deleted",
companyId: user.companyId,
actor: { type: "user", id: session.user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "option", id: option.id }],
summary: `${user.name} deleted stock option of stakholder ${option.stakeholder.name}`,
},
tx,
);
});
return { success: true };
} catch (err) {
console.error(err);
return {
success: false,
message: "Oops, something went wrong while deleting option.",
};
}
}
return { success: true };
} catch (error) {
console.error(error);
return { success: false, message: captableFailure(error) };
}
},
);
@@ -1,75 +1,44 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { withAuth, type withAuthTrpcContextType } from "@/trpc/api/trpc";
import {
type TypeZodDeleteShareMutationSchema,
ZodDeleteShareMutationSchema,
} from "../schema";
import { captableFailure } from "@/server/captable-api";
import { withAuth } from "@/trpc/api/trpc";
import { ZodDeleteShareMutationSchema } from "../schema";
export const deleteShareProcedure = withAuth
.input(ZodDeleteShareMutationSchema)
.mutation(async (args) => {
return await deleteShareHandler(args);
});
.mutation(
async ({ ctx: { db, captable, session, requestIp, userAgent }, input }) => {
const user = session.user;
const { shareId } = input;
interface deleteShareHandlerOptions {
input: TypeZodDeleteShareMutationSchema;
ctx: withAuthTrpcContextType;
}
try {
// Read the row first so the audit entry still names the stakeholder it
// belonged to — a delete answers with no body to name them from.
const share = (await captable.shares.list()).find(
(s) => s.id === shareId,
);
export async function deleteShareHandler({
ctx: { db, session, requestIp, userAgent },
input,
}: deleteShareHandlerOptions) {
const user = session.user;
const { shareId } = input;
try {
await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
await captable.shares.remove(shareId);
const share = await tx.share.delete({
where: {
id: shareId,
companyId,
},
select: {
id: true,
stakeholder: {
select: {
id: true,
name: true,
},
const { companyId } = await checkMembership({ tx: db, session });
await Audit.create(
{
action: "share.deleted",
companyId,
actor: { type: "user", id: user.id },
context: { requestIp, userAgent },
target: [{ type: "share", id: shareId }],
summary: `${user.name} deleted share of stakholder ${
share?.stakeholderName ?? shareId
}`,
},
company: {
select: {
name: true,
},
},
},
});
db,
);
await Audit.create(
{
action: "share.deleted",
companyId: user.companyId,
actor: { type: "user", id: session.user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "share", id: share.id }],
summary: `${user.name} deleted share of stakholder ${share.stakeholder.name}`,
},
tx,
);
});
return { success: true };
} catch (err) {
console.error(err);
return {
success: false,
message: "Oops, something went wrong while deleting option.",
};
}
}
return { success: true };
} catch (error) {
console.error(error);
return { success: false, message: captableFailure(error) };
}
},
);
@@ -1,62 +1,23 @@
import { checkMembership } from "@/server/auth";
import { documentsBySecurity } from "@/server/captable-documents";
import { withAuth } from "@/trpc/api/trpc";
export const getOptionsProcedure = withAuth.query(
async ({ ctx: { db, session } }) => {
const data = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
async ({ ctx: { db, captable } }) => {
const options = await captable.options.list();
const documents = await documentsBySecurity(
db,
"optionId",
options.map((option) => option.id),
);
const option = await tx.option.findMany({
where: {
companyId,
},
select: {
id: true,
grantId: true,
quantity: true,
exercisePrice: true,
type: true,
status: true,
cliffYears: true,
vestingYears: true,
issueDate: true,
expirationDate: true,
vestingStartDate: true,
boardApprovalDate: true,
rule144Date: true,
stakeholder: {
select: {
name: true,
},
},
documents: {
select: {
id: true,
name: true,
uploader: {
select: {
user: {
select: {
name: true,
image: true,
},
},
},
},
bucket: {
select: {
key: true,
mimeType: true,
size: true,
},
},
},
},
},
});
return option;
});
const data = options.map(
({ stakeholderName, equityPlanName, ...option }) => ({
...option,
stakeholder: { name: stakeholderName },
equityPlan: { name: equityPlanName },
documents: documents.get(option.id) ?? [],
}),
);
return { data };
},
@@ -1,71 +1,24 @@
import { checkMembership } from "@/server/auth";
import { documentsBySecurity } from "@/server/captable-documents";
import { withAuth } from "@/trpc/api/trpc";
export const getSharesProcedure = withAuth.query(
async ({ ctx: { db, session } }) => {
const data = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
async ({ ctx: { db, captable } }) => {
const shares = await captable.shares.list();
const documents = await documentsBySecurity(
db,
"shareId",
shares.map((share) => share.id),
);
const shares = await tx.share.findMany({
where: {
companyId,
},
select: {
id: true,
certificateId: true,
quantity: true,
pricePerShare: true,
capitalContribution: true,
ipContribution: true,
debtCancelled: true,
otherContributions: true,
cliffYears: true,
vestingYears: true,
companyLegends: true,
status: true,
issueDate: true,
rule144Date: true,
vestingStartDate: true,
boardApprovalDate: true,
stakeholder: {
select: {
name: true,
},
},
shareClass: {
select: {
classType: true,
},
},
documents: {
select: {
id: true,
name: true,
uploader: {
select: {
user: {
select: {
name: true,
image: true,
},
},
},
},
bucket: {
select: {
key: true,
mimeType: true,
size: true,
},
},
},
},
},
});
return shares;
});
const data = shares.map(
({ stakeholderName, shareClassName, shareClassType, ...share }) => ({
...share,
// The table reads these as the joins Prisma used to return.
stakeholder: { name: stakeholderName },
shareClass: { name: shareClassName, classType: shareClassType },
documents: documents.get(share.id) ?? [],
}),
);
return { data };
},
+2 -2
View File
@@ -1,9 +1,9 @@
import {
OptionStatusEnum,
OptionTypeEnum,
SecuritiesStatusEnum,
ShareLegendsEnum,
} from "@/prisma/enums";
import { SecuritiesStatusEnum } from "@prisma/client";
} from "@/server/captable-api";
import { z } from "zod";
// OPTIONS
+59 -129
View File
@@ -1,163 +1,93 @@
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
import { captableFailure } from "@/server/captable-api";
import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
import { ShareClassMutationSchema } from "./schema";
import { Audit } from "@/server/audit";
import { checkMembership } from "@/server/auth";
/** The wire body a share class is created or amended with. */
const body = (input: Record<string, unknown>) => ({
name: input.name,
classType: input.classType,
initialSharesAuthorized: input.initialSharesAuthorized,
boardApprovalDate: (input.boardApprovalDate as Date).toISOString(),
stockholderApprovalDate: (
input.stockholderApprovalDate as Date
).toISOString(),
votesPerShare: input.votesPerShare,
parValue: input.parValue,
pricePerShare: input.pricePerShare,
seniority: input.seniority,
conversionRights: input.conversionRights,
convertsToShareClassId: input.convertsToShareClassId,
liquidationPreferenceMultiple: input.liquidationPreferenceMultiple,
participationCapMultiple: input.participationCapMultiple,
});
export const shareClassRouter = createTRPCRouter({
create: withAuth
.input(ShareClassMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp } = ctx;
const { userAgent, requestIp, captable, db, session } = ctx;
try {
const prefix = (input.classType === "COMMON" ? "CS" : "PS") as
| "CS"
| "PS";
// The backend assigns `idx` and derives `prefix` from the class type;
// both were computed here only because the row was written here.
await captable.classes.add(body(input));
await ctx.db.$transaction(async (tx) => {
const { companyId } = await checkMembership({
tx,
session: ctx.session,
});
const maxIdx = await tx.shareClass.count({
where: {
companyId,
},
});
const idx = maxIdx + 1;
const data = {
idx,
prefix,
const { companyId } = await checkMembership({ tx: db, session });
await Audit.create(
{
action: "shareClass.created",
companyId,
name: input.name,
classType: input.classType,
initialSharesAuthorized: input.initialSharesAuthorized,
boardApprovalDate: new Date(input.boardApprovalDate),
stockholderApprovalDate: new Date(input.stockholderApprovalDate),
votesPerShare: input.votesPerShare,
parValue: input.parValue,
pricePerShare: input.pricePerShare,
seniority: input.seniority,
conversionRights: input.conversionRights,
convertsToShareClassId: input.convertsToShareClassId,
liquidationPreferenceMultiple: input.liquidationPreferenceMultiple,
participationCapMultiple: input.participationCapMultiple,
};
await tx.shareClass.create({ data });
await Audit.create(
{
action: "shareClass.created",
companyId,
actor: { type: "user", id: ctx.session.user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "company", id: companyId }],
summary: `${ctx.session.user.name} created a share class - ${input.name}`,
},
tx,
);
});
actor: { type: "user", id: session.user.id },
context: { userAgent, requestIp },
target: [{ type: "company", id: companyId }],
summary: `${session.user.name} created a share class - ${input.name}`,
},
db,
);
return { success: true, message: "Share class created successfully." };
} catch (error) {
console.error("Error creating shareClass:", error);
return {
success: false,
message: "Oops, something went wrong. Please try again later.",
};
return { success: false, message: captableFailure(error) };
}
}),
update: withAuth
.input(ShareClassMutationSchema)
.mutation(async ({ ctx, input }) => {
const { userAgent, requestIp } = ctx;
const { userAgent, requestIp, captable, db, session } = ctx;
try {
const prefix = (input.classType === "COMMON" ? "CS" : "PS") as
| "CS"
| "PS";
if (!input.id) throw new Error("share class id is required to update");
await captable.classes.update(input.id, body(input));
await ctx.db.$transaction(async (tx) => {
const { companyId } = await checkMembership({
tx,
session: ctx.session,
});
const data = {
prefix,
name: input.name,
classType: input.classType,
initialSharesAuthorized: input.initialSharesAuthorized,
boardApprovalDate: new Date(input.boardApprovalDate),
stockholderApprovalDate: new Date(input.stockholderApprovalDate),
votesPerShare: input.votesPerShare,
parValue: input.parValue,
pricePerShare: input.pricePerShare,
seniority: input.seniority,
conversionRights: input.conversionRights,
convertsToShareClassId: input.convertsToShareClassId,
liquidationPreferenceMultiple: input.liquidationPreferenceMultiple,
participationCapMultiple: input.participationCapMultiple,
};
await tx.shareClass.update({
where: { id: input.id },
data,
});
await Audit.create(
{
action: "shareClass.updated",
companyId,
actor: { type: "user", id: ctx.session.user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "company", id: companyId }],
summary: `${ctx.session.user.name} updated a share class - ${input.name}`,
},
tx,
);
});
const { companyId } = await checkMembership({ tx: db, session });
await Audit.create(
{
action: "shareClass.updated",
companyId,
actor: { type: "user", id: session.user.id },
context: { userAgent, requestIp },
target: [{ type: "company", id: companyId }],
summary: `${session.user.name} updated a share class - ${input.name}`,
},
db,
);
return { success: true, message: "Share class updated successfully." };
} catch (error) {
console.error("Error updating shareClass:", error);
return {
success: false,
message: "Oops, something went wrong. Please try again later.",
};
return { success: false, message: captableFailure(error) };
}
}),
get: withAuth.query(async ({ ctx: { db, session } }) => {
const shareClass = await db.$transaction(async (tx) => {
const { companyId } = await checkMembership({ session, tx });
return await tx.shareClass.findMany({
where: {
companyId,
},
select: {
id: true,
name: true,
company: {
select: {
name: true,
},
},
},
});
});
return shareClass;
get: withAuth.query(async ({ ctx }) => {
const classes = await ctx.captable.classes.list();
return classes.map(({ companyName, ...shareClass }) => ({
...shareClass,
company: { name: companyName },
}));
}),
});
@@ -1,4 +1,5 @@
import { Audit } from "@/server/audit";
import { captableFailure } from "@/server/captable-api";
import { withAccessControl } from "@/trpc/api/trpc";
import { ZodAddStakeholderArrayMutationSchema } from "../schema";
@@ -7,50 +8,32 @@ export const addStakeholdersProcedure = withAccessControl
.meta({ policies: { stakeholder: { allow: ["create"] } } })
.mutation(
async ({
ctx: { db, membership, userAgent, requestIp, session },
ctx: { db, captable, membership, userAgent, requestIp, session },
input,
}) => {
try {
const { user } = session;
await db.$transaction(async (tx) => {
// insert companyId in every input
const inputDataWithCompanyId = input.map((stakeholder) => ({
...stakeholder,
companyId: membership.companyId,
}));
await tx.stakeholder.createMany({
data: inputDataWithCompanyId,
skipDuplicates: true,
});
await captable.stakeholders.add(input);
inputDataWithCompanyId.map(async (inp) => {
await Audit.create(
{
action: "stakeholder.added",
companyId: user.companyId,
actor: { type: "user", id: user.id },
context: {
userAgent,
requestIp,
},
target: [{ type: "stakeholder", id: inp.id }],
summary: `${user.name} added stakeholder ${inp.name} for the company ID ${inp.companyId}`,
},
tx,
);
});
});
for (const stakeholder of input) {
await Audit.create(
{
action: "stakeholder.added",
companyId: membership.companyId,
actor: { type: "user", id: user.id },
context: { userAgent, requestIp },
target: [{ type: "stakeholder", id: stakeholder.email }],
summary: `${user.name} added stakeholder ${stakeholder.name} for the company ID ${membership.companyId}`,
},
db,
);
}
return {
success: true,
message: "Stakeholders added successfully!",
};
} catch (_error) {
return {
success: false,
message: "Oops, something went wrong. Please try again later.",
};
return { success: true, message: "Stakeholders added successfully!" };
} catch (error) {
console.error("Error adding stakeholders:", error);
return { success: false, message: captableFailure(error) };
}
},
);
@@ -2,23 +2,14 @@ import { withAccessControl } from "@/trpc/api/trpc";
export const getStakeholdersProcedure = withAccessControl
.meta({ policies: { stakeholder: { allow: ["read"] } } })
.query(async ({ ctx }) => {
const { db, membership } = ctx;
const data = await db.stakeholder.findMany({
where: {
companyId: membership.companyId,
},
include: {
company: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: "desc",
},
});
.query(async ({ ctx: { captable, membership } }) => {
const stakeholders = await captable.stakeholders.list();
return data;
return stakeholders.map(({ companyName, ...stakeholder }) => ({
...stakeholder,
companyId: membership.companyId,
// The pickers label a stakeholder with its company; the list route joins
// the company and returns the name, so it needs no second read.
company: { name: companyName },
}));
});
@@ -1,4 +1,5 @@
import { Audit } from "@/server/audit";
import { captableFailure } from "@/server/captable-api";
import { withAccessControl } from "@/trpc/api/trpc";
import { ZodUpdateStakeholderMutationSchema } from "./../schema";
@@ -10,6 +11,7 @@ export const updateStakeholderProcedure = withAccessControl
ctx: {
session,
db,
captable,
requestIp,
userAgent,
membership: { companyId },
@@ -20,47 +22,31 @@ export const updateStakeholderProcedure = withAccessControl
const { id: stakeholderId, ...rest } = input;
const user = session.user;
await db.$transaction(async (tx) => {
const updated = await tx.stakeholder.update({
where: {
id: stakeholderId,
companyId,
},
data: {
...rest,
},
select: {
id: true,
name: true,
},
});
if (!stakeholderId) throw new Error("stakeholder id is required");
await Audit.create(
{
action: "stakeholder.updated",
companyId: user.companyId,
actor: { type: "user", id: user.id },
context: {
requestIp,
userAgent,
},
target: [{ type: "stakeholder", id: updated.id }],
summary: `${user.name} updated detailes of stakeholder : ${updated.name}`,
},
tx,
);
});
await captable.stakeholders.update(stakeholderId, rest);
await Audit.create(
{
action: "stakeholder.updated",
companyId,
actor: { type: "user", id: user.id },
context: { requestIp, userAgent },
target: [{ type: "stakeholder", id: stakeholderId }],
summary: `${user.name} updated detailes of stakeholder : ${
rest.name ?? stakeholderId
}`,
},
db,
);
return {
success: true,
message: "Successfully updated the stakeholder",
};
} catch (error) {
console.log(error);
return {
success: false,
message: "Something went up. Please try again later",
};
console.error(error);
return { success: false, message: captableFailure(error) };
}
},
);
@@ -1,7 +1,7 @@
import {
StakeholderRelationshipEnum,
StakeholderTypeEnum,
} from "@/prisma/enums";
} from "@/server/captable-api";
import { z } from "zod";
export const ZodAddStakeholderMutationSchema = z.object({