mirror of
https://github.com/hanzoai/captable.git
synced 2026-10-16 14:21:29 +00:00
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:
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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(() =>
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { SafeTemplateEnum } from "@/prisma/enums";
|
||||
import { SafeTemplateEnum } from "@/server/captable-api";
|
||||
import { z } from "zod";
|
||||
import { ZodTemplateFieldRecipientSchema } from "../template-router/schema";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user