From 2243d8ab7a5c3d973575664b947fa63af186dfbe Mon Sep 17 00:00:00 2001 From: hanzo-dev Date: Thu, 9 Jul 2026 22:26:46 -0700 Subject: [PATCH] feat(goja): self-contained cap-table bundle + Go embed module (epic #96 pilot) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a self-contained, ESM-free goja bundle of the FULL captable business logic so the unified hanzoai/cloud binary can host it in-process via dop251/goja (HIP-0106), backed by per-tenant Base/SQLite — dropping Next.js/Prisma/tRPC from this path. This is the PILOT of the read-write-Base-via-goja pattern (epic #96) that esign (#100) and dataroom (#101) reuse via cloud's clients/gojabase binding. Full fold, ported from the tRPC routers with Prisma/next-auth/zod removed and persistence delegated to an injected globalThis.__db bridge: - company (root, seeded per tenant by the host), stakeholders (list/add/update/ delete), share classes (list/create/update), equity plans (list/create). - securities issuance: shares (add/list/delete) + options (add/list/delete); share transfers (full reassign + partial split, atomic). - convertibles: SAFEs + convertible notes (list/create/delete). - rounds + investments: a PRICED round issues shares to each investor and dilutes the cap table; SAFE/CONVERTIBLE rounds record capital. - captable: computed ownership on a fully-diluted basis (shares + options), per-class authorized-vs-issued, convertibles + rounds summary. Structure: - goja/src/*.ts — domain logic split by concern (host bridge, validators, and a routes/ module per domain), exposing globalThis.handle({route,params,orgId, body}) -> {status,body}. The bundle carries logic, never a DB engine. - goja/build.mjs — esbuild → goja/bundle.js (IIFE, ES2015, platform=neutral, no node: builtins). bundle.js committed so cloud go:embeds it (as @hanzo/plans ships goja/bundle.js). - goja/test/bundle.test.mjs — smoke + wiring test in node:vm vs a mock __db (tenant gate, route table, validation, INSERT shape, idx auto-increment, cap table shape, full transfer, round-create gating). 9/9 pass. - embed.go + go.mod — tiny std-only Go embed module (github.com/hanzoai/captable) exposing Bundle(); mirrors github.com/hanzoai/plans. The Next app + goja/src stay the single source of truth; the Go layer is a read-only embed. - tsconfig/biome — exclude goja/ from the Next typecheck + lint. Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ Co-authored-by: hanzo-dev --- biome.json | 3 + embed.go | 41 + go.mod | 14 + goja/.gitignore | 1 + goja/README.md | 74 ++ goja/build.mjs | 34 + goja/bundle.js | 1299 +++++++++++++++++++++++++++++++ goja/package.json | 14 + goja/src/host.ts | 46 ++ goja/src/index.ts | 142 ++++ goja/src/routes/captable.ts | 96 +++ goja/src/routes/common.ts | 47 ++ goja/src/routes/company.ts | 42 + goja/src/routes/convertibles.ts | 181 +++++ goja/src/routes/equity.ts | 194 +++++ goja/src/routes/rounds.ts | 220 ++++++ goja/src/routes/securities.ts | 364 +++++++++ goja/src/routes/stakeholders.ts | 155 ++++ goja/src/validate.ts | 175 +++++ goja/test/bundle.test.mjs | 218 ++++++ goja/tsconfig.json | 13 + tsconfig.json | 2 +- 22 files changed, 3374 insertions(+), 1 deletion(-) create mode 100644 embed.go create mode 100644 go.mod create mode 100644 goja/.gitignore create mode 100644 goja/README.md create mode 100644 goja/build.mjs create mode 100644 goja/bundle.js create mode 100644 goja/package.json create mode 100644 goja/src/host.ts create mode 100644 goja/src/index.ts create mode 100644 goja/src/routes/captable.ts create mode 100644 goja/src/routes/common.ts create mode 100644 goja/src/routes/company.ts create mode 100644 goja/src/routes/convertibles.ts create mode 100644 goja/src/routes/equity.ts create mode 100644 goja/src/routes/rounds.ts create mode 100644 goja/src/routes/securities.ts create mode 100644 goja/src/routes/stakeholders.ts create mode 100644 goja/src/validate.ts create mode 100644 goja/test/bundle.test.mjs create mode 100644 goja/tsconfig.json diff --git a/biome.json b/biome.json index d0f84dcc..8f624782 100644 --- a/biome.json +++ b/biome.json @@ -5,6 +5,7 @@ "ignore": [ "node_modules", ".next", + "goja", "dist", "public/pdf.worker.min.js", "./prisma/enums.ts", @@ -25,6 +26,7 @@ "ignore": [ "node_modules", ".next", + "goja", "dist", "public/pdf.worker.min.js", "./prisma/enums.ts", @@ -43,6 +45,7 @@ "ignore": [ "node_modules", ".next", + "goja", "dist", "public/pdf.worker.min.js", "./prisma/enums.ts", diff --git a/embed.go b/embed.go new file mode 100644 index 00000000..ee067649 --- /dev/null +++ b/embed.go @@ -0,0 +1,41 @@ +// Package captable embeds the captable goja bundle so the unified hanzoai/cloud +// binary can host the cap-table business logic in-process via the dop251/goja +// engine (HIP-0106), without copying the bundle into the cloud repo. +// +// The Next.js app + the goja/src TypeScript remain the single source of truth. +// This Go file is a read-only embed: +// +// - Bundle() returns goja/bundle.js — the self-contained, ESM-free port of the +// tRPC routers (stakeholders, share classes, securities, cap-table read) +// that the Go host runs in goja, calling globalThis.handle({route,params, +// orgId,body}) per request. Persistence is the host's injected globalThis.__db +// over per-tenant Base/SQLite; there is no Prisma in this path. +// +// Mirrors github.com/hanzoai/plans exactly (the proven precedent). +package captable + +import _ "embed" + +// Version identifies the embedded bundle for the host's mount log. Bump on any +// change to goja/bundle.js. +const Version = "0.1.0" + +//go:embed goja/bundle.js +var bundle []byte + +// Bundle returns the goja bundle source (goja/bundle.js). The host compiles it +// once and runs it on each pooled goja runtime. +func Bundle() ([]byte, error) { + if len(bundle) == 0 { + return nil, errEmptyBundle + } + return bundle, nil +} + +// errEmptyBundle is returned if the embed produced no bytes (a build that forgot +// to run goja/build.mjs). Fail loud rather than mount an empty bundle. +var errEmptyBundle = &bundleError{"captable: embedded goja/bundle.js is empty (run `node goja/build.mjs`)"} + +type bundleError struct{ msg string } + +func (e *bundleError) Error() string { return e.msg } diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..239fe0b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +// hanzoai/captable — Go embed module. +// +// This repo's PRIMARY artifact is the Next.js cap-table application. This tiny +// Go module exists ONLY so the unified hanzoai/cloud binary (HIP-0106) can embed +// the captable goja bundle (goja/bundle.js) — the self-contained, ESM-free port +// of the tRPC business logic — WITHOUT copying it into the cloud repo. Cloud +// imports github.com/hanzoai/captable and gets Bundle(). +// +// std-lib only — no third-party Go deps. The TypeScript in goja/src stays the +// single source of truth; the Go layer is a read-only embed, exactly like +// github.com/hanzoai/plans. +module github.com/hanzoai/captable + +go 1.26.4 diff --git a/goja/.gitignore b/goja/.gitignore new file mode 100644 index 00000000..3c3629e6 --- /dev/null +++ b/goja/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/goja/README.md b/goja/README.md new file mode 100644 index 00000000..7f93214b --- /dev/null +++ b/goja/README.md @@ -0,0 +1,74 @@ +# `goja/` — captable inside the unified cloud binary + +`bundle.js` is a **self-contained, ESM-free** port of the captable tRPC business +logic (`src/trpc/routers/{stakeholder,share-class,securities}-router`), authored +in TypeScript (`src/*.ts`) and bundled by esbuild so it runs verbatim inside the +[`dop251/goja`](https://github.com/dop251/goja) JavaScript engine embedded in +[`hanzoai/cloud`](https://github.com/hanzoai/cloud) per HIP-0106. It is the pilot +of the **read-write-Base-via-goja** pattern (epic #96): the same idiom sign (#98) +and dataroom (#99) will replicate. + +## Why a bundle? + +goja runs ES2020 but **not** ES-module `import`/`export` and **not** `node:` +builtins — so Prisma, tRPC, next-auth and zod cannot load. Rather than rewrite +the logic in Go, the domain + validation logic lives here as TypeScript and runs +in goja; **persistence is delegated to the Go host** through an injected `__db` +bridge (per-tenant Base/SQLite). The bundle carries logic, never a DB engine. + +## Host contract + +The Go host (`hanzoai/cloud/clients/captable`) injects these globals before the +first dispatch, then calls `handle` per request: + +``` +globalThis.__db.query(sql, args) -> row objects (SELECT) +globalThis.__db.exec(sql, args) -> { changes, lastId } (INSERT/UPDATE/DELETE) +globalThis.__newId() -> collision-resistant id +globalThis.__now() -> unix milliseconds + +globalThis.handle({ route, params, orgId, session, body }) -> { status, body } +``` + +`orgId` is the gateway-minted, **validated** tenant. The host uses it BOTH to +select the per-tenant SQLite file AND passes it here as the cap-table +`companyId`, so every query is scoped to one tenant — physically (one file per +org) and logically (the `company_id` column), mirroring the tRPC +`where: { companyId }` scope. + +### Routes (the full cap-table fold) + +| route(s) | HTTP (mounted by the host) | +|---|---| +| `company.get` / `company.update` | GET/PUT `/v1/captable/company` | +| `stakeholders.{list,add,update,delete}` | `/v1/captable/stakeholders[/:id]` | +| `shareClasses.{list,create,update}` | `/v1/captable/share-classes[/:id]` | +| `equityPlans.{list,create}` | `/v1/captable/equity-plans` | +| `shares.{list,add,delete,transfer}` | `/v1/captable/shares[/:id]`, `.../shares/transfer` | +| `options.{list,add,delete}` | `/v1/captable/options[/:id]` | +| `safes.{list,create,delete}` | `/v1/captable/safes[/:id]` | +| `convertibles.{list,create,delete}` | `/v1/captable/convertibles[/:id]` | +| `rounds.{list,create,get,close}` | `/v1/captable/rounds[/:id][/close]` | +| `rounds.investments.{list,add}` | `/v1/captable/rounds/:id/investments`, `/v1/captable/investments` | +| `captable` | GET `/v1/captable/summary` (computed cap table) | + +Securities **issuance** (shares + options), **transfers** (full + partial), and +**rounds** (a priced round issues shares to each investor and dilutes the cap +table) are all implemented over Base. The `captable` route computes ownership on +a fully-diluted basis (shares + options), per-class authorized-vs-issued, plus a +convertibles + rounds summary. + +Out of this repo's scope by design: documents / data-room (→ dataroom #101), +e-signature (→ esign #100), email. Vesting is stored (cliff/vesting years) but +schedule *computation* is a later enhancement. + +## Build & test + +``` +node goja/build.mjs # regenerate bundle.js from src/*.ts (esbuild) +node goja/test/bundle.test.mjs # smoke + wiring test against a mock __db +``` + +`bundle.js` is **committed** so `hanzoai/cloud` can `go:embed` it via `embed.go` +(`Bundle()`), exactly how `@hanzo/plans` ships `goja/bundle.js`. Edit the +TypeScript, re-run the build, commit the regenerated bundle. diff --git a/goja/build.mjs b/goja/build.mjs new file mode 100644 index 00000000..73ecc42a --- /dev/null +++ b/goja/build.mjs @@ -0,0 +1,34 @@ +// Builds the self-contained, ESM-free goja bundle from the TypeScript source. +// +// Output: goja/bundle.js — an IIFE targeting ES2015 (goja supports ES2020+, but +// ES2015 keeps the output free of any runtime-feature surprises), platform +// "neutral" so esbuild injects NO node/browser globals or `node:` builtins. The +// only globals the bundle touches are the ones the Go host injects +// (__db/__newId/__now) and standard JS (JSON/Math/RegExp). +// +// Run: `node goja/build.mjs` (esbuild is the sole devDependency; see package.json) +// The built bundle.js is committed so hanzoai/cloud can go:embed it (embed.go), +// exactly how @hanzo/plans ships goja/bundle.js. + +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); + +await build({ + entryPoints: [join(here, "src/index.ts")], + bundle: true, + format: "iife", + target: "es2015", + platform: "neutral", + legalComments: "none", + banner: { + js: + "// @hanzo/captable — goja bundle (GENERATED by goja/build.mjs; edit src/*.ts).\n" + + "// Self-contained, ESM-free; runs in dop251/goja inside hanzoai/cloud (HIP-0106).", + }, + outfile: join(here, "bundle.js"), +}); + +console.log("built goja/bundle.js"); diff --git a/goja/bundle.js b/goja/bundle.js new file mode 100644 index 00000000..745a51c1 --- /dev/null +++ b/goja/bundle.js @@ -0,0 +1,1299 @@ +// @hanzo/captable — goja bundle (GENERATED by goja/build.mjs; edit src/*.ts). +// Self-contained, ESM-free; runs in dop251/goja inside hanzoai/cloud (HIP-0106). +"use strict"; +(() => { + var __defProp = Object.defineProperty; + var __getOwnPropSymbols = Object.getOwnPropertySymbols; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __propIsEnum = Object.prototype.propertyIsEnumerable; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __spreadValues = (a, b) => { + for (var prop in b || (b = {})) + if (__hasOwnProp.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + if (__getOwnPropSymbols) + for (var prop of __getOwnPropSymbols(b)) { + if (__propIsEnum.call(b, prop)) + __defNormalProp(a, prop, b[prop]); + } + return a; + }; + + // src/host.ts + var db = { + query(sql, args) { + return globalThis.__db.query(sql, args || []); + }, + exec(sql, args) { + return globalThis.__db.exec(sql, args || []); + } + }; + function newId() { + return globalThis.__newId(); + } + function now() { + return globalThis.__now(); + } + + // src/routes/common.ts + var okRes = (body) => ({ status: 200, body }); + var created = (body) => ({ status: 201, body }); + var badReq = (errors) => ({ + status: 400, + body: { success: false, message: "Validation failed", errors } + }); + var notFound = (msg) => ({ + status: 404, + body: { success: false, message: msg } + }); + var conflict = (msg) => ({ + status: 409, + body: { success: false, message: msg } + }); + function asObj(body) { + return body || {}; + } + function refExists(companyId, table, id) { + if (!id) return false; + return db.query(`SELECT 1 FROM ${table} WHERE company_id = ? AND id = ?`, [ + companyId, + id + ]).length > 0; + } + + // src/validate.ts + var StakeholderType = ["INDIVIDUAL", "INSTITUTION"]; + var StakeholderRelationship = [ + "ADVISOR", + "BOARD_MEMBER", + "CONSULTANT", + "EMPLOYEE", + "EX_ADVISOR", + "EX_CONSULTANT", + "EX_EMPLOYEE", + "EXECUTIVE", + "FOUNDER", + "INVESTOR", + "NON_US_EMPLOYEE", + "OFFICER", + "OTHER" + ]; + var ShareClassType = ["COMMON", "PREFERRED"]; + var ConversionRights = [ + "CONVERTS_TO_FUTURE_ROUND", + "CONVERTS_TO_SHARE_CLASS_ID" + ]; + var SecuritiesStatus = ["ACTIVE", "DRAFT", "SIGNED", "PENDING"]; + var ShareLegends = [ + "US_SECURITIES_ACT", + "SALE_AND_ROFR", + "TRANSFER_RESTRICTIONS" + ]; + var CancellationBehavior = [ + "RETIRE", + "RETURN_TO_POOL", + "HOLD_AS_CAPITAL_STOCK", + "DEFINED_PER_PLAN_SECURITY" + ]; + var OptionType = ["ISO", "NSO", "RSU"]; + var OptionStatus = [ + "DRAFT", + "ACTIVE", + "EXERCISED", + "EXPIRED", + "CANCELLED" + ]; + var SafeType = ["PRE_MONEY", "POST_MONEY"]; + var SafeStatus = [ + "DRAFT", + "ACTIVE", + "PENDING", + "EXPIRED", + "CANCELLED" + ]; + var ConvertibleStatus = [ + "DRAFT", + "ACTIVE", + "PENDING", + "EXPIRED", + "CANCELLED" + ]; + var ConvertibleType = ["CCD", "OCD", "NOTE"]; + var RoundType = ["PRICED", "SAFE", "CONVERTIBLE"]; + function reqString(o, k, errs) { + const v = o[k]; + if (typeof v !== "string" || v.trim() === "") { + errs.push(`${k} is required`); + return ""; + } + return v; + } + function optString(o, k) { + const v = o[k]; + if (v === void 0 || v === null || v === "") return null; + return String(v); + } + function email(o, k, errs) { + const v = reqString(o, k, errs); + if (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { + errs.push(`${k} must be a valid email`); + } + return v; + } + function num(o, k, errs, min) { + const raw = o[k]; + const n = typeof raw === "number" ? raw : Number(raw); + if (raw === void 0 || raw === null || Number.isNaN(n)) { + errs.push(`${k} is required`); + return 0; + } + if (min !== void 0 && n < min) { + errs.push(`${k} must be >= ${min}`); + } + return n; + } + function optNum(o, k) { + const raw = o[k]; + if (raw === void 0 || raw === null || raw === "") return null; + const n = Number(raw); + return Number.isNaN(n) ? null : n; + } + function intNum(o, k, errs, min) { + return Math.trunc(num(o, k, errs, min)); + } + function oneOf(o, k, allowed, errs) { + const v = o[k]; + if (typeof v !== "string" || !allowed.includes(v)) { + errs.push(`${k} must be one of ${allowed.join(", ")}`); + return allowed[0]; + } + return v; + } + function dateString(o, k, errs) { + const v = o[k]; + if (typeof v !== "string" || v.trim() === "") { + errs.push(`${k} is required`); + return ""; + } + return v; + } + function optDateString(o, k) { + const v = o[k]; + if (v === void 0 || v === null || v === "") return null; + return String(v); + } + function legends(o, k, errs) { + const v = o[k]; + if (v === void 0 || v === null) return []; + if (!Array.isArray(v)) { + errs.push(`${k} must be an array`); + return []; + } + const out = []; + for (const item of v) { + if (typeof item !== "string" || !ShareLegends.includes(item)) { + errs.push(`${k} contains an invalid legend`); + continue; + } + out.push(item); + } + return out; + } + + // src/routes/company.ts + function getCompany(ctx) { + const rows = db.query( + `SELECT id, name, public_id AS publicId, incorporation_type AS incorporationType, + incorporation_country AS incorporationCountry, + incorporation_state AS incorporationState, + created_at AS createdAt, updated_at AS updatedAt + FROM company WHERE id = ?`, + [ctx.companyId] + ); + if (rows.length === 0) return notFound("company not found"); + return okRes(rows[0]); + } + function updateCompany(ctx) { + const o = asObj(ctx.body); + const errs = []; + const name = reqString(o, "name", errs); + if (errs.length) return badReq(errs); + const r = db.exec( + `UPDATE company + SET name = ?, incorporation_type = ?, incorporation_country = ?, + incorporation_state = ?, updated_at = ? + WHERE id = ?`, + [ + name, + optString(o, "incorporationType") || "", + optString(o, "incorporationCountry") || "", + optString(o, "incorporationState") || "", + now(), + ctx.companyId + ] + ); + if (r.changes === 0) return notFound("company not found"); + return okRes({ success: true, message: "Company updated" }); + } + + // src/routes/stakeholders.ts + function listStakeholders(ctx) { + const rows = db.query( + `SELECT s.id, s.name, s.email, s.institution_name AS institutionName, + s.stakeholder_type AS stakeholderType, + s.current_relationship AS currentRelationship, + s.tax_id AS taxId, s.street_address AS streetAddress, s.city, + s.state, s.zipcode, s.country, s.created_at AS createdAt, + c.name AS companyName + FROM stakeholder s + JOIN company c ON c.id = s.company_id + WHERE s.company_id = ? + ORDER BY s.created_at DESC`, + [ctx.companyId] + ); + return okRes(rows); + } + function validateStakeholder(o, errs) { + return { + name: reqString(o, "name", errs), + email: email(o, "email", errs), + institutionName: optString(o, "institutionName"), + stakeholderType: oneOf(o, "stakeholderType", StakeholderType, errs), + currentRelationship: oneOf( + o, + "currentRelationship", + StakeholderRelationship, + errs + ), + taxId: optString(o, "taxId"), + streetAddress: optString(o, "streetAddress"), + city: optString(o, "city"), + state: optString(o, "state"), + zipcode: optString(o, "zipcode") + }; + } + function addStakeholders(ctx) { + const raw = ctx.body; + const list = Array.isArray(raw) ? raw : [raw]; + if (list.length === 0) return badReq(["at least one stakeholder is required"]); + const errs = []; + const parsed = list.map( + (item) => validateStakeholder(asObj(item), errs) + ); + if (errs.length) return badReq(errs); + const ts = now(); + let inserted = 0; + for (const s of parsed) { + const dup = db.query( + `SELECT 1 FROM stakeholder WHERE company_id = ? AND email = ?`, + [ctx.companyId, s.email] + ); + if (dup.length > 0) continue; + db.exec( + `INSERT INTO stakeholder + (id, company_id, name, email, institution_name, stakeholder_type, + current_relationship, tax_id, street_address, city, state, zipcode, + country, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + s.name, + s.email, + s.institutionName, + s.stakeholderType, + s.currentRelationship, + s.taxId, + s.streetAddress, + s.city, + s.state, + s.zipcode, + "US", + ts, + ts + ] + ); + inserted += 1; + } + return created({ + success: true, + message: "Stakeholders added successfully!", + inserted + }); + } + function updateStakeholder(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["stakeholder id is required"]); + const o = asObj(ctx.body); + const sets = []; + const args = []; + const map = [ + ["name", "name"], + ["email", "email"], + ["institutionName", "institution_name"], + ["stakeholderType", "stakeholder_type"], + ["currentRelationship", "current_relationship"], + ["taxId", "tax_id"], + ["streetAddress", "street_address"], + ["city", "city"], + ["state", "state"], + ["zipcode", "zipcode"] + ]; + for (const [jsonKey, col] of map) { + if (o[jsonKey] !== void 0) { + sets.push(`${col} = ?`); + args.push(o[jsonKey]); + } + } + if (sets.length === 0) return badReq(["no fields to update"]); + sets.push("updated_at = ?"); + args.push(now(), ctx.companyId, id); + const r = db.exec( + `UPDATE stakeholder SET ${sets.join(", ")} WHERE company_id = ? AND id = ?`, + args + ); + if (r.changes === 0) return notFound("stakeholder not found"); + return okRes({ success: true, message: "Successfully updated the stakeholder" }); + } + function deleteStakeholder(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["stakeholder id is required"]); + const held = db.query( + `SELECT (SELECT COUNT(*) FROM share WHERE stakeholder_id = ? AND company_id = ?) + + (SELECT COUNT(*) FROM option WHERE stakeholder_id = ? AND company_id = ?) AS n`, + [id, ctx.companyId, id, ctx.companyId] + ); + if (Number(held[0].n) > 0) { + return badReq(["cannot delete a stakeholder that still holds shares or options"]); + } + const r = db.exec(`DELETE FROM stakeholder WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id + ]); + if (r.changes === 0) return notFound("stakeholder not found"); + return okRes({ success: true }); + } + + // src/routes/equity.ts + function listShareClasses(ctx) { + const rows = db.query( + `SELECT sc.id, sc.idx, sc.name, sc.class_type AS classType, sc.prefix, + sc.initial_shares_authorized AS initialSharesAuthorized, + sc.votes_per_share AS votesPerShare, sc.par_value AS parValue, + sc.price_per_share AS pricePerShare, sc.seniority, + sc.conversion_rights AS conversionRights, + sc.liquidation_preference_multiple AS liquidationPreferenceMultiple, + sc.participation_cap_multiple AS participationCapMultiple, + c.name AS companyName + FROM share_class sc + JOIN company c ON c.id = sc.company_id + WHERE sc.company_id = ? + ORDER BY sc.idx ASC`, + [ctx.companyId] + ); + return okRes(rows); + } + function validateShareClass(o, errs) { + const classType = oneOf(o, "classType", ShareClassType, errs); + return { + name: reqString(o, "name", errs), + classType, + prefix: classType === "COMMON" ? "CS" : "PS", + initialSharesAuthorized: intNum(o, "initialSharesAuthorized", errs, 1), + boardApprovalDate: dateString(o, "boardApprovalDate", errs), + stockholderApprovalDate: dateString(o, "stockholderApprovalDate", errs), + votesPerShare: intNum(o, "votesPerShare", errs, 0), + parValue: num(o, "parValue", errs, 0), + pricePerShare: num(o, "pricePerShare", errs, 0), + seniority: intNum(o, "seniority", errs, 0), + conversionRights: oneOf(o, "conversionRights", ConversionRights, errs), + convertsToShareClassId: optString(o, "convertsToShareClassId"), + liquidationPreferenceMultiple: num(o, "liquidationPreferenceMultiple", errs, 0), + participationCapMultiple: num(o, "participationCapMultiple", errs, 0) + }; + } + function createShareClass(ctx) { + const errs = []; + const s = validateShareClass(asObj(ctx.body), errs); + if (errs.length) return badReq(errs); + const countRows = db.query( + `SELECT COUNT(*) AS n FROM share_class WHERE company_id = ?`, + [ctx.companyId] + ); + const idx = Number(countRows[0].n) + 1; + const ts = now(); + db.exec( + `INSERT INTO share_class + (id, company_id, idx, name, class_type, prefix, + initial_shares_authorized, board_approval_date, stockholder_approval_date, + votes_per_share, par_value, price_per_share, seniority, conversion_rights, + converts_to_share_class_id, liquidation_preference_multiple, + participation_cap_multiple, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + idx, + s.name, + s.classType, + s.prefix, + s.initialSharesAuthorized, + s.boardApprovalDate, + s.stockholderApprovalDate, + s.votesPerShare, + s.parValue, + s.pricePerShare, + s.seniority, + s.conversionRights, + s.convertsToShareClassId, + s.liquidationPreferenceMultiple, + s.participationCapMultiple, + ts, + ts + ] + ); + return created({ success: true, message: "Share class created successfully." }); + } + function updateShareClass(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["share class id is required"]); + const errs = []; + const s = validateShareClass(asObj(ctx.body), errs); + if (errs.length) return badReq(errs); + const r = db.exec( + `UPDATE share_class + SET name = ?, class_type = ?, prefix = ?, initial_shares_authorized = ?, + board_approval_date = ?, stockholder_approval_date = ?, + votes_per_share = ?, par_value = ?, price_per_share = ?, seniority = ?, + conversion_rights = ?, converts_to_share_class_id = ?, + liquidation_preference_multiple = ?, participation_cap_multiple = ?, + updated_at = ? + WHERE company_id = ? AND id = ?`, + [ + s.name, + s.classType, + s.prefix, + s.initialSharesAuthorized, + s.boardApprovalDate, + s.stockholderApprovalDate, + s.votesPerShare, + s.parValue, + s.pricePerShare, + s.seniority, + s.conversionRights, + s.convertsToShareClassId, + s.liquidationPreferenceMultiple, + s.participationCapMultiple, + now(), + ctx.companyId, + id + ] + ); + if (r.changes === 0) return notFound("share class not found"); + return okRes({ success: true, message: "Share class updated successfully." }); + } + function listEquityPlans(ctx) { + const rows = db.query( + `SELECT id, name, board_approval_date AS boardApprovalDate, + plan_effective_date AS planEffectiveDate, + initial_shares_reserved AS initialSharesReserved, + default_cancellation_behavior AS defaultCancellatonBehavior, + share_class_id AS shareClassId, comments, created_at AS createdAt + FROM equity_plan WHERE company_id = ? + ORDER BY created_at DESC`, + [ctx.companyId] + ); + return okRes({ data: rows }); + } + function createEquityPlan(ctx) { + const o = asObj(ctx.body); + const errs = []; + const name = reqString(o, "name", errs); + const boardApprovalDate = dateString(o, "boardApprovalDate", errs); + const initialSharesReserved = intNum(o, "initialSharesReserved", errs, 0); + const shareClassId = reqString(o, "shareClassId", errs); + const behavior = oneOf(o, "defaultCancellatonBehavior", CancellationBehavior, errs); + if (errs.length) return badReq(errs); + if (!refExists(ctx.companyId, "share_class", shareClassId)) { + return badReq(["shareClassId does not reference a share class in this company"]); + } + const ts = now(); + db.exec( + `INSERT INTO equity_plan + (id, company_id, name, board_approval_date, plan_effective_date, + initial_shares_reserved, default_cancellation_behavior, share_class_id, + comments, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + name, + boardApprovalDate, + optDateString(o, "planEffectiveDate"), + initialSharesReserved, + behavior, + shareClassId, + optString(o, "comments"), + ts, + ts + ] + ); + return created({ success: true, message: "Equity plan created successfully." }); + } + + // src/routes/securities.ts + function listShares(ctx) { + const rows = db.query( + `SELECT sh.id, sh.certificate_id AS certificateId, sh.quantity, + sh.price_per_share AS pricePerShare, + sh.capital_contribution AS capitalContribution, + sh.status, sh.issue_date AS issueDate, + sh.company_legends AS companyLegends, + st.name AS stakeholderName, st.id AS stakeholderId, + sc.class_type AS shareClassType, sc.id AS shareClassId, sc.name AS shareClassName + FROM share sh + JOIN stakeholder st ON st.id = sh.stakeholder_id + JOIN share_class sc ON sc.id = sh.share_class_id + WHERE sh.company_id = ? + ORDER BY sh.created_at DESC`, + [ctx.companyId] + ); + for (const r of rows) { + try { + r.companyLegends = JSON.parse(String(r.companyLegends || "[]")); + } catch (_e) { + r.companyLegends = []; + } + } + return okRes({ data: rows }); + } + function insertShare(companyId, s) { + const id = newId(); + const ts = now(); + db.exec( + `INSERT INTO share + (id, company_id, stakeholder_id, share_class_id, status, certificate_id, + quantity, price_per_share, capital_contribution, ip_contribution, + debt_cancelled, other_contributions, cliff_years, vesting_years, + company_legends, issue_date, rule144_date, vesting_start_date, + board_approval_date, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + id, + companyId, + s.stakeholderId, + s.shareClassId, + s.status, + s.certificateId, + s.quantity, + s.pricePerShare, + s.capitalContribution, + s.ipContribution, + s.debtCancelled, + s.otherContributions, + s.cliffYears, + s.vestingYears, + JSON.stringify(s.companyLegends), + s.issueDate, + s.rule144Date, + s.vestingStartDate, + s.boardApprovalDate, + ts, + ts + ] + ); + return id; + } + function addShare(ctx) { + const o = asObj(ctx.body); + const errs = []; + const stakeholderId = reqString(o, "stakeholderId", errs); + const shareClassId = reqString(o, "shareClassId", errs); + const certificateId = reqString(o, "certificateId", errs); + const quantity = intNum(o, "quantity", errs, 0); + const status = oneOf(o, "status", SecuritiesStatus, errs); + const rest = { + pricePerShare: optNum(o, "pricePerShare"), + capitalContribution: optNum(o, "capitalContribution"), + ipContribution: optNum(o, "ipContribution"), + debtCancelled: optNum(o, "debtCancelled"), + otherContributions: optNum(o, "otherContributions"), + cliffYears: intNum(o, "cliffYears", errs, 0), + vestingYears: intNum(o, "vestingYears", errs, 0), + companyLegends: legends(o, "companyLegends", errs), + issueDate: dateString(o, "issueDate", errs), + rule144Date: optDateString(o, "rule144Date"), + vestingStartDate: optDateString(o, "vestingStartDate"), + boardApprovalDate: dateString(o, "boardApprovalDate", errs) + }; + if (errs.length) return badReq(errs); + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if (!refExists(ctx.companyId, "share_class", shareClassId)) { + return badReq(["shareClassId does not reference a share class in this company"]); + } + if (db.query(`SELECT 1 FROM share WHERE company_id = ? AND certificate_id = ?`, [ + ctx.companyId, + certificateId + ]).length > 0) { + return conflict("Please use unique Certificate Id."); + } + insertShare(ctx.companyId, __spreadValues({ + stakeholderId, + shareClassId, + status, + certificateId, + quantity + }, rest)); + return created({ success: true, message: "\u{1F389} Successfully added a share" }); + } + function deleteShare(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["share id is required"]); + const r = db.exec(`DELETE FROM share WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id + ]); + if (r.changes === 0) return notFound("share not found"); + return okRes({ success: true }); + } + function transferShare(ctx) { + const o = asObj(ctx.body); + const errs = []; + const shareId = reqString(o, "shareId", errs); + const toStakeholderId = reqString(o, "toStakeholderId", errs); + if (errs.length) return badReq(errs); + const rows = db.query( + `SELECT id, stakeholder_id AS stakeholderId, share_class_id AS shareClassId, + quantity, status, certificate_id AS certificateId + FROM share WHERE company_id = ? AND id = ?`, + [ctx.companyId, shareId] + ); + if (rows.length === 0) return notFound("share not found"); + const src = rows[0]; + const held = Number(src.quantity); + if (!refExists(ctx.companyId, "stakeholder", toStakeholderId)) { + return badReq(["toStakeholderId does not reference a stakeholder in this company"]); + } + const qtyRaw = o.quantity; + const qty = qtyRaw === void 0 || qtyRaw === null ? held : Math.trunc(Number(qtyRaw)); + if (Number.isNaN(qty) || qty <= 0 || qty > held) { + return badReq([`quantity must be between 1 and ${held}`]); + } + if (qty === held) { + db.exec( + `UPDATE share SET stakeholder_id = ?, updated_at = ? WHERE company_id = ? AND id = ?`, + [toStakeholderId, now(), ctx.companyId, shareId] + ); + return okRes({ + success: true, + message: "Share transferred", + transferred: qty, + newShareId: null + }); + } + const newCert = optString(o, "certificateId"); + if (!newCert) { + return badReq(["certificateId is required for a partial transfer"]); + } + if (db.query(`SELECT 1 FROM share WHERE company_id = ? AND certificate_id = ?`, [ + ctx.companyId, + newCert + ]).length > 0) { + return conflict("Please use unique Certificate Id."); + } + db.exec(`UPDATE share SET quantity = ?, updated_at = ? WHERE company_id = ? AND id = ?`, [ + held - qty, + now(), + ctx.companyId, + shareId + ]); + const newId2 = insertShare(ctx.companyId, { + stakeholderId: toStakeholderId, + shareClassId: src.shareClassId, + status: src.status, + certificateId: newCert, + quantity: qty, + pricePerShare: null, + capitalContribution: null, + ipContribution: null, + debtCancelled: null, + otherContributions: null, + cliffYears: 0, + vestingYears: 0, + companyLegends: [], + issueDate: new Date(now()).toISOString().slice(0, 10), + rule144Date: null, + vestingStartDate: null, + boardApprovalDate: new Date(now()).toISOString().slice(0, 10) + }); + return okRes({ + success: true, + message: "Share partially transferred", + transferred: qty, + newShareId: newId2 + }); + } + function listOptions(ctx) { + const rows = db.query( + `SELECT o.id, o.grant_id AS grantId, o.quantity, + o.exercise_price AS exercisePrice, o.type, o.status, + o.cliff_years AS cliffYears, o.vesting_years AS vestingYears, + o.issue_date AS issueDate, o.expiration_date AS expirationDate, + st.name AS stakeholderName, st.id AS stakeholderId, + ep.name AS equityPlanName, ep.id AS equityPlanId + FROM option o + JOIN stakeholder st ON st.id = o.stakeholder_id + JOIN equity_plan ep ON ep.id = o.equity_plan_id + WHERE o.company_id = ? + ORDER BY o.created_at DESC`, + [ctx.companyId] + ); + return okRes({ data: rows }); + } + function addOption(ctx) { + const o = asObj(ctx.body); + const errs = []; + const grantId = reqString(o, "grantId", errs); + const stakeholderId = reqString(o, "stakeholderId", errs); + const equityPlanId = reqString(o, "equityPlanId", errs); + const quantity = intNum(o, "quantity", errs, 0); + const exercisePrice = num(o, "exercisePrice", errs, 0); + const type = oneOf(o, "type", OptionType, errs); + const status = oneOf(o, "status", OptionStatus, errs); + const rest = { + cliffYears: intNum(o, "cliffYears", errs, 0), + vestingYears: intNum(o, "vestingYears", errs, 0), + issueDate: dateString(o, "issueDate", errs), + expirationDate: dateString(o, "expirationDate", errs), + vestingStartDate: dateString(o, "vestingStartDate", errs), + boardApprovalDate: dateString(o, "boardApprovalDate", errs), + rule144Date: dateString(o, "rule144Date", errs), + notes: optString(o, "notes") + }; + if (errs.length) return badReq(errs); + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if (!refExists(ctx.companyId, "equity_plan", equityPlanId)) { + return badReq(["equityPlanId does not reference an equity plan in this company"]); + } + if (db.query(`SELECT 1 FROM option WHERE company_id = ? AND grant_id = ?`, [ + ctx.companyId, + grantId + ]).length > 0) { + return conflict("Please use unique Grant Id."); + } + const ts = now(); + db.exec( + `INSERT INTO option + (id, company_id, stakeholder_id, equity_plan_id, grant_id, notes, quantity, + exercise_price, type, status, cliff_years, vesting_years, issue_date, + expiration_date, vesting_start_date, board_approval_date, rule144_date, + created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + stakeholderId, + equityPlanId, + grantId, + rest.notes, + quantity, + exercisePrice, + type, + status, + rest.cliffYears, + rest.vestingYears, + rest.issueDate, + rest.expirationDate, + rest.vestingStartDate, + rest.boardApprovalDate, + rest.rule144Date, + ts, + ts + ] + ); + return created({ success: true, message: "\u{1F389} Successfully added an option" }); + } + function deleteOption(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["option id is required"]); + const r = db.exec(`DELETE FROM option WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id + ]); + if (r.changes === 0) return notFound("option not found"); + return okRes({ success: true }); + } + + // src/routes/convertibles.ts + function listSafes(ctx) { + const rows = db.query( + `SELECT sf.id, sf.public_id AS publicId, sf.type, sf.status, sf.capital, + sf.valuation_cap AS valuationCap, sf.discount_rate AS discountRate, + sf.mfn, sf.pro_rata AS proRata, sf.issue_date AS issueDate, + st.name AS stakeholderName, st.id AS stakeholderId + FROM safe sf + JOIN stakeholder st ON st.id = sf.stakeholder_id + WHERE sf.company_id = ? + ORDER BY sf.created_at DESC`, + [ctx.companyId] + ); + for (const r of rows) { + r.mfn = Number(r.mfn) !== 0; + r.proRata = Number(r.proRata) !== 0; + } + return okRes({ data: rows }); + } + function createSafe(ctx) { + const o = asObj(ctx.body); + const errs = []; + const publicId = reqString(o, "publicId", errs); + const stakeholderId = reqString(o, "stakeholderId", errs); + const capital = num(o, "capital", errs, 0); + const type = oneOf(o, "type", SafeType, errs); + const status = oneOf(o, "status", SafeStatus, errs); + const issueDate = dateString(o, "issueDate", errs); + const boardApprovalDate = dateString(o, "boardApprovalDate", errs); + if (errs.length) return badReq(errs); + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if (db.query(`SELECT 1 FROM safe WHERE company_id = ? AND public_id = ?`, [ + ctx.companyId, + publicId + ]).length > 0) { + return conflict("Please use a unique SAFE id."); + } + const ts = now(); + db.exec( + `INSERT INTO safe + (id, company_id, stakeholder_id, public_id, type, status, capital, + valuation_cap, discount_rate, mfn, pro_rata, additional_terms, + issue_date, board_approval_date, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + stakeholderId, + publicId, + type, + status, + capital, + optNum(o, "valuationCap"), + optNum(o, "discountRate"), + o.mfn ? 1 : 0, + o.proRata ? 1 : 0, + optString(o, "additionalTerms"), + issueDate, + boardApprovalDate, + ts, + ts + ] + ); + return created({ success: true, message: "SAFE created successfully." }); + } + function deleteSafe(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["safe id is required"]); + const r = db.exec(`DELETE FROM safe WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id + ]); + if (r.changes === 0) return notFound("safe not found"); + return okRes({ success: true }); + } + function listConvertibles(ctx) { + const rows = db.query( + `SELECT cn.id, cn.public_id AS publicId, cn.type, cn.status, cn.capital, + cn.conversion_cap AS conversionCap, cn.discount_rate AS discountRate, + cn.interest_rate AS interestRate, cn.issue_date AS issueDate, + st.name AS stakeholderName, st.id AS stakeholderId + FROM convertible_note cn + JOIN stakeholder st ON st.id = cn.stakeholder_id + WHERE cn.company_id = ? + ORDER BY cn.created_at DESC`, + [ctx.companyId] + ); + return okRes({ data: rows }); + } + function createConvertible(ctx) { + const o = asObj(ctx.body); + const errs = []; + const publicId = reqString(o, "publicId", errs); + const stakeholderId = reqString(o, "stakeholderId", errs); + const capital = num(o, "capital", errs, 0); + const type = oneOf(o, "type", ConvertibleType, errs); + const status = oneOf(o, "status", ConvertibleStatus, errs); + const issueDate = dateString(o, "issueDate", errs); + const boardApprovalDate = dateString(o, "boardApprovalDate", errs); + if (errs.length) return badReq(errs); + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if (db.query(`SELECT 1 FROM convertible_note WHERE company_id = ? AND public_id = ?`, [ + ctx.companyId, + publicId + ]).length > 0) { + return conflict("Please use a unique convertible note id."); + } + const ts = now(); + db.exec( + `INSERT INTO convertible_note + (id, company_id, stakeholder_id, public_id, type, status, capital, + conversion_cap, discount_rate, mfn, interest_rate, additional_terms, + issue_date, board_approval_date, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + stakeholderId, + publicId, + type, + status, + capital, + optNum(o, "conversionCap"), + optNum(o, "discountRate"), + o.mfn ? 1 : 0, + optNum(o, "interestRate"), + optString(o, "additionalTerms"), + issueDate, + boardApprovalDate, + ts, + ts + ] + ); + return created({ success: true, message: "Convertible note created successfully." }); + } + function deleteConvertible(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["convertible note id is required"]); + const r = db.exec(`DELETE FROM convertible_note WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id + ]); + if (r.changes === 0) return notFound("convertible note not found"); + return okRes({ success: true }); + } + + // src/routes/rounds.ts + function listRounds(ctx) { + const rows = db.query( + `SELECT id, name, round_type AS roundType, status, + target_amount AS targetAmount, raised_amount AS raisedAmount, + pre_money_valuation AS preMoneyValuation, + price_per_share AS pricePerShare, share_class_id AS shareClassId, + close_date AS closeDate, created_at AS createdAt + FROM round WHERE company_id = ? + ORDER BY created_at DESC`, + [ctx.companyId] + ); + return okRes({ data: rows }); + } + function createRound(ctx) { + const o = asObj(ctx.body); + const errs = []; + const name = reqString(o, "name", errs); + const roundType = oneOf(o, "roundType", RoundType, errs); + const targetAmount = num(o, "targetAmount", errs, 0); + if (errs.length) return badReq(errs); + let shareClassId = null; + let pricePerShare = null; + let preMoney = null; + if (roundType === "PRICED") { + const perrs = []; + shareClassId = reqString(o, "shareClassId", perrs); + pricePerShare = num(o, "pricePerShare", perrs, 0); + preMoney = optNum(o, "preMoneyValuation"); + if (pricePerShare <= 0) perrs.push("pricePerShare must be > 0 for a priced round"); + if (perrs.length) return badReq(perrs); + if (!refExists(ctx.companyId, "share_class", shareClassId)) { + return badReq(["shareClassId does not reference a share class in this company"]); + } + } + const ts = now(); + const id = newId(); + db.exec( + `INSERT INTO round + (id, company_id, name, round_type, status, target_amount, raised_amount, + pre_money_valuation, price_per_share, share_class_id, close_date, + created_at, updated_at) + VALUES (?,?,?,?, 'OPEN', ?, 0, ?, ?, ?, NULL, ?, ?)`, + [id, ctx.companyId, name, roundType, targetAmount, preMoney, pricePerShare, shareClassId, ts, ts] + ); + return created({ success: true, message: "Round created.", id }); + } + function getRound(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["round id is required"]); + const rows = db.query( + `SELECT id, name, round_type AS roundType, status, + target_amount AS targetAmount, raised_amount AS raisedAmount, + pre_money_valuation AS preMoneyValuation, + price_per_share AS pricePerShare, share_class_id AS shareClassId, + close_date AS closeDate, created_at AS createdAt + FROM round WHERE company_id = ? AND id = ?`, + [ctx.companyId, id] + ); + if (rows.length === 0) return notFound("round not found"); + const investments = db.query( + `SELECT i.id, i.amount, i.shares, i.date, i.comments, + st.name AS stakeholderName, st.id AS stakeholderId + FROM investment i + JOIN stakeholder st ON st.id = i.stakeholder_id + WHERE i.company_id = ? AND i.round_id = ? + ORDER BY i.created_at ASC`, + [ctx.companyId, id] + ); + for (const r of investments) r.shares = Number(r.shares); + return okRes({ round: rows[0], investments }); + } + function closeRound(ctx) { + const id = ctx.params.id; + if (!id) return badReq(["round id is required"]); + const o = asObj(ctx.body); + const closeDate = optDateString(o, "closeDate") || new Date(now()).toISOString().slice(0, 10); + const r = db.exec( + `UPDATE round SET status = 'CLOSED', close_date = ?, updated_at = ? + WHERE company_id = ? AND id = ? AND status = 'OPEN'`, + [closeDate, now(), ctx.companyId, id] + ); + if (r.changes === 0) return notFound("open round not found"); + return okRes({ success: true, message: "Round closed." }); + } + function listInvestments(ctx) { + const rows = db.query( + `SELECT i.id, i.round_id AS roundId, i.amount, i.shares, i.date, + i.share_class_id AS shareClassId, st.name AS stakeholderName, + st.id AS stakeholderId + FROM investment i + JOIN stakeholder st ON st.id = i.stakeholder_id + WHERE i.company_id = ? + ORDER BY i.created_at DESC`, + [ctx.companyId] + ); + for (const r of rows) r.shares = Number(r.shares); + return okRes({ data: rows }); + } + function addInvestment(ctx) { + const roundId = ctx.params.id; + if (!roundId) return badReq(["round id is required"]); + const o = asObj(ctx.body); + const errs = []; + const stakeholderId = reqString(o, "stakeholderId", errs); + const amount = num(o, "amount", errs, 0); + if (errs.length) return badReq(errs); + const roundRows = db.query( + `SELECT id, round_type AS roundType, status, price_per_share AS pricePerShare, + share_class_id AS shareClassId + FROM round WHERE company_id = ? AND id = ?`, + [ctx.companyId, roundId] + ); + if (roundRows.length === 0) return notFound("round not found"); + const round = roundRows[0]; + if (round.status !== "OPEN") return badReq(["round is not open for investment"]); + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + const date = optDateString(o, "date") || new Date(now()).toISOString().slice(0, 10); + let shares = 0; + let newShareId = null; + if (round.roundType === "PRICED") { + const pps = Number(round.pricePerShare); + shares = Math.floor(amount / pps); + if (shares <= 0) return badReq(["amount is too small to buy a whole share at this round price"]); + const cert = `INV-${roundId.slice(1, 9)}-${newId().slice(1, 7)}`; + newShareId = insertShare(ctx.companyId, { + stakeholderId, + shareClassId: String(round.shareClassId), + status: "ACTIVE", + certificateId: cert, + quantity: shares, + pricePerShare: pps, + capitalContribution: amount, + ipContribution: null, + debtCancelled: null, + otherContributions: null, + cliffYears: 0, + vestingYears: 0, + companyLegends: [], + issueDate: date, + rule144Date: null, + vestingStartDate: null, + boardApprovalDate: date + }); + } + const ts = now(); + const invId = newId(); + db.exec( + `INSERT INTO investment + (id, company_id, round_id, stakeholder_id, share_class_id, amount, shares, + date, comments, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [ + invId, + ctx.companyId, + roundId, + stakeholderId, + round.shareClassId, + amount, + shares, + date, + optString(o, "comments"), + ts, + ts + ] + ); + db.exec( + `UPDATE round SET raised_amount = raised_amount + ?, updated_at = ? WHERE company_id = ? AND id = ?`, + [amount, ts, ctx.companyId, roundId] + ); + return created({ + success: true, + message: "Investment recorded.", + id: invId, + sharesIssued: shares, + newShareId + }); + } + + // src/routes/captable.ts + function capTable(ctx) { + const cid = ctx.companyId; + const company = db.query(`SELECT id, name FROM company WHERE id = ?`, [cid]); + if (company.length === 0) return notFound("company not found"); + const num2 = (rows, key) => rows.length ? Number(rows[0][key]) : 0; + const outstanding = num2( + db.query(`SELECT COALESCE(SUM(quantity),0) AS n FROM share WHERE company_id = ?`, [cid]), + "n" + ); + const optionShares = num2( + db.query(`SELECT COALESCE(SUM(quantity),0) AS n FROM option WHERE company_id = ?`, [cid]), + "n" + ); + const fullyDiluted = outstanding + optionShares; + const byStakeholder = db.query( + `SELECT st.id AS stakeholderId, st.name, + COALESCE((SELECT SUM(sh.quantity) FROM share sh WHERE sh.stakeholder_id = st.id AND sh.company_id = st.company_id), 0) AS shares, + COALESCE((SELECT SUM(op.quantity) FROM option op WHERE op.stakeholder_id = st.id AND op.company_id = st.company_id), 0) AS options + FROM stakeholder st + WHERE st.company_id = ? + ORDER BY shares DESC, options DESC`, + [cid] + ); + for (const r of byStakeholder) { + const shares = Number(r.shares); + const options = Number(r.options); + r.shares = shares; + r.options = options; + const held = shares + options; + r.fullyDiluted = held; + r.ownershipPct = fullyDiluted > 0 ? Math.round(held / fullyDiluted * 1e4) / 100 : 0; + } + const byShareClass = db.query( + `SELECT sc.id AS shareClassId, sc.name, sc.class_type AS classType, + sc.initial_shares_authorized AS authorized, + COALESCE((SELECT SUM(sh.quantity) FROM share sh WHERE sh.share_class_id = sc.id AND sh.company_id = sc.company_id), 0) AS issued + FROM share_class sc + WHERE sc.company_id = ? + ORDER BY sc.idx ASC`, + [cid] + ); + for (const r of byShareClass) { + r.authorized = Number(r.authorized); + r.issued = Number(r.issued); + } + const convertibles = { + safes: { + count: num2(db.query(`SELECT COUNT(*) AS n FROM safe WHERE company_id = ?`, [cid]), "n"), + capital: num2(db.query(`SELECT COALESCE(SUM(capital),0) AS n FROM safe WHERE company_id = ?`, [cid]), "n") + }, + notes: { + count: num2(db.query(`SELECT COUNT(*) AS n FROM convertible_note WHERE company_id = ?`, [cid]), "n"), + capital: num2(db.query(`SELECT COALESCE(SUM(capital),0) AS n FROM convertible_note WHERE company_id = ?`, [cid]), "n") + } + }; + const rounds = { + count: num2(db.query(`SELECT COUNT(*) AS n FROM round WHERE company_id = ?`, [cid]), "n"), + totalRaised: num2(db.query(`SELECT COALESCE(SUM(raised_amount),0) AS n FROM round WHERE company_id = ?`, [cid]), "n") + }; + return okRes({ + company: company[0], + totals: { + outstandingShares: outstanding, + grantedOptions: optionShares, + fullyDilutedShares: fullyDiluted, + stakeholders: num2(db.query(`SELECT COUNT(*) AS n FROM stakeholder WHERE company_id = ?`, [cid]), "n"), + shareClasses: num2(db.query(`SELECT COUNT(*) AS n FROM share_class WHERE company_id = ?`, [cid]), "n") + }, + byStakeholder, + byShareClass, + convertibles, + rounds + }); + } + + // src/index.ts + var routes = { + "company.get": getCompany, + "company.update": updateCompany, + "stakeholders.list": listStakeholders, + "stakeholders.add": addStakeholders, + "stakeholders.update": updateStakeholder, + "stakeholders.delete": deleteStakeholder, + "shareClasses.list": listShareClasses, + "shareClasses.create": createShareClass, + "shareClasses.update": updateShareClass, + "equityPlans.list": listEquityPlans, + "equityPlans.create": createEquityPlan, + "shares.list": listShares, + "shares.add": addShare, + "shares.delete": deleteShare, + "shares.transfer": transferShare, + "options.list": listOptions, + "options.add": addOption, + "options.delete": deleteOption, + "safes.list": listSafes, + "safes.create": createSafe, + "safes.delete": deleteSafe, + "convertibles.list": listConvertibles, + "convertibles.create": createConvertible, + "convertibles.delete": deleteConvertible, + "rounds.list": listRounds, + "rounds.create": createRound, + "rounds.get": getRound, + "rounds.close": closeRound, + "rounds.investments.list": listInvestments, + "rounds.investments.add": addInvestment, + captable: capTable + }; + globalThis.handle = (req) => { + req = req || {}; + const route = req.route || ""; + const companyId = req.orgId || ""; + if (!companyId) { + return { status: 401, body: { success: false, message: "missing tenant" } }; + } + const fn = routes[route]; + if (!fn) { + return { + status: 404, + body: { success: false, message: `unknown captable route: ${route}` } + }; + } + const ctx = { companyId, params: req.params || {}, body: req.body }; + try { + return fn(ctx); + } catch (err) { + return { + status: 500, + body: { + success: false, + message: String(err && err.message || err) + } + }; + } + }; +})(); diff --git a/goja/package.json b/goja/package.json new file mode 100644 index 00000000..07e1eacf --- /dev/null +++ b/goja/package.json @@ -0,0 +1,14 @@ +{ + "name": "@hanzo/captable-goja", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Self-contained goja bundle of the captable business logic — hosted in-process by hanzoai/cloud (HIP-0106). Source of truth: src/*.ts; artifact: bundle.js.", + "scripts": { + "build": "node build.mjs", + "test": "node test/bundle.test.mjs" + }, + "devDependencies": { + "esbuild": "0.28.1" + } +} diff --git a/goja/src/host.ts b/goja/src/host.ts new file mode 100644 index 00000000..d6cd6477 --- /dev/null +++ b/goja/src/host.ts @@ -0,0 +1,46 @@ +// Host bridge — the ONLY seam between the captable business logic and the world. +// +// The Go host (hanzoai/cloud clients/captable) injects these globals onto the +// goja runtime before calling handle(): +// +// globalThis.__db.query(sql, args) -> row objects (SELECT) +// globalThis.__db.exec(sql, args) -> { changes, lastId } (INSERT/UPDATE/DELETE) +// globalThis.__newId() -> a fresh collision-resistant id +// globalThis.__now() -> unix milliseconds (host clock) +// +// There is NO Prisma, NO tRPC, NO next-auth and NO node: builtin in this path. +// Persistence is per-tenant Base/SQLite, owned by the Go host; this bundle only +// issues SQL through the bridge. Every query is already inside ONE tenant's +// database file, and additionally carries the company_id column for defence in +// depth (mirrors the tRPC `where: { companyId }` scope). + +export interface Db { + query(sql: string, args: unknown[]): Record[]; + exec(sql: string, args: unknown[]): { changes: number; lastId: string }; +} + +declare global { + // eslint-disable-next-line no-var + var __db: Db; + // eslint-disable-next-line no-var + var __newId: () => string; + // eslint-disable-next-line no-var + var __now: () => number; +} + +export const db: Db = { + query(sql, args) { + return globalThis.__db.query(sql, args || []); + }, + exec(sql, args) { + return globalThis.__db.exec(sql, args || []); + }, +}; + +export function newId(): string { + return globalThis.__newId(); +} + +export function now(): number { + return globalThis.__now(); +} diff --git a/goja/src/index.ts b/goja/src/index.ts new file mode 100644 index 00000000..7bb658de --- /dev/null +++ b/goja/src/index.ts @@ -0,0 +1,142 @@ +// @hanzo/captable — goja bundle entry. +// +// SELF-CONTAINED, NO ESM, NO node: imports. Bundled by esbuild into +// goja/bundle.js and run verbatim inside the dop251/goja engine embedded in +// hanzoai/cloud (HIP-0106), hosted by clients/gojabase — the reusable +// read-write-Base binding. See goja/README.md for the full host contract. +// +// globalThis.__db = { query(sql,args)->rows, exec(sql,args)->{changes,lastId} } +// globalThis.__newId = () => string +// globalThis.__now = () => number (unix millis) +// globalThis.handle({ route, params, orgId, session, body }) -> { status, body } +// +// orgId is the gateway-minted, validated tenant; the host uses it BOTH to select +// the per-tenant SQLite file AND passes it here as the cap-table company id. + +import type { Ctx, Res } from "./routes/common"; +import { getCompany, updateCompany } from "./routes/company"; +import { + addStakeholders, + deleteStakeholder, + listStakeholders, + updateStakeholder, +} from "./routes/stakeholders"; +import { + createEquityPlan, + createShareClass, + listEquityPlans, + listShareClasses, + updateShareClass, +} from "./routes/equity"; +import { + addOption, + addShare, + deleteOption, + deleteShare, + listOptions, + listShares, + transferShare, +} from "./routes/securities"; +import { + createConvertible, + createSafe, + deleteConvertible, + deleteSafe, + listConvertibles, + listSafes, +} from "./routes/convertibles"; +import { + addInvestment, + closeRound, + createRound, + getRound, + listInvestments, + listRounds, +} from "./routes/rounds"; +import { capTable } from "./routes/captable"; + +interface Req { + route?: string; + params?: Record; + orgId?: string; + body?: unknown; + session?: unknown; +} + +type Handler = (ctx: Ctx) => Res; + +const routes: Record = { + "company.get": getCompany, + "company.update": updateCompany, + + "stakeholders.list": listStakeholders, + "stakeholders.add": addStakeholders, + "stakeholders.update": updateStakeholder, + "stakeholders.delete": deleteStakeholder, + + "shareClasses.list": listShareClasses, + "shareClasses.create": createShareClass, + "shareClasses.update": updateShareClass, + + "equityPlans.list": listEquityPlans, + "equityPlans.create": createEquityPlan, + + "shares.list": listShares, + "shares.add": addShare, + "shares.delete": deleteShare, + "shares.transfer": transferShare, + + "options.list": listOptions, + "options.add": addOption, + "options.delete": deleteOption, + + "safes.list": listSafes, + "safes.create": createSafe, + "safes.delete": deleteSafe, + + "convertibles.list": listConvertibles, + "convertibles.create": createConvertible, + "convertibles.delete": deleteConvertible, + + "rounds.list": listRounds, + "rounds.create": createRound, + "rounds.get": getRound, + "rounds.close": closeRound, + "rounds.investments.list": listInvestments, + "rounds.investments.add": addInvestment, + + captable: capTable, +}; + +globalThis.handle = (req: Req): Res => { + req = req || {}; + const route = req.route || ""; + const companyId = req.orgId || ""; + if (!companyId) { + return { status: 401, body: { success: false, message: "missing tenant" } }; + } + const fn = routes[route]; + if (!fn) { + return { + status: 404, + body: { success: false, message: `unknown captable route: ${route}` }, + }; + } + const ctx: Ctx = { companyId, params: req.params || {}, body: req.body }; + try { + return fn(ctx); + } catch (err) { + return { + status: 500, + body: { + success: false, + message: String((err && (err as Error).message) || err), + }, + }; + } +}; + +declare global { + // eslint-disable-next-line no-var + var handle: (req: Req) => Res; +} diff --git a/goja/src/routes/captable.ts b/goja/src/routes/captable.ts new file mode 100644 index 00000000..e9885318 --- /dev/null +++ b/goja/src/routes/captable.ts @@ -0,0 +1,96 @@ +// Cap-table computation — the read the whole pilot exists to prove round-trips +// through Base. A real aggregation over issued equity: +// - outstanding = Σ share.quantity +// - fullyDiluted = outstanding + Σ option.quantity (granted options) +// - per-stakeholder ownership on a fully-diluted basis +// - per-share-class authorized vs issued +// - convertibles (SAFE + note capital not yet converted) as a side section +// - rounds summary (count + total raised) + +import { db } from "../host"; +import { type Ctx, notFound, okRes, type Res } from "./common"; + +export function capTable(ctx: Ctx): Res { + const cid = ctx.companyId; + const company = db.query(`SELECT id, name FROM company WHERE id = ?`, [cid]); + if (company.length === 0) return notFound("company not found"); + + const num = (rows: Record[], key: string): number => + rows.length ? Number(rows[0][key]) : 0; + + const outstanding = num( + db.query(`SELECT COALESCE(SUM(quantity),0) AS n FROM share WHERE company_id = ?`, [cid]), + "n", + ); + const optionShares = num( + db.query(`SELECT COALESCE(SUM(quantity),0) AS n FROM option WHERE company_id = ?`, [cid]), + "n", + ); + const fullyDiluted = outstanding + optionShares; + + // Per-stakeholder shares + options, ownership on the fully-diluted base. + const byStakeholder = db.query( + `SELECT st.id AS stakeholderId, st.name, + COALESCE((SELECT SUM(sh.quantity) FROM share sh WHERE sh.stakeholder_id = st.id AND sh.company_id = st.company_id), 0) AS shares, + COALESCE((SELECT SUM(op.quantity) FROM option op WHERE op.stakeholder_id = st.id AND op.company_id = st.company_id), 0) AS options + FROM stakeholder st + WHERE st.company_id = ? + ORDER BY shares DESC, options DESC`, + [cid], + ); + for (const r of byStakeholder) { + const shares = Number(r.shares); + const options = Number(r.options); + r.shares = shares; + r.options = options; + const held = shares + options; + r.fullyDiluted = held; + r.ownershipPct = + fullyDiluted > 0 ? Math.round((held / fullyDiluted) * 10000) / 100 : 0; + } + + const byShareClass = db.query( + `SELECT sc.id AS shareClassId, sc.name, sc.class_type AS classType, + sc.initial_shares_authorized AS authorized, + COALESCE((SELECT SUM(sh.quantity) FROM share sh WHERE sh.share_class_id = sc.id AND sh.company_id = sc.company_id), 0) AS issued + FROM share_class sc + WHERE sc.company_id = ? + ORDER BY sc.idx ASC`, + [cid], + ); + for (const r of byShareClass) { + r.authorized = Number(r.authorized); + r.issued = Number(r.issued); + } + + const convertibles = { + safes: { + count: num(db.query(`SELECT COUNT(*) AS n FROM safe WHERE company_id = ?`, [cid]), "n"), + capital: num(db.query(`SELECT COALESCE(SUM(capital),0) AS n FROM safe WHERE company_id = ?`, [cid]), "n"), + }, + notes: { + count: num(db.query(`SELECT COUNT(*) AS n FROM convertible_note WHERE company_id = ?`, [cid]), "n"), + capital: num(db.query(`SELECT COALESCE(SUM(capital),0) AS n FROM convertible_note WHERE company_id = ?`, [cid]), "n"), + }, + }; + + const rounds = { + count: num(db.query(`SELECT COUNT(*) AS n FROM round WHERE company_id = ?`, [cid]), "n"), + totalRaised: num(db.query(`SELECT COALESCE(SUM(raised_amount),0) AS n FROM round WHERE company_id = ?`, [cid]), "n"), + }; + + return okRes({ + company: company[0], + totals: { + outstandingShares: outstanding, + grantedOptions: optionShares, + fullyDilutedShares: fullyDiluted, + stakeholders: num(db.query(`SELECT COUNT(*) AS n FROM stakeholder WHERE company_id = ?`, [cid]), "n"), + shareClasses: num(db.query(`SELECT COUNT(*) AS n FROM share_class WHERE company_id = ?`, [cid]), "n"), + }, + byStakeholder, + byShareClass, + convertibles, + rounds, + }); +} diff --git a/goja/src/routes/common.ts b/goja/src/routes/common.ts new file mode 100644 index 00000000..208085f9 --- /dev/null +++ b/goja/src/routes/common.ts @@ -0,0 +1,47 @@ +// Shared request/response types + helpers for the captable route handlers. + +import { db } from "../host"; + +export interface Ctx { + companyId: string; // == the validated tenant; scopes every row + params: Record; + body: unknown; +} + +export interface Res { + status: number; + body: unknown; +} + +export const okRes = (body: unknown): Res => ({ status: 200, body }); +export const created = (body: unknown): Res => ({ status: 201, body }); +export const badReq = (errors: string[]): Res => ({ + status: 400, + body: { success: false, message: "Validation failed", errors }, +}); +export const notFound = (msg: string): Res => ({ + status: 404, + body: { success: false, message: msg }, +}); +export const conflict = (msg: string): Res => ({ + status: 409, + body: { success: false, message: msg }, +}); + +export function asObj(body: unknown): Record { + return (body || {}) as Record; +} + +// refExists reports whether a row (company_id, id) exists in `table` — the +// in-tenant referential-integrity check the Prisma FKs gave us, enforced at the +// write layer since every entity lives in the same per-tenant DB. `table` is +// always a package constant, never user input (no injection surface). +export function refExists(companyId: string, table: string, id: string): boolean { + if (!id) return false; + return ( + db.query(`SELECT 1 FROM ${table} WHERE company_id = ? AND id = ?`, [ + companyId, + id, + ]).length > 0 + ); +} diff --git a/goja/src/routes/company.ts b/goja/src/routes/company.ts new file mode 100644 index 00000000..492ed54c --- /dev/null +++ b/goja/src/routes/company.ts @@ -0,0 +1,42 @@ +// Company — the org's cap-table root. The Go host seeds one row per tenant +// (OnOpen), so companyId always resolves; these routes read/rename it. + +import { db, now } from "../host"; +import { asObj, badReq, type Ctx, notFound, okRes, type Res } from "./common"; +import * as v from "../validate"; + +export function getCompany(ctx: Ctx): Res { + const rows = db.query( + `SELECT id, name, public_id AS publicId, incorporation_type AS incorporationType, + incorporation_country AS incorporationCountry, + incorporation_state AS incorporationState, + created_at AS createdAt, updated_at AS updatedAt + FROM company WHERE id = ?`, + [ctx.companyId], + ); + if (rows.length === 0) return notFound("company not found"); + return okRes(rows[0]); +} + +export function updateCompany(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const name = v.reqString(o, "name", errs); + if (errs.length) return badReq(errs); + const r = db.exec( + `UPDATE company + SET name = ?, incorporation_type = ?, incorporation_country = ?, + incorporation_state = ?, updated_at = ? + WHERE id = ?`, + [ + name, + v.optString(o, "incorporationType") || "", + v.optString(o, "incorporationCountry") || "", + v.optString(o, "incorporationState") || "", + now(), + ctx.companyId, + ], + ); + if (r.changes === 0) return notFound("company not found"); + return okRes({ success: true, message: "Company updated" }); +} diff --git a/goja/src/routes/convertibles.ts b/goja/src/routes/convertibles.ts new file mode 100644 index 00000000..d099547b --- /dev/null +++ b/goja/src/routes/convertibles.ts @@ -0,0 +1,181 @@ +// Convertible instruments — SAFEs (src/trpc/routers/safe) and convertible notes. +// Capital raised on these sits OUTSIDE issued equity until conversion; the cap +// table surfaces it as a separate "convertibles" section. + +import { db, newId, now } from "../host"; +import { + asObj, + badReq, + conflict, + type Ctx, + created, + notFound, + okRes, + refExists, + type Res, +} from "./common"; +import * as v from "../validate"; + +// ---- SAFEs ---- + +export function listSafes(ctx: Ctx): Res { + const rows = db.query( + `SELECT sf.id, sf.public_id AS publicId, sf.type, sf.status, sf.capital, + sf.valuation_cap AS valuationCap, sf.discount_rate AS discountRate, + sf.mfn, sf.pro_rata AS proRata, sf.issue_date AS issueDate, + st.name AS stakeholderName, st.id AS stakeholderId + FROM safe sf + JOIN stakeholder st ON st.id = sf.stakeholder_id + WHERE sf.company_id = ? + ORDER BY sf.created_at DESC`, + [ctx.companyId], + ); + for (const r of rows) { + r.mfn = Number(r.mfn) !== 0; + r.proRata = Number(r.proRata) !== 0; + } + return okRes({ data: rows }); +} + +export function createSafe(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const publicId = v.reqString(o, "publicId", errs); + const stakeholderId = v.reqString(o, "stakeholderId", errs); + const capital = v.num(o, "capital", errs, 0); + const type = v.oneOf(o, "type", v.SafeType, errs); + const status = v.oneOf(o, "status", v.SafeStatus, errs); + const issueDate = v.dateString(o, "issueDate", errs); + const boardApprovalDate = v.dateString(o, "boardApprovalDate", errs); + if (errs.length) return badReq(errs); + + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if ( + db.query(`SELECT 1 FROM safe WHERE company_id = ? AND public_id = ?`, [ + ctx.companyId, + publicId, + ]).length > 0 + ) { + return conflict("Please use a unique SAFE id."); + } + const ts = now(); + db.exec( + `INSERT INTO safe + (id, company_id, stakeholder_id, public_id, type, status, capital, + valuation_cap, discount_rate, mfn, pro_rata, additional_terms, + issue_date, board_approval_date, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + stakeholderId, + publicId, + type, + status, + capital, + v.optNum(o, "valuationCap"), + v.optNum(o, "discountRate"), + o.mfn ? 1 : 0, + o.proRata ? 1 : 0, + v.optString(o, "additionalTerms"), + issueDate, + boardApprovalDate, + ts, + ts, + ], + ); + return created({ success: true, message: "SAFE created successfully." }); +} + +export function deleteSafe(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["safe id is required"]); + const r = db.exec(`DELETE FROM safe WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id, + ]); + if (r.changes === 0) return notFound("safe not found"); + return okRes({ success: true }); +} + +// ---- Convertible notes ---- + +export function listConvertibles(ctx: Ctx): Res { + const rows = db.query( + `SELECT cn.id, cn.public_id AS publicId, cn.type, cn.status, cn.capital, + cn.conversion_cap AS conversionCap, cn.discount_rate AS discountRate, + cn.interest_rate AS interestRate, cn.issue_date AS issueDate, + st.name AS stakeholderName, st.id AS stakeholderId + FROM convertible_note cn + JOIN stakeholder st ON st.id = cn.stakeholder_id + WHERE cn.company_id = ? + ORDER BY cn.created_at DESC`, + [ctx.companyId], + ); + return okRes({ data: rows }); +} + +export function createConvertible(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const publicId = v.reqString(o, "publicId", errs); + const stakeholderId = v.reqString(o, "stakeholderId", errs); + const capital = v.num(o, "capital", errs, 0); + const type = v.oneOf(o, "type", v.ConvertibleType, errs); + const status = v.oneOf(o, "status", v.ConvertibleStatus, errs); + const issueDate = v.dateString(o, "issueDate", errs); + const boardApprovalDate = v.dateString(o, "boardApprovalDate", errs); + if (errs.length) return badReq(errs); + + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if ( + db.query(`SELECT 1 FROM convertible_note WHERE company_id = ? AND public_id = ?`, [ + ctx.companyId, + publicId, + ]).length > 0 + ) { + return conflict("Please use a unique convertible note id."); + } + const ts = now(); + db.exec( + `INSERT INTO convertible_note + (id, company_id, stakeholder_id, public_id, type, status, capital, + conversion_cap, discount_rate, mfn, interest_rate, additional_terms, + issue_date, board_approval_date, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + stakeholderId, + publicId, + type, + status, + capital, + v.optNum(o, "conversionCap"), + v.optNum(o, "discountRate"), + o.mfn ? 1 : 0, + v.optNum(o, "interestRate"), + v.optString(o, "additionalTerms"), + issueDate, + boardApprovalDate, + ts, + ts, + ], + ); + return created({ success: true, message: "Convertible note created successfully." }); +} + +export function deleteConvertible(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["convertible note id is required"]); + const r = db.exec(`DELETE FROM convertible_note WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id, + ]); + if (r.changes === 0) return notFound("convertible note not found"); + return okRes({ success: true }); +} diff --git a/goja/src/routes/equity.ts b/goja/src/routes/equity.ts new file mode 100644 index 00000000..b8e14503 --- /dev/null +++ b/goja/src/routes/equity.ts @@ -0,0 +1,194 @@ +// Share classes (src/trpc/routers/share-class) + equity plans +// (src/trpc/routers/equity-plan). Both are prerequisites for issuing securities: +// shares reference a share class; options reference an equity plan. + +import { db, newId, now } from "../host"; +import { + asObj, + badReq, + type Ctx, + created, + notFound, + okRes, + refExists, + type Res, +} from "./common"; +import * as v from "../validate"; + +// ---- Share classes ---- + +export function listShareClasses(ctx: Ctx): Res { + const rows = db.query( + `SELECT sc.id, sc.idx, sc.name, sc.class_type AS classType, sc.prefix, + sc.initial_shares_authorized AS initialSharesAuthorized, + sc.votes_per_share AS votesPerShare, sc.par_value AS parValue, + sc.price_per_share AS pricePerShare, sc.seniority, + sc.conversion_rights AS conversionRights, + sc.liquidation_preference_multiple AS liquidationPreferenceMultiple, + sc.participation_cap_multiple AS participationCapMultiple, + c.name AS companyName + FROM share_class sc + JOIN company c ON c.id = sc.company_id + WHERE sc.company_id = ? + ORDER BY sc.idx ASC`, + [ctx.companyId], + ); + return okRes(rows); +} + +function validateShareClass(o: Record, errs: string[]) { + const classType = v.oneOf(o, "classType", v.ShareClassType, errs); + return { + name: v.reqString(o, "name", errs), + classType, + prefix: classType === "COMMON" ? "CS" : "PS", + initialSharesAuthorized: v.intNum(o, "initialSharesAuthorized", errs, 1), + boardApprovalDate: v.dateString(o, "boardApprovalDate", errs), + stockholderApprovalDate: v.dateString(o, "stockholderApprovalDate", errs), + votesPerShare: v.intNum(o, "votesPerShare", errs, 0), + parValue: v.num(o, "parValue", errs, 0), + pricePerShare: v.num(o, "pricePerShare", errs, 0), + seniority: v.intNum(o, "seniority", errs, 0), + conversionRights: v.oneOf(o, "conversionRights", v.ConversionRights, errs), + convertsToShareClassId: v.optString(o, "convertsToShareClassId"), + liquidationPreferenceMultiple: v.num(o, "liquidationPreferenceMultiple", errs, 0), + participationCapMultiple: v.num(o, "participationCapMultiple", errs, 0), + }; +} + +export function createShareClass(ctx: Ctx): Res { + const errs: string[] = []; + const s = validateShareClass(asObj(ctx.body), errs); + if (errs.length) return badReq(errs); + + // idx auto-increments per company (tRPC: count(where companyId) + 1). + const countRows = db.query( + `SELECT COUNT(*) AS n FROM share_class WHERE company_id = ?`, + [ctx.companyId], + ); + const idx = Number((countRows[0] as { n: number }).n) + 1; + const ts = now(); + db.exec( + `INSERT INTO share_class + (id, company_id, idx, name, class_type, prefix, + initial_shares_authorized, board_approval_date, stockholder_approval_date, + votes_per_share, par_value, price_per_share, seniority, conversion_rights, + converts_to_share_class_id, liquidation_preference_multiple, + participation_cap_multiple, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + idx, + s.name, + s.classType, + s.prefix, + s.initialSharesAuthorized, + s.boardApprovalDate, + s.stockholderApprovalDate, + s.votesPerShare, + s.parValue, + s.pricePerShare, + s.seniority, + s.conversionRights, + s.convertsToShareClassId, + s.liquidationPreferenceMultiple, + s.participationCapMultiple, + ts, + ts, + ], + ); + return created({ success: true, message: "Share class created successfully." }); +} + +export function updateShareClass(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["share class id is required"]); + const errs: string[] = []; + const s = validateShareClass(asObj(ctx.body), errs); + if (errs.length) return badReq(errs); + const r = db.exec( + `UPDATE share_class + SET name = ?, class_type = ?, prefix = ?, initial_shares_authorized = ?, + board_approval_date = ?, stockholder_approval_date = ?, + votes_per_share = ?, par_value = ?, price_per_share = ?, seniority = ?, + conversion_rights = ?, converts_to_share_class_id = ?, + liquidation_preference_multiple = ?, participation_cap_multiple = ?, + updated_at = ? + WHERE company_id = ? AND id = ?`, + [ + s.name, + s.classType, + s.prefix, + s.initialSharesAuthorized, + s.boardApprovalDate, + s.stockholderApprovalDate, + s.votesPerShare, + s.parValue, + s.pricePerShare, + s.seniority, + s.conversionRights, + s.convertsToShareClassId, + s.liquidationPreferenceMultiple, + s.participationCapMultiple, + now(), + ctx.companyId, + id, + ], + ); + if (r.changes === 0) return notFound("share class not found"); + return okRes({ success: true, message: "Share class updated successfully." }); +} + +// ---- Equity plans (src/trpc/routers/equity-plan) ---- + +export function listEquityPlans(ctx: Ctx): Res { + const rows = db.query( + `SELECT id, name, board_approval_date AS boardApprovalDate, + plan_effective_date AS planEffectiveDate, + initial_shares_reserved AS initialSharesReserved, + default_cancellation_behavior AS defaultCancellatonBehavior, + share_class_id AS shareClassId, comments, created_at AS createdAt + FROM equity_plan WHERE company_id = ? + ORDER BY created_at DESC`, + [ctx.companyId], + ); + return okRes({ data: rows }); +} + +export function createEquityPlan(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const name = v.reqString(o, "name", errs); + const boardApprovalDate = v.dateString(o, "boardApprovalDate", errs); + const initialSharesReserved = v.intNum(o, "initialSharesReserved", errs, 0); + const shareClassId = v.reqString(o, "shareClassId", errs); + const behavior = v.oneOf(o, "defaultCancellatonBehavior", v.CancellationBehavior, errs); + if (errs.length) return badReq(errs); + + if (!refExists(ctx.companyId, "share_class", shareClassId)) { + return badReq(["shareClassId does not reference a share class in this company"]); + } + const ts = now(); + db.exec( + `INSERT INTO equity_plan + (id, company_id, name, board_approval_date, plan_effective_date, + initial_shares_reserved, default_cancellation_behavior, share_class_id, + comments, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + name, + boardApprovalDate, + v.optDateString(o, "planEffectiveDate"), + initialSharesReserved, + behavior, + shareClassId, + v.optString(o, "comments"), + ts, + ts, + ], + ); + return created({ success: true, message: "Equity plan created successfully." }); +} diff --git a/goja/src/routes/rounds.ts b/goja/src/routes/rounds.ts new file mode 100644 index 00000000..2b7d5d35 --- /dev/null +++ b/goja/src/routes/rounds.ts @@ -0,0 +1,220 @@ +// Financing rounds + investments. A round groups a fundraising event; a PRICED +// round issues shares to each investor at its price-per-share (so the cap table +// dilutes), while SAFE/CONVERTIBLE rounds only record capital (the instruments +// live in convertibles.ts and convert later). Adding an investment updates the +// round's raised total. Every write is inside the host's per-request transaction, +// so "record investment + issue shares + bump raised" is atomic. + +import { db, newId, now } from "../host"; +import { + asObj, + badReq, + type Ctx, + created, + notFound, + okRes, + refExists, + type Res, +} from "./common"; +import * as v from "../validate"; +import { insertShare } from "./securities"; + +export function listRounds(ctx: Ctx): Res { + const rows = db.query( + `SELECT id, name, round_type AS roundType, status, + target_amount AS targetAmount, raised_amount AS raisedAmount, + pre_money_valuation AS preMoneyValuation, + price_per_share AS pricePerShare, share_class_id AS shareClassId, + close_date AS closeDate, created_at AS createdAt + FROM round WHERE company_id = ? + ORDER BY created_at DESC`, + [ctx.companyId], + ); + return okRes({ data: rows }); +} + +export function createRound(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const name = v.reqString(o, "name", errs); + const roundType = v.oneOf(o, "roundType", v.RoundType, errs); + const targetAmount = v.num(o, "targetAmount", errs, 0); + if (errs.length) return badReq(errs); + + let shareClassId: string | null = null; + let pricePerShare: number | null = null; + let preMoney: number | null = null; + if (roundType === "PRICED") { + const perrs: string[] = []; + shareClassId = v.reqString(o, "shareClassId", perrs); + pricePerShare = v.num(o, "pricePerShare", perrs, 0); + preMoney = v.optNum(o, "preMoneyValuation"); + if (pricePerShare <= 0) perrs.push("pricePerShare must be > 0 for a priced round"); + if (perrs.length) return badReq(perrs); + if (!refExists(ctx.companyId, "share_class", shareClassId)) { + return badReq(["shareClassId does not reference a share class in this company"]); + } + } + + const ts = now(); + const id = newId(); + db.exec( + `INSERT INTO round + (id, company_id, name, round_type, status, target_amount, raised_amount, + pre_money_valuation, price_per_share, share_class_id, close_date, + created_at, updated_at) + VALUES (?,?,?,?, 'OPEN', ?, 0, ?, ?, ?, NULL, ?, ?)`, + [id, ctx.companyId, name, roundType, targetAmount, preMoney, pricePerShare, shareClassId, ts, ts], + ); + return created({ success: true, message: "Round created.", id }); +} + +export function getRound(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["round id is required"]); + const rows = db.query( + `SELECT id, name, round_type AS roundType, status, + target_amount AS targetAmount, raised_amount AS raisedAmount, + pre_money_valuation AS preMoneyValuation, + price_per_share AS pricePerShare, share_class_id AS shareClassId, + close_date AS closeDate, created_at AS createdAt + FROM round WHERE company_id = ? AND id = ?`, + [ctx.companyId, id], + ); + if (rows.length === 0) return notFound("round not found"); + const investments = db.query( + `SELECT i.id, i.amount, i.shares, i.date, i.comments, + st.name AS stakeholderName, st.id AS stakeholderId + FROM investment i + JOIN stakeholder st ON st.id = i.stakeholder_id + WHERE i.company_id = ? AND i.round_id = ? + ORDER BY i.created_at ASC`, + [ctx.companyId, id], + ); + for (const r of investments) r.shares = Number(r.shares); + return okRes({ round: rows[0], investments }); +} + +export function closeRound(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["round id is required"]); + const o = asObj(ctx.body); + const closeDate = v.optDateString(o, "closeDate") || new Date(now()).toISOString().slice(0, 10); + const r = db.exec( + `UPDATE round SET status = 'CLOSED', close_date = ?, updated_at = ? + WHERE company_id = ? AND id = ? AND status = 'OPEN'`, + [closeDate, now(), ctx.companyId, id], + ); + if (r.changes === 0) return notFound("open round not found"); + return okRes({ success: true, message: "Round closed." }); +} + +export function listInvestments(ctx: Ctx): Res { + const rows = db.query( + `SELECT i.id, i.round_id AS roundId, i.amount, i.shares, i.date, + i.share_class_id AS shareClassId, st.name AS stakeholderName, + st.id AS stakeholderId + FROM investment i + JOIN stakeholder st ON st.id = i.stakeholder_id + WHERE i.company_id = ? + ORDER BY i.created_at DESC`, + [ctx.companyId], + ); + for (const r of rows) r.shares = Number(r.shares); + return okRes({ data: rows }); +} + +// POST /rounds/:id/investments — record an investor's cheque into a round. For a +// PRICED round this ALSO issues shares (quantity = floor(amount / pricePerShare)) +// in the round's share class, so the investment shows up as real equity in the +// cap table. Atomic (per-request transaction). +export function addInvestment(ctx: Ctx): Res { + const roundId = ctx.params.id; + if (!roundId) return badReq(["round id is required"]); + const o = asObj(ctx.body); + const errs: string[] = []; + const stakeholderId = v.reqString(o, "stakeholderId", errs); + const amount = v.num(o, "amount", errs, 0); + if (errs.length) return badReq(errs); + + const roundRows = db.query( + `SELECT id, round_type AS roundType, status, price_per_share AS pricePerShare, + share_class_id AS shareClassId + FROM round WHERE company_id = ? AND id = ?`, + [ctx.companyId, roundId], + ); + if (roundRows.length === 0) return notFound("round not found"); + const round = roundRows[0] as { + roundType: string; + status: string; + pricePerShare: number | null; + shareClassId: string | null; + }; + if (round.status !== "OPEN") return badReq(["round is not open for investment"]); + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + + const date = v.optDateString(o, "date") || new Date(now()).toISOString().slice(0, 10); + let shares = 0; + let newShareId: string | null = null; + + if (round.roundType === "PRICED") { + const pps = Number(round.pricePerShare); + shares = Math.floor(amount / pps); + if (shares <= 0) return badReq(["amount is too small to buy a whole share at this round price"]); + const cert = `INV-${roundId.slice(1, 9)}-${newId().slice(1, 7)}`; + newShareId = insertShare(ctx.companyId, { + stakeholderId, + shareClassId: String(round.shareClassId), + status: "ACTIVE", + certificateId: cert, + quantity: shares, + pricePerShare: pps, + capitalContribution: amount, + ipContribution: null, + debtCancelled: null, + otherContributions: null, + cliffYears: 0, + vestingYears: 0, + companyLegends: [], + issueDate: date, + rule144Date: null, + vestingStartDate: null, + boardApprovalDate: date, + }); + } + + const ts = now(); + const invId = newId(); + db.exec( + `INSERT INTO investment + (id, company_id, round_id, stakeholder_id, share_class_id, amount, shares, + date, comments, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`, + [ + invId, + ctx.companyId, + roundId, + stakeholderId, + round.shareClassId, + amount, + shares, + date, + v.optString(o, "comments"), + ts, + ts, + ], + ); + db.exec( + `UPDATE round SET raised_amount = raised_amount + ?, updated_at = ? WHERE company_id = ? AND id = ?`, + [amount, ts, ctx.companyId, roundId], + ); + return created({ + success: true, + message: "Investment recorded.", + id: invId, + sharesIssued: shares, + newShareId, + }); +} diff --git a/goja/src/routes/securities.ts b/goja/src/routes/securities.ts new file mode 100644 index 00000000..2b6d68d5 --- /dev/null +++ b/goja/src/routes/securities.ts @@ -0,0 +1,364 @@ +// Securities — shares and options (src/trpc/routers/securities-router) plus a +// real share TRANSFER flow. Every write is inside the host's per-request +// transaction, so multi-statement flows (a partial transfer: shrink source + +// insert target) are atomic. + +import { db, newId, now } from "../host"; +import { + asObj, + badReq, + conflict, + type Ctx, + created, + notFound, + okRes, + refExists, + type Res, +} from "./common"; +import * as v from "../validate"; + +// ---- Shares ---- + +export function listShares(ctx: Ctx): Res { + const rows = db.query( + `SELECT sh.id, sh.certificate_id AS certificateId, sh.quantity, + sh.price_per_share AS pricePerShare, + sh.capital_contribution AS capitalContribution, + sh.status, sh.issue_date AS issueDate, + sh.company_legends AS companyLegends, + st.name AS stakeholderName, st.id AS stakeholderId, + sc.class_type AS shareClassType, sc.id AS shareClassId, sc.name AS shareClassName + FROM share sh + JOIN stakeholder st ON st.id = sh.stakeholder_id + JOIN share_class sc ON sc.id = sh.share_class_id + WHERE sh.company_id = ? + ORDER BY sh.created_at DESC`, + [ctx.companyId], + ); + for (const r of rows) { + try { + r.companyLegends = JSON.parse(String(r.companyLegends || "[]")); + } catch (_e) { + r.companyLegends = []; + } + } + return okRes({ data: rows }); +} + +// insertShare is shared with the rounds flow (a priced-round investment issues +// shares). Returns the new share id. +export function insertShare( + companyId: string, + s: { + stakeholderId: string; + shareClassId: string; + status: 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[]; + issueDate: string; + rule144Date: string | null; + vestingStartDate: string | null; + boardApprovalDate: string; + }, +): string { + const id = newId(); + const ts = now(); + db.exec( + `INSERT INTO share + (id, company_id, stakeholder_id, share_class_id, status, certificate_id, + quantity, price_per_share, capital_contribution, ip_contribution, + debt_cancelled, other_contributions, cliff_years, vesting_years, + company_legends, issue_date, rule144_date, vesting_start_date, + board_approval_date, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + id, + companyId, + s.stakeholderId, + s.shareClassId, + s.status, + s.certificateId, + s.quantity, + s.pricePerShare, + s.capitalContribution, + s.ipContribution, + s.debtCancelled, + s.otherContributions, + s.cliffYears, + s.vestingYears, + JSON.stringify(s.companyLegends), + s.issueDate, + s.rule144Date, + s.vestingStartDate, + s.boardApprovalDate, + ts, + ts, + ], + ); + return id; +} + +export function addShare(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const stakeholderId = v.reqString(o, "stakeholderId", errs); + const shareClassId = v.reqString(o, "shareClassId", errs); + const certificateId = v.reqString(o, "certificateId", errs); + const quantity = v.intNum(o, "quantity", errs, 0); + const status = v.oneOf(o, "status", v.SecuritiesStatus, errs); + const rest = { + pricePerShare: v.optNum(o, "pricePerShare"), + capitalContribution: v.optNum(o, "capitalContribution"), + ipContribution: v.optNum(o, "ipContribution"), + debtCancelled: v.optNum(o, "debtCancelled"), + otherContributions: v.optNum(o, "otherContributions"), + cliffYears: v.intNum(o, "cliffYears", errs, 0), + vestingYears: v.intNum(o, "vestingYears", errs, 0), + companyLegends: v.legends(o, "companyLegends", errs), + issueDate: v.dateString(o, "issueDate", errs), + rule144Date: v.optDateString(o, "rule144Date"), + vestingStartDate: v.optDateString(o, "vestingStartDate"), + boardApprovalDate: v.dateString(o, "boardApprovalDate", errs), + }; + if (errs.length) return badReq(errs); + + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if (!refExists(ctx.companyId, "share_class", shareClassId)) { + return badReq(["shareClassId does not reference a share class in this company"]); + } + if ( + db.query(`SELECT 1 FROM share WHERE company_id = ? AND certificate_id = ?`, [ + ctx.companyId, + certificateId, + ]).length > 0 + ) { + return conflict("Please use unique Certificate Id."); + } + + insertShare(ctx.companyId, { + stakeholderId, + shareClassId, + status, + certificateId, + quantity, + ...rest, + }); + return created({ success: true, message: "🎉 Successfully added a share" }); +} + +export function deleteShare(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["share id is required"]); + const r = db.exec(`DELETE FROM share WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id, + ]); + if (r.changes === 0) return notFound("share not found"); + return okRes({ success: true }); +} + +// Transfer part or all of a share certificate to another stakeholder. A FULL +// transfer reassigns the certificate; a PARTIAL transfer shrinks the source and +// issues a new certificate to the recipient (which requires a unique +// certificateId). Both are atomic via the per-request transaction. +export function transferShare(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const shareId = v.reqString(o, "shareId", errs); + const toStakeholderId = v.reqString(o, "toStakeholderId", errs); + if (errs.length) return badReq(errs); + + const rows = db.query( + `SELECT id, stakeholder_id AS stakeholderId, share_class_id AS shareClassId, + quantity, status, certificate_id AS certificateId + FROM share WHERE company_id = ? AND id = ?`, + [ctx.companyId, shareId], + ); + if (rows.length === 0) return notFound("share not found"); + const src = rows[0] as { + quantity: number; + shareClassId: string; + status: string; + certificateId: string; + }; + const held = Number(src.quantity); + + if (!refExists(ctx.companyId, "stakeholder", toStakeholderId)) { + return badReq(["toStakeholderId does not reference a stakeholder in this company"]); + } + + const qtyRaw = o.quantity; + const qty = qtyRaw === undefined || qtyRaw === null ? held : Math.trunc(Number(qtyRaw)); + if (Number.isNaN(qty) || qty <= 0 || qty > held) { + return badReq([`quantity must be between 1 and ${held}`]); + } + + if (qty === held) { + // Full transfer: reassign the certificate. + db.exec( + `UPDATE share SET stakeholder_id = ?, updated_at = ? WHERE company_id = ? AND id = ?`, + [toStakeholderId, now(), ctx.companyId, shareId], + ); + return okRes({ + success: true, + message: "Share transferred", + transferred: qty, + newShareId: null, + }); + } + + // Partial transfer: shrink source, issue a new certificate to the recipient. + const newCert = v.optString(o, "certificateId"); + if (!newCert) { + return badReq(["certificateId is required for a partial transfer"]); + } + if ( + db.query(`SELECT 1 FROM share WHERE company_id = ? AND certificate_id = ?`, [ + ctx.companyId, + newCert, + ]).length > 0 + ) { + return conflict("Please use unique Certificate Id."); + } + db.exec(`UPDATE share SET quantity = ?, updated_at = ? WHERE company_id = ? AND id = ?`, [ + held - qty, + now(), + ctx.companyId, + shareId, + ]); + const newId2 = insertShare(ctx.companyId, { + stakeholderId: toStakeholderId, + shareClassId: src.shareClassId, + status: src.status, + certificateId: newCert, + quantity: qty, + pricePerShare: null, + capitalContribution: null, + ipContribution: null, + debtCancelled: null, + otherContributions: null, + cliffYears: 0, + vestingYears: 0, + companyLegends: [], + issueDate: new Date(now()).toISOString().slice(0, 10), + rule144Date: null, + vestingStartDate: null, + boardApprovalDate: new Date(now()).toISOString().slice(0, 10), + }); + return okRes({ + success: true, + message: "Share partially transferred", + transferred: qty, + newShareId: newId2, + }); +} + +// ---- Options (src/trpc/routers/securities-router option procedures) ---- + +export function listOptions(ctx: Ctx): Res { + const rows = db.query( + `SELECT o.id, o.grant_id AS grantId, o.quantity, + o.exercise_price AS exercisePrice, o.type, o.status, + o.cliff_years AS cliffYears, o.vesting_years AS vestingYears, + o.issue_date AS issueDate, o.expiration_date AS expirationDate, + st.name AS stakeholderName, st.id AS stakeholderId, + ep.name AS equityPlanName, ep.id AS equityPlanId + FROM option o + JOIN stakeholder st ON st.id = o.stakeholder_id + JOIN equity_plan ep ON ep.id = o.equity_plan_id + WHERE o.company_id = ? + ORDER BY o.created_at DESC`, + [ctx.companyId], + ); + return okRes({ data: rows }); +} + +export function addOption(ctx: Ctx): Res { + const o = asObj(ctx.body); + const errs: string[] = []; + const grantId = v.reqString(o, "grantId", errs); + const stakeholderId = v.reqString(o, "stakeholderId", errs); + const equityPlanId = v.reqString(o, "equityPlanId", errs); + const quantity = v.intNum(o, "quantity", errs, 0); + const exercisePrice = v.num(o, "exercisePrice", errs, 0); + const type = v.oneOf(o, "type", v.OptionType, errs); + const status = v.oneOf(o, "status", v.OptionStatus, errs); + const rest = { + cliffYears: v.intNum(o, "cliffYears", errs, 0), + vestingYears: v.intNum(o, "vestingYears", errs, 0), + issueDate: v.dateString(o, "issueDate", errs), + expirationDate: v.dateString(o, "expirationDate", errs), + vestingStartDate: v.dateString(o, "vestingStartDate", errs), + boardApprovalDate: v.dateString(o, "boardApprovalDate", errs), + rule144Date: v.dateString(o, "rule144Date", errs), + notes: v.optString(o, "notes"), + }; + if (errs.length) return badReq(errs); + + if (!refExists(ctx.companyId, "stakeholder", stakeholderId)) { + return badReq(["stakeholderId does not reference a stakeholder in this company"]); + } + if (!refExists(ctx.companyId, "equity_plan", equityPlanId)) { + return badReq(["equityPlanId does not reference an equity plan in this company"]); + } + if ( + db.query(`SELECT 1 FROM option WHERE company_id = ? AND grant_id = ?`, [ + ctx.companyId, + grantId, + ]).length > 0 + ) { + return conflict("Please use unique Grant Id."); + } + const ts = now(); + db.exec( + `INSERT INTO option + (id, company_id, stakeholder_id, equity_plan_id, grant_id, notes, quantity, + exercise_price, type, status, cliff_years, vesting_years, issue_date, + expiration_date, vesting_start_date, board_approval_date, rule144_date, + created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + stakeholderId, + equityPlanId, + grantId, + rest.notes, + quantity, + exercisePrice, + type, + status, + rest.cliffYears, + rest.vestingYears, + rest.issueDate, + rest.expirationDate, + rest.vestingStartDate, + rest.boardApprovalDate, + rest.rule144Date, + ts, + ts, + ], + ); + return created({ success: true, message: "🎉 Successfully added an option" }); +} + +export function deleteOption(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["option id is required"]); + const r = db.exec(`DELETE FROM option WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id, + ]); + if (r.changes === 0) return notFound("option not found"); + return okRes({ success: true }); +} diff --git a/goja/src/routes/stakeholders.ts b/goja/src/routes/stakeholders.ts new file mode 100644 index 00000000..abdb9058 --- /dev/null +++ b/goja/src/routes/stakeholders.ts @@ -0,0 +1,155 @@ +// Stakeholders — port of src/trpc/routers/stakeholder-router. + +import { db, newId, now } from "../host"; +import { asObj, badReq, type Ctx, created, notFound, okRes, type Res } from "./common"; +import * as v from "../validate"; + +export function listStakeholders(ctx: Ctx): Res { + const rows = db.query( + `SELECT s.id, s.name, s.email, s.institution_name AS institutionName, + s.stakeholder_type AS stakeholderType, + s.current_relationship AS currentRelationship, + s.tax_id AS taxId, s.street_address AS streetAddress, s.city, + s.state, s.zipcode, s.country, s.created_at AS createdAt, + c.name AS companyName + FROM stakeholder s + JOIN company c ON c.id = s.company_id + WHERE s.company_id = ? + ORDER BY s.created_at DESC`, + [ctx.companyId], + ); + return okRes(rows); +} + +function validateStakeholder(o: Record, errs: string[]) { + return { + name: v.reqString(o, "name", errs), + email: v.email(o, "email", errs), + institutionName: v.optString(o, "institutionName"), + stakeholderType: v.oneOf(o, "stakeholderType", v.StakeholderType, errs), + currentRelationship: v.oneOf( + o, + "currentRelationship", + v.StakeholderRelationship, + errs, + ), + taxId: v.optString(o, "taxId"), + streetAddress: v.optString(o, "streetAddress"), + city: v.optString(o, "city"), + state: v.optString(o, "state"), + zipcode: v.optString(o, "zipcode"), + }; +} + +// POST /stakeholders — single object OR array (tRPC addStakeholders takes an +// array). Faithful: createMany with skipDuplicates on (company_id, email). +export function addStakeholders(ctx: Ctx): Res { + const raw = ctx.body; + const list = Array.isArray(raw) ? raw : [raw]; + if (list.length === 0) return badReq(["at least one stakeholder is required"]); + + const errs: string[] = []; + const parsed = list.map((item) => + validateStakeholder(asObj(item), errs), + ); + if (errs.length) return badReq(errs); + + const ts = now(); + let inserted = 0; + for (const s of parsed) { + const dup = db.query( + `SELECT 1 FROM stakeholder WHERE company_id = ? AND email = ?`, + [ctx.companyId, s.email], + ); + if (dup.length > 0) continue; // skipDuplicates + db.exec( + `INSERT INTO stakeholder + (id, company_id, name, email, institution_name, stakeholder_type, + current_relationship, tax_id, street_address, city, state, zipcode, + country, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [ + newId(), + ctx.companyId, + s.name, + s.email, + s.institutionName, + s.stakeholderType, + s.currentRelationship, + s.taxId, + s.streetAddress, + s.city, + s.state, + s.zipcode, + "US", + ts, + ts, + ], + ); + inserted += 1; + } + return created({ + success: true, + message: "Stakeholders added successfully!", + inserted, + }); +} + +export function updateStakeholder(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["stakeholder id is required"]); + const o = asObj(ctx.body); + + // Partial update (ZodUpdateStakeholderMutationSchema.partial()). + const sets: string[] = []; + const args: unknown[] = []; + const map: Array<[string, string]> = [ + ["name", "name"], + ["email", "email"], + ["institutionName", "institution_name"], + ["stakeholderType", "stakeholder_type"], + ["currentRelationship", "current_relationship"], + ["taxId", "tax_id"], + ["streetAddress", "street_address"], + ["city", "city"], + ["state", "state"], + ["zipcode", "zipcode"], + ]; + for (const [jsonKey, col] of map) { + if (o[jsonKey] !== undefined) { + sets.push(`${col} = ?`); + args.push(o[jsonKey]); + } + } + if (sets.length === 0) return badReq(["no fields to update"]); + sets.push("updated_at = ?"); + args.push(now(), ctx.companyId, id); + + const r = db.exec( + `UPDATE stakeholder SET ${sets.join(", ")} WHERE company_id = ? AND id = ?`, + args, + ); + if (r.changes === 0) return notFound("stakeholder not found"); + return okRes({ success: true, message: "Successfully updated the stakeholder" }); +} + +export function deleteStakeholder(ctx: Ctx): Res { + const id = ctx.params.id; + if (!id) return badReq(["stakeholder id is required"]); + // Refuse to orphan issued securities (the ON DELETE CASCADE in Prisma would + // silently remove them; a cap table must not lose issued equity by accident). + const held = db.query( + `SELECT (SELECT COUNT(*) FROM share WHERE stakeholder_id = ? AND company_id = ?) + + (SELECT COUNT(*) FROM option WHERE stakeholder_id = ? AND company_id = ?) AS n`, + [id, ctx.companyId, id, ctx.companyId], + ); + if (Number((held[0] as { n: number }).n) > 0) { + return badReq(["cannot delete a stakeholder that still holds shares or options"]); + } + const r = db.exec(`DELETE FROM stakeholder WHERE company_id = ? AND id = ?`, [ + ctx.companyId, + id, + ]); + if (r.changes === 0) return notFound("stakeholder not found"); + return okRes({ success: true }); +} diff --git a/goja/src/validate.ts b/goja/src/validate.ts new file mode 100644 index 00000000..3c3f5b9d --- /dev/null +++ b/goja/src/validate.ts @@ -0,0 +1,175 @@ +// Validation — a zod-free port of the tRPC input schemas +// (src/trpc/routers/*/schema.ts). Same required fields, same enum vocabularies, +// same numeric coercion, expressed without the zod runtime (which would bloat +// the goja bundle). Each validator returns { ok, value?, errors? }; a failed +// validation becomes an HTTP 400, mirroring how the app surfaces a ZodError. + +// Enum vocabularies — verbatim from prisma/schema.prisma. +export const StakeholderType = ["INDIVIDUAL", "INSTITUTION"] as const; +export const StakeholderRelationship = [ + "ADVISOR", + "BOARD_MEMBER", + "CONSULTANT", + "EMPLOYEE", + "EX_ADVISOR", + "EX_CONSULTANT", + "EX_EMPLOYEE", + "EXECUTIVE", + "FOUNDER", + "INVESTOR", + "NON_US_EMPLOYEE", + "OFFICER", + "OTHER", +] as const; +export const ShareClassType = ["COMMON", "PREFERRED"] as const; +export const ConversionRights = [ + "CONVERTS_TO_FUTURE_ROUND", + "CONVERTS_TO_SHARE_CLASS_ID", +] as const; +export const SecuritiesStatus = ["ACTIVE", "DRAFT", "SIGNED", "PENDING"] as const; +export const ShareLegends = [ + "US_SECURITIES_ACT", + "SALE_AND_ROFR", + "TRANSFER_RESTRICTIONS", +] as const; +export const CancellationBehavior = [ + "RETIRE", + "RETURN_TO_POOL", + "HOLD_AS_CAPITAL_STOCK", + "DEFINED_PER_PLAN_SECURITY", +] as const; +export const OptionType = ["ISO", "NSO", "RSU"] as const; +export const OptionStatus = [ + "DRAFT", + "ACTIVE", + "EXERCISED", + "EXPIRED", + "CANCELLED", +] as const; +export const SafeType = ["PRE_MONEY", "POST_MONEY"] as const; +export const SafeStatus = [ + "DRAFT", + "ACTIVE", + "PENDING", + "EXPIRED", + "CANCELLED", +] as const; +export const ConvertibleStatus = [ + "DRAFT", + "ACTIVE", + "PENDING", + "EXPIRED", + "CANCELLED", +] as const; +export const ConvertibleType = ["CCD", "OCD", "NOTE"] as const; +export const RoundType = ["PRICED", "SAFE", "CONVERTIBLE"] as const; +export const RoundStatus = ["OPEN", "CLOSED"] as const; + +export type Result = { ok: true; value: T } | { ok: false; errors: string[] }; + +export function ok(value: T): Result { + return { ok: true, value }; +} +export function fail(errors: string[]): Result { + return { ok: false, errors }; +} + +type Rec = Record; + +// Field helpers accumulate errors into `errs`. +export function reqString(o: Rec, k: string, errs: string[]): string { + const v = o[k]; + if (typeof v !== "string" || v.trim() === "") { + errs.push(`${k} is required`); + return ""; + } + return v; +} + +export function optString(o: Rec, k: string): string | null { + const v = o[k]; + if (v === undefined || v === null || v === "") return null; + return String(v); +} + +export function email(o: Rec, k: string, errs: string[]): string { + const v = reqString(o, k, errs); + if (v && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) { + errs.push(`${k} must be a valid email`); + } + return v; +} + +// coerce number (zod z.coerce.number()): accepts number or numeric string. +export function num(o: Rec, k: string, errs: string[], min?: number): number { + const raw = o[k]; + const n = typeof raw === "number" ? raw : Number(raw); + if (raw === undefined || raw === null || Number.isNaN(n)) { + errs.push(`${k} is required`); + return 0; + } + if (min !== undefined && n < min) { + errs.push(`${k} must be >= ${min}`); + } + return n; +} + +export function optNum(o: Rec, k: string): number | null { + const raw = o[k]; + if (raw === undefined || raw === null || raw === "") return null; + const n = Number(raw); + return Number.isNaN(n) ? null : n; +} + +export function intNum(o: Rec, k: string, errs: string[], min?: number): number { + return Math.trunc(num(o, k, errs, min)); +} + +export function oneOf( + o: Rec, + k: string, + allowed: readonly T[], + errs: string[], +): T { + const v = o[k]; + if (typeof v !== "string" || !allowed.includes(v as T)) { + errs.push(`${k} must be one of ${allowed.join(", ")}`); + return allowed[0]; + } + return v as T; +} + +// date string (zod z.string().date() / z.coerce.date()): kept verbatim as the +// caller's string; the Go host stores dates as TEXT so no parsing is needed here. +export function dateString(o: Rec, k: string, errs: string[]): string { + const v = o[k]; + if (typeof v !== "string" || v.trim() === "") { + errs.push(`${k} is required`); + return ""; + } + return v; +} + +export function optDateString(o: Rec, k: string): string | null { + const v = o[k]; + if (v === undefined || v === null || v === "") return null; + return String(v); +} + +export function legends(o: Rec, k: string, errs: string[]): string[] { + const v = o[k]; + if (v === undefined || v === null) return []; + if (!Array.isArray(v)) { + errs.push(`${k} must be an array`); + return []; + } + const out: string[] = []; + for (const item of v) { + if (typeof item !== "string" || !ShareLegends.includes(item as never)) { + errs.push(`${k} contains an invalid legend`); + continue; + } + out.push(item); + } + return out; +} diff --git a/goja/test/bundle.test.mjs b/goja/test/bundle.test.mjs new file mode 100644 index 00000000..043e29f7 --- /dev/null +++ b/goja/test/bundle.test.mjs @@ -0,0 +1,218 @@ +// Smoke + wiring test for goja/bundle.js. +// +// Runs the built bundle in an isolated node:vm context with a MOCK __db (the +// same contract the Go host implements) and asserts the dispatch surface: +// tenant gate, route table, validation, and that a valid mutation issues the +// expected INSERT through the bridge. Real SQL round-trips are proven by the +// hanzoai/cloud integration (curl against Base); this guards the JS logic. +// +// Run: node goja/test/bundle.test.mjs (or: npm --prefix goja test) + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import vm from "node:vm"; +import assert from "node:assert/strict"; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(here, "..", "bundle.js"), "utf8"); + +// A mock __db that records calls and returns scripted rows. `plan` maps a +// substring of the SQL to the rows/result the mock should return. +function makeDb(plan = {}) { + const calls = []; + const match = (sql) => + Object.keys(plan).find((k) => sql.replace(/\s+/g, " ").includes(k)); + return { + calls, + db: { + query(sql, args) { + calls.push({ kind: "query", sql, args }); + const k = match(sql); + return k ? plan[k] : []; + }, + exec(sql, args) { + calls.push({ kind: "exec", sql, args }); + return { changes: 1, lastId: "x" }; + }, + }, + }; +} + +function runBundle(dbImpl) { + let n = 0; + const ctx = { + __db: dbImpl, + __newId: () => `id_${++n}`, + __now: () => 1_700_000_000_000, + JSON, + Math, + RegExp, + Number, + String, + Array, + Object, + }; + ctx.globalThis = ctx; + vm.createContext(ctx); + vm.runInContext(src, ctx); + return ctx.handle; +} + +let passed = 0; +const it = (name, fn) => { + fn(); + passed++; + console.log(`ok - ${name}`); +}; + +// 1. Missing tenant → 401. +it("missing orgId → 401", () => { + const handle = runBundle(makeDb().db); + const res = handle({ route: "stakeholders.list", orgId: "" }); + assert.equal(res.status, 401); +}); + +// 2. Unknown route → 404 (the bundle's OWN 404, not a proxy). +it("unknown route → 404", () => { + const handle = runBundle(makeDb().db); + const res = handle({ route: "does.not.exist", orgId: "acme" }); + assert.equal(res.status, 404); + assert.match(res.body.message, /unknown captable route/); +}); + +// 3. Validation runs before any DB write → 400 with field errors. +it("addStakeholders invalid body → 400", () => { + const { db, calls } = makeDb(); + const handle = runBundle(db); + const res = handle({ + route: "stakeholders.add", + orgId: "acme", + body: { name: "", email: "not-an-email", stakeholderType: "NOPE" }, + }); + assert.equal(res.status, 400); + assert.ok(res.body.errors.length >= 2, "expected multiple field errors"); + assert.equal( + calls.filter((c) => c.kind === "exec").length, + 0, + "no write on invalid input", + ); +}); + +// 4. Valid stakeholder → 201 and issues one INSERT with the right columns/args. +it("addStakeholders valid → 201 + INSERT", () => { + const { db, calls } = makeDb(); // query() returns [] → no duplicate + const handle = runBundle(db); + const res = handle({ + route: "stakeholders.add", + orgId: "acme", + body: { + name: "Ada Lovelace", + email: "ada@example.com", + stakeholderType: "INDIVIDUAL", + currentRelationship: "FOUNDER", + }, + }); + assert.equal(res.status, 201); + assert.equal(res.body.inserted, 1); + const insert = calls.find( + (c) => c.kind === "exec" && c.sql.includes("INSERT INTO stakeholder"), + ); + assert.ok(insert, "expected an INSERT INTO stakeholder"); + assert.equal(insert.args[1], "acme"); // company_id == tenant + assert.equal(insert.args[3], "ada@example.com"); // email column +}); + +// 5. Share class create auto-increments idx from the per-company count. +it("createShareClass computes idx = count + 1", () => { + const { db, calls } = makeDb({ "COUNT(*) AS n FROM share_class": [{ n: 2 }] }); + const handle = runBundle(db); + const res = handle({ + route: "shareClasses.create", + orgId: "acme", + body: { + name: "Series A Preferred", + classType: "PREFERRED", + initialSharesAuthorized: 1000000, + boardApprovalDate: "2026-01-01", + stockholderApprovalDate: "2026-01-02", + votesPerShare: 1, + parValue: 0.0001, + pricePerShare: 1.25, + seniority: 1, + conversionRights: "CONVERTS_TO_FUTURE_ROUND", + convertsToShareClassId: null, + liquidationPreferenceMultiple: 1, + participationCapMultiple: 0, + }, + }); + assert.equal(res.status, 201); + const insert = calls.find( + (c) => c.kind === "exec" && c.sql.includes("INSERT INTO share_class"), + ); + assert.ok(insert); + assert.equal(insert.args[2], 3, "idx should be count(2) + 1"); // idx column + assert.equal(insert.args[5], "PS", "PREFERRED → prefix PS"); // prefix column +}); + +// 6. capTable returns the computed shape (real SQL round-trips are proven in the +// cloud integration with a live SQLite; here we assert dispatch + shape with a +// mock that yields a company row and zero equity). +it("capTable → 200 with totals + sections", () => { + const { db } = makeDb({ "FROM company WHERE id": [{ id: "acme", name: "Acme" }] }); + const handle = runBundle(db); + const res = handle({ route: "captable", orgId: "acme" }); + assert.equal(res.status, 200); + assert.equal(res.body.company.id, "acme"); + assert.ok("outstandingShares" in res.body.totals); + assert.ok("fullyDilutedShares" in res.body.totals); + assert.ok(res.body.convertibles && res.body.rounds); +}); + +// 7. Full share transfer reassigns the certificate (one UPDATE, no new share). +it("shares.transfer full → 200 + UPDATE stakeholder_id", () => { + const { db, calls } = makeDb({ + // source share lookup, then the recipient existence check. + "FROM share WHERE company_id = ? AND id = ?": [ + { id: "sh1", stakeholderId: "s1", shareClassId: "c1", quantity: 100, status: "ACTIVE", certificateId: "CS-1" }, + ], + "SELECT 1 FROM stakeholder WHERE company_id = ? AND id = ?": [{ 1: 1 }], + }); + const handle = runBundle(db); + const res = handle({ + route: "shares.transfer", + orgId: "acme", + body: { shareId: "sh1", toStakeholderId: "s2" }, // no quantity → full + }); + assert.equal(res.status, 200); + assert.equal(res.body.transferred, 100); + assert.equal(res.body.newShareId, null); + const upd = calls.find( + (c) => c.kind === "exec" && c.sql.includes("UPDATE share SET stakeholder_id"), + ); + assert.ok(upd, "expected an UPDATE reassigning the certificate"); + assert.equal(upd.args[0], "s2"); +}); + +// 8. addOption validation gate (missing refs/fields) → 400 before any write. +it("options.add invalid → 400", () => { + const { db, calls } = makeDb(); + const handle = runBundle(db); + const res = handle({ route: "options.add", orgId: "acme", body: { grantId: "" } }); + assert.equal(res.status, 400); + assert.equal(calls.filter((c) => c.kind === "exec").length, 0); +}); + +// 9. rounds.create PRICED requires a share class + price. +it("rounds.create PRICED without shareClassId → 400", () => { + const { db } = makeDb(); + const handle = runBundle(db); + const res = handle({ + route: "rounds.create", + orgId: "acme", + body: { name: "Seed", roundType: "PRICED", targetAmount: 1000000 }, + }); + assert.equal(res.status, 400); +}); + +console.log(`\n${passed} passed`); diff --git a/goja/tsconfig.json b/goja/tsconfig.json new file mode 100644 index 00000000..0d5b6000 --- /dev/null +++ b/goja/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 9d95da44..4ecff006 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,5 +42,5 @@ "src/components/onboarding/verify-email", "src/app/(unauthenticated)/forgot-password" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "goja"] }