feat: setting up base for safe
This commit is contained in:
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
// };
|
||||
// }
|
||||
// }),
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user