feat: setting up base for safe

This commit is contained in:
Puru D
2024-03-19 01:47:46 -05:00
parent bce405fa1a
commit 52dfe7203f
18 changed files with 461 additions and 11 deletions
@@ -0,0 +1,26 @@
/*
Warnings:
- The values [UNCAPPED] on the enum `SafeTypeEnum` will be removed. If these variants are still used in the database, this will fail.
- Made the column `mfn` on table `Safe` required. This step will fail if there are existing NULL values in that column.
- Made the column `proRata` on table `Safe` required. This step will fail if there are existing NULL values in that column.
*/
-- CreateEnum
CREATE TYPE "YCSafeEnum" AS ENUM ('Valuation Cap, no Discount', 'Discount, no Valuation Cap', 'MFN, no Valuation Cap, no Discount', 'Valuation Cap, no Discount, include Pro Rata Rights', 'Discount, no Valuation Cap, include Pro Rata Rights', 'MFN, no Valuation Cap, no Discount, include Pro Rata Rights');
-- AlterEnum
BEGIN;
CREATE TYPE "SafeTypeEnum_new" AS ENUM ('PRE_MONEY', 'POST_MONEY');
ALTER TABLE "Safe" ALTER COLUMN "type" TYPE "SafeTypeEnum_new" USING ("type"::text::"SafeTypeEnum_new");
ALTER TYPE "SafeTypeEnum" RENAME TO "SafeTypeEnum_old";
ALTER TYPE "SafeTypeEnum_new" RENAME TO "SafeTypeEnum";
DROP TYPE "SafeTypeEnum_old";
COMMIT;
-- AlterTable
ALTER TABLE "Safe" ADD COLUMN "ycTemplate" "YCSafeEnum",
ALTER COLUMN "mfn" SET NOT NULL,
ALTER COLUMN "mfn" SET DEFAULT false,
ALTER COLUMN "proRata" SET NOT NULL,
ALTER COLUMN "proRata" SET DEFAULT false;
+14 -4
View File
@@ -352,7 +352,7 @@ model TemplateField {
left Int
width Int
height Int
template Template @relation(fields: [templateId], references: [id])
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
templateId String
viewportHeight Int
viewportWidth Int
@@ -554,7 +554,6 @@ model Investment {
enum SafeTypeEnum {
PRE_MONEY
POST_MONEY
UNCAPPED
}
enum SafeStatusEnum {
@@ -564,6 +563,16 @@ enum SafeStatusEnum {
EXPIRED
CANCELLED
}
// YC Standard Safe
enum YCSafeEnum {
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")
}
model Safe {
id String @id @default(cuid())
@@ -571,11 +580,12 @@ model Safe {
type SafeTypeEnum
status SafeStatusEnum @default(DRAFT)
capital Float // Amount of money invested
ycTemplate YCSafeEnum?
valuationCap Float?
discountRate Float?
mfn Boolean? // Most Favored Nation
proRata Boolean? // Pro Rata Rights
mfn Boolean @default(false) // Most Favored Nation
proRata Boolean @default(false) // Pro Rata Rights
additionalTerms String?
documents Document[]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -20,7 +20,7 @@ const SafePage = async () => {
// />
<EmptyState
icon={<RiSafeFill />}
title="Simple agreement for future equity (SAFE)"
title="SAFE"
subtitle="Create and manage SAFE agreements for your company."
>
<SafeActions companyPublicId={user.companyPublicId} />
+4 -5
View File
@@ -6,6 +6,7 @@ import Modal from "@/components/shared/modal";
import { Button } from "@/components/ui/button";
import Uploader from "@/components/ui/uploader";
import { RiAddFill } from "@remixicon/react";
import CreateNewSafeModal from "./new/modal";
import {
DropdownMenu,
@@ -37,16 +38,14 @@ const SafeActions = ({ companyPublicId }: SafeActionsProps) => {
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<ul>
<li>
<Modal
title="Create a new SAFE agreement"
<CreateNewSafeModal
companyId={companyPublicId}
trigger={
<Button variant="ghost" size="sm">
Create new SAFE
</Button>
}
>
<span>Multi-step form</span>
</Modal>
/>
</li>
<li>
+49
View File
@@ -0,0 +1,49 @@
import { useState } from "react";
import { api } from "@/trpc/react";
import { useForm } from "react-hook-form";
import useSteps from "@/components/safe/new/steps";
import { zodResolver } from "@hookform/resolvers/zod";
import MultiStepModal from "@/components/shared/multistep-modal";
import {
type SafeMutationType,
SafeMutationSchema,
} from "@/trpc/routers/safe/schema";
type CreateNewSafeType = {
companyId: string;
trigger: React.ReactNode;
};
export default function CreateNewSafe({
companyId,
trigger,
}: CreateNewSafeType) {
const steps = useSteps({ companyId });
const formSchema = SafeMutationSchema;
const { mutateAsync } = api.safe.create.useMutation();
const [open, setOpen] = useState(false);
const form = useForm<SafeMutationType>({
resolver: zodResolver(formSchema),
});
const isSubmitting = form.formState.isSubmitting;
const onSubmit = async (values: SafeMutationType) => {
await mutateAsync({ values });
setOpen(false);
};
return (
<MultiStepModal
title="Create a new SAFE agreement"
subtitle="Create, sign and send a new SAFE agreement to your investors."
trigger={trigger}
steps={steps}
schema={formSchema}
onSubmit={onSubmit}
dialogProps={{ open, onOpenChange: setOpen }}
/>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { type stepsType } from "@/components/shared/multistep-modal";
type SafeStepsType = {
companyId: string;
};
const useSafeSteps = ({ companyId: string }: SafeStepsType) => {
const steps: Array<stepsType> = [
{
id: 1,
title: "General information",
fields: ["name", "company", "valuationCap", "discountRate"],
component: () => {
return <div>General info form</div>;
},
},
{
id: 2,
title: "Investor information",
fields: ["investorName", "investorEmail", "investorAddress"],
component: () => {
return <div>Investor info form</div>;
},
},
{
id: 3,
title: "Review and send",
fields: ["review"],
component: () => {
return <div>Review and send form</div>;
},
},
];
return steps;
};
export default useSafeSteps;
+3 -1
View File
@@ -28,7 +28,7 @@ import { type DialogProps } from "@radix-ui/react-dialog";
type ModalProps = {
title: string | React.ReactNode;
subtitle?: string | React.ReactNode;
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl";
size?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
trigger: React.ReactNode;
children: React.ReactNode;
dialogProps?: DialogProps;
@@ -54,6 +54,8 @@ const Modal = ({
size === "xl" && "sm:max-w-xl",
size === "2xl" && "sm:max-w-2xl",
size === "3xl" && "sm:max-w-3xl",
size === "4xl" && "sm:max-w-4xl",
size === "5xl" && "sm:max-w-5xl",
)}
>
<div className="no-scrollbar max-h-[80vh] overflow-scroll">
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { RiCheckLine } from "@remixicon/react";
import { Button } from "@/components/ui/button";
import Modal from "@/components/shared/modal";
import { useState } from "react";
import { FormProvider, type SubmitHandler, useForm } from "react-hook-form";
import { type z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { type DialogProps } from "@radix-ui/react-dialog";
export type stepsType = {
id: number;
title: string;
fields: string[];
component: () => JSX.Element;
};
type MultiStepModalType = {
title: string | React.ReactNode;
subtitle: string | React.ReactNode;
trigger: React.ReactNode;
steps: stepsType[];
schema: z.AnyZodObject;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSubmit: SubmitHandler<any>;
dialogProps: DialogProps;
};
export default function MultiStepModal({
title,
subtitle,
trigger,
steps,
schema,
onSubmit,
dialogProps,
}: MultiStepModalType) {
const [formStep, setFormStep] = useState(1);
const methods = useForm<FormField>({ resolver: zodResolver(schema) });
type FormField = z.infer<typeof schema>;
type FieldName = keyof FormField;
const StepForm = steps[formStep - 1]!.component;
const nextStep = async () => {
const fields = steps[formStep - 1]?.fields ?? [];
const output = await methods.trigger(fields as FieldName[] as string[], {
shouldFocus: true,
});
if (!output) return;
if (formStep < steps.length) {
setFormStep(formStep + 1);
} else {
await methods.handleSubmit(onSubmit)();
methods.reset();
dialogProps.open = false;
}
};
const prevStep = () => {
if (formStep > 1) {
setFormStep(formStep - 1);
}
};
return (
<Modal
size="4xl"
title={title}
subtitle={subtitle}
trigger={trigger}
dialogProps={dialogProps}
>
<div className="grid grid-cols-10">
<nav aria-label="Progress" className="red col-span-3 max-w-64">
<ol role="list" className="overflow-hidden">
{steps.map((step, stepIdx) => (
<li
key={step.title}
className={`${stepIdx !== steps.length - 1 ? "pb-5" : ""} relative`}
>
{step.id < formStep ? (
<>
{stepIdx !== steps.length - 1 ? (
<div
className="absolute left-4 top-4 -ml-1 mt-0.5 h-full w-0.5 bg-teal-500"
aria-hidden="true"
/>
) : null}
<span className="group relative flex items-center">
<span className="flex h-9 items-center">
<span className="relative z-10 flex h-6 w-6 items-center justify-center rounded-full bg-teal-500 group-hover:bg-teal-600">
<RiCheckLine
className="h-4 w-4 text-white"
aria-hidden="true"
/>
</span>
</span>
<span className="ml-4 flex min-w-0 flex-col">
<span className="text-sm font-medium">
{step.title}
</span>
</span>
</span>
</>
) : step.id === formStep ? (
<>
{stepIdx !== steps.length - 1 ? (
<div
className="absolute left-4 top-4 -ml-1 mt-0.5 h-full w-0.5 bg-gray-300"
aria-hidden="true"
/>
) : null}
<span
className="group relative flex items-center"
aria-current="step"
>
<span
className="flex h-9 items-center"
aria-hidden="true"
>
<span className="relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-teal-500 bg-white">
<span className="h-2.5 w-2.5 rounded-full bg-teal-500" />
</span>
</span>
<span className="ml-4 flex min-w-0 flex-col">
<span className="text-sm font-medium text-teal-500">
{step.title}
</span>
</span>
</span>
</>
) : (
<>
{stepIdx !== steps.length - 1 ? (
<div
className="absolute left-4 top-4 -ml-1 mt-0.5 h-full w-0.5 bg-gray-300"
aria-hidden="true"
/>
) : null}
<span className="group relative flex items-center">
<span
className="flex h-9 items-center"
aria-hidden="true"
>
<span className="relative z-10 flex h-6 w-6 items-center justify-center rounded-full border-2 border-gray-300 bg-white group-hover:border-gray-400">
<span className="h-2.5 w-2.5 rounded-full bg-transparent group-hover:bg-gray-300" />
</span>
</span>
<span className="ml-4 flex min-w-0 flex-col">
<span className="text-sm font-medium text-gray-500">
{step.title}
</span>
</span>
</span>
</>
)}
</li>
))}
</ol>
</nav>
<div className="col-span-7 space-y-6">
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<div className="mt-2 min-h-96 w-full rounded border p-3">
<h5 className="text-lg font-medium">
{steps[formStep - 1]?.title}
</h5>
<StepForm />
</div>
</form>
</FormProvider>
<div className="flex justify-end">
<div className="space-x-4">
{formStep > 1 && (
<Button variant={"outline"} onClick={prevStep}>
Back
</Button>
)}
<Button type="submit" onClick={nextStep}>
{formStep < steps.length ? "Continue" : "Submit"}
</Button>
</div>
</div>
</div>
</div>
</Modal>
);
}
+5
View File
@@ -30,6 +30,11 @@ export const AuditSchema = z.object({
"option.created",
"option.deleted",
"safe.created",
"safe.imported",
"safe.sent",
"safe.signed",
]),
occurredAt: z.date().optional(),
actor: z.object({
+3
View File
@@ -11,6 +11,8 @@ import { templateRouter } from "../routers/template-router/router";
import { templateFieldRouter } from "../routers/template-field-router/router";
import { stakeholderRouter } from "../routers/stakeholder-router/router";
import { securitiesRouter } from "../routers/securities-router/router";
import { safeRouter } from "../routers/safe/router";
/**
* This is the primary router for your server.
*
@@ -29,6 +31,7 @@ export const appRouter = createTRPCRouter({
templateField: templateFieldRouter,
stakeholder: stakeholderRouter,
securities: securitiesRouter,
safe: safeRouter,
});
// export type definition of API
+93
View File
@@ -0,0 +1,93 @@
import { Audit } from "@/server/audit";
import { SafeMutationSchema } from "./schema";
import { createTRPCRouter, withAuth } from "@/trpc/api/trpc";
export const safeRouter = createTRPCRouter({
create: withAuth
.input(SafeMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { userAgent, requestIp } = ctx;
const companyId = ctx.session.user.companyId;
await ctx.db.$transaction(async (tx) => {
const data = {
publicId: input.publicId,
type: input.type,
status: input.status,
capital: input.capital,
valuationCap: input.valuationCap,
discountRate: input.discountRate,
mfn: input.mfn,
proRata: input.proRata,
additionalTerms: input.additionalTerms,
shareholderId: input.shareholderId,
issueDate: input.issueDate,
boardApprovalDate: input.boardApprovalDate,
};
await tx.safe.create({ data });
await Audit.create(
{
action: "safe.created",
companyId,
actor: { type: "user", id: ctx.session.user.id },
context: { requestIp, userAgent },
target: [{ type: "company", id: companyId }],
summary: `${ctx.session.user.name} created a new SAFE agreement.`,
},
tx,
);
});
return {
success: true,
message: "Successfully created a new SAFE agreement.",
};
} catch (error) {
console.error("Error creating an SAFE:", error);
return {
success: false,
message: "Oops, something went wrong. Please try again later.",
};
}
}),
// import: withAuth
// .input(SafeMutationSchema)
// .mutation(async ({ ctx, input }) => {
// try {
// try {
// const { userAgent, requestIp } = ctx;
// const companyId = ctx.session.user.companyId;
// await ctx.db.$transaction(async (tx) => {
// const data = {};
// await tx.safe.create({ data });
// await Audit.create(
// {
// action: "safe.imported",
// companyId,
// actor: { type: "user", id: ctx.session.user.id },
// context: {
// requestIp,
// userAgent,
// },
// target: [{ type: "company", id: companyId }],
// summary: `${ctx.session.user.name} imported an existing SAFE agreement.`,
// },
// tx,
// );
// });
// return { success: true, message: "Successfully imported an existing SAFE agreement." };
// } catch (error) {
// console.error("Error importing an existing SAFE.", error);
// return {
// success: false,
// message: "Oops, something went wrong. Please try again later.",
// };
// }
// }),
});
+28
View File
@@ -0,0 +1,28 @@
import { SafeTypeEnum, SafeStatusEnum } from "@/prisma-enums";
import { z } from "zod";
export const SafeMutationSchema = z.object({
id: z.string().optional(),
publicId: z.string(),
type: z.nativeEnum(SafeTypeEnum, {
errorMap: () => ({ message: "Invalid value for type" }),
}),
status: z.nativeEnum(SafeStatusEnum, {
errorMap: () => ({ message: "Invalid value for status" }),
}),
capital: z.number(),
valuationCap: z.number().optional(),
discountRate: z.number().optional(),
mfn: z.boolean().optional(),
proRata: z.boolean().optional(),
additionalTerms: z.string().optional(),
companyId: z.string().optional(),
shareholderId: z.string(),
issueDate: z.date().optional(),
boardApprovalDate: z.date().optional(),
});
export type SafeMutationType = z.infer<typeof SafeMutationSchema>;