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 <dev@hanzo.ai>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
+1299
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>[];
|
||||
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();
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
orgId?: string;
|
||||
body?: unknown;
|
||||
session?: unknown;
|
||||
}
|
||||
|
||||
type Handler = (ctx: Ctx) => Res;
|
||||
|
||||
const routes: Record<string, Handler> = {
|
||||
"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;
|
||||
}
|
||||
@@ -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<string, unknown>[], 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,
|
||||
});
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
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<string, unknown> {
|
||||
return (body || {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<string, unknown>, 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." });
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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<string, unknown>, 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 });
|
||||
}
|
||||
@@ -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<T> = { ok: true; value: T } | { ok: false; errors: string[] };
|
||||
|
||||
export function ok<T>(value: T): Result<T> {
|
||||
return { ok: true, value };
|
||||
}
|
||||
export function fail<T>(errors: string[]): Result<T> {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
|
||||
// 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<T extends string>(
|
||||
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;
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
+1
-1
@@ -42,5 +42,5 @@
|
||||
"src/components/onboarding/verify-email",
|
||||
"src/app/(unauthenticated)/forgot-password"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "goja"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user