feat(goja): self-contained cap-table bundle + Go embed module (epic #96 pilot) (#2)

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:
z
2026-07-10 02:24:59 -07:00
committed by GitHub
co-authored by hanzo-dev
parent ae4b32bd31
commit 3cc625ee44
22 changed files with 3374 additions and 1 deletions
+3
View File
@@ -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",
+41
View File
@@ -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 }
+14
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
node_modules
+74
View File
@@ -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.
+34
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
}
+46
View File
@@ -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();
}
+142
View File
@@ -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;
}
+96
View File
@@ -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,
});
}
+47
View File
@@ -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
);
}
+42
View File
@@ -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" });
}
+181
View File
@@ -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 });
}
+194
View File
@@ -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." });
}
+220
View File
@@ -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,
});
}
+364
View File
@@ -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 });
}
+155
View File
@@ -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 });
}
+175
View File
@@ -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;
}
+218
View File
@@ -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`);
+13
View File
@@ -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
View File
@@ -42,5 +42,5 @@
"src/components/onboarding/verify-email",
"src/app/(unauthenticated)/forgot-password"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "goja"]
}