12 Commits
Author SHA1 Message Date
hanzo-dev ccc1785292 chore(go): bump go directive to 1.26.5
Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-27 16:09:31 -07:00
hanzo-dev 0c012128f3 ci: drop the Gitea mirror-sync nudge
Superseded: the Hanzo GitHub App pushes a webhook, so the forge tracks GitHub
without a per-repo workflow. This file called git.hanzo.ai/api/v1/.../mirror-sync
— a Gitea API for a system we no longer drive — and would sit inert in every repo.

One mechanism, in one place, instead of ~350 copies of a cron.
2026-07-27 10:26:34 -07:00
hanzo-dev c985e1fb73 ci: nudge git.hanzo.ai to pull on push
git.hanzo.ai mirrors this repo by PULL on a ~10-minute interval, and arcd runs
CI/CD there — so every push waited out that interval before anything built.
This asks Gitea to pull HEAD immediately.

Latency only: the repo already mirrors via the App webhook, so a missing
HANZO_GIT_TOKEN or a failed curl is non-fatal and never fails the push.
Idempotent (mirror-sync just pulls HEAD) and concurrency-coalesced.
2026-07-27 08:10:35 -07:00
Hanzo Dev d49b801acb ci: remove GitHub CI — native git.hanzo.ai + cd.hanzo.ai only
This workflow ran on a self-hosted actions-runner-controller pool. ARC is
decommissioned: CI executes inside git.hanzo.ai (Gitea Actions, .hanzo/workflows)
and delivery is cd.hanzo.ai reconciling the reviewed image pin in
hanzoai/universe. GitHub is a mirror and runs nothing.

Removed rather than repointed — there is no GitHub-side pool to move to, and
leaving it would keep ARC load-bearing.
2026-07-27 07:57:28 -07:00
Hanzo Dev b24b49f4b3 ci: remove GitHub CI — native git.hanzo.ai + cd.hanzo.ai only
This workflow ran on a self-hosted actions-runner-controller pool. ARC is
decommissioned: CI executes inside git.hanzo.ai (Gitea Actions, .hanzo/workflows)
and delivery is cd.hanzo.ai reconciling the reviewed image pin in
hanzoai/universe. GitHub is a mirror and runs nothing.

Removed rather than repointed — there is no GitHub-side pool to move to, and
leaving it would keep ARC load-bearing.
2026-07-27 07:57:26 -07:00
Hanzo Dev d3ca7f9f82 ci: remove GitHub CI — native git.hanzo.ai + cd.hanzo.ai only
This workflow ran on a self-hosted actions-runner-controller pool. ARC is
decommissioned: CI executes inside git.hanzo.ai (Gitea Actions, .hanzo/workflows)
and delivery is cd.hanzo.ai reconciling the reviewed image pin in
hanzoai/universe. GitHub is a mirror and runs nothing.

Removed rather than repointed — there is no GitHub-side pool to move to, and
leaving it would keep ARC load-bearing.
2026-07-27 07:57:24 -07:00
Hanzo Dev b6db4eb7bc ci: remove GitHub CI — native git.hanzo.ai + cd.hanzo.ai only
This workflow ran on a self-hosted actions-runner-controller pool. ARC is
decommissioned: CI executes inside git.hanzo.ai (Gitea Actions, .hanzo/workflows)
and delivery is cd.hanzo.ai reconciling the reviewed image pin in
hanzoai/universe. GitHub is a mirror and runs nothing.

Removed rather than repointed — there is no GitHub-side pool to move to, and
leaving it would keep ARC load-bearing.
2026-07-27 07:57:23 -07:00
Hanzo Dev 282ae8d8de ci: remove GitHub CI — native git.hanzo.ai + cd.hanzo.ai only
This workflow ran on a self-hosted actions-runner-controller pool. ARC is
decommissioned: CI executes inside git.hanzo.ai (Gitea Actions, .hanzo/workflows)
and delivery is cd.hanzo.ai reconciling the reviewed image pin in
hanzoai/universe. GitHub is a mirror and runs nothing.

Removed rather than repointed — there is no GitHub-side pool to move to, and
leaving it would keep ARC load-bearing.
2026-07-27 07:57:21 -07:00
Hanzo Dev b75eaa11c3 ci: remove GitHub CI — native git.hanzo.ai + cd.hanzo.ai only
This workflow ran on a self-hosted actions-runner-controller pool. ARC is
decommissioned: CI executes inside git.hanzo.ai (Gitea Actions, .hanzo/workflows)
and delivery is cd.hanzo.ai reconciling the reviewed image pin in
hanzoai/universe. GitHub is a mirror and runs nothing.

Removed rather than repointed — there is no GitHub-side pool to move to, and
leaving it would keep ARC load-bearing.
2026-07-27 07:57:19 -07:00
hanzo-dev 5ff4d151c2 ci: run linux jobs on hanzo-build-linux-amd64 ARC scale set (no GitHub-hosted builders)
Claude-Session: https://claude.ai/code/session_01RFrWpXc1BsqfrFYMbyDusJ
2026-07-11 00:25:58 -07:00
119bf9c088 fix(captable): exclude terminal-state options from fully-diluted math (RED M5) (#3)
capTable summed ALL option rows into optionShares / per-stakeholder options
regardless of status, so EXERCISED, EXPIRED and CANCELLED grants inflated
fullyDilutedShares and understated every real ownership %. A cap-table surface
must not do this.

Filter both dilution queries (the totals sum and the per-stakeholder subquery)
to OUTSTANDING options only: status NOT IN ('EXERCISED','EXPIRED','CANCELLED').
EXERCISED options already became shares (counted in `share`) — counting the
option too double-counts; EXPIRED/CANCELLED are void. DRAFT + ACTIVE still dilute.

Rebuilt goja/bundle.js. Existing goja bundle tests pass.

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-10 14:39:06 -07:00
3cc625ee44 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>
2026-07-10 02:24:59 -07:00
28 changed files with 3391 additions and 268 deletions
-50
View File
@@ -1,50 +0,0 @@
name: "Build docker image"
on:
pull_request:
branches: ["main"]
types: [opened, synchronize, reopened, ready_for_review]
concurrency:
group: build-${{ github.ref }}
cancel-in-progress: true
jobs:
build_docker:
name: Build Docker Image
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: ⛅ Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🎁 Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: 🚀 Build Docker Image
uses: docker/build-push-action@v5
with:
push: false
context: .
file: ./docker/Dockerfile
tags: captable-${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- # Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: 🚚 Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-29
View File
@@ -1,29 +0,0 @@
name: Cleanup cache
on:
pull_request:
types:
- closed
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup
run: |
gh extension install actions/gh-actions-cache
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH -L 100 | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
do
gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm
done
echo "Done"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge
-36
View File
@@ -1,36 +0,0 @@
name: First interaction greeting
on:
pull_request:
types: ["opened"]
issues:
types: ["opened"]
permissions:
pull-requests: write
issues: write
jobs:
greeting:
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |
Welcome @${{github.actor}} :wave:
Thank you for creating your first Pull Request!
One of our team members will review it and get back to you as soon as possible.
In the meantime feel free to hop into our community in [Discord](https://discord.gg/rCpqnD6G6p)
Thanks again, and welcome to the community! :smiley:
issue-message: |
Welcome @${{github.actor}} :wave:
thank you for opening your first issue! We will get back to you shortly.
Meanwhile, please feel free to hop into our community in [Discord](https://discord.gg/rCpqnD6G6p)
-63
View File
@@ -1,63 +0,0 @@
name: Production
on:
release:
types: [published]
jobs:
push_to_registries:
name: Publish docker image & deploy to production
runs-on: ubuntu-latest
concurrency:
group: release
cancel-in-progress: true
permissions:
packages: write
contents: read
id-token: write
attestations: write
steps:
- name: ✅ Check out the repo
uses: actions/checkout@v4
- name: 🐬 Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: 🗄️ Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 🗃️ Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
captable/captable
ghcr.io/${{ github.repository }}
- name: 🚀 Build and push Docker images
id: push
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: 🪰 Setup flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: latest
- name: 🚀 Deploy Production
run: flyctl deploy --vm-cpu-kind=performance --remote-only --image captable/captable:latest
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
@@ -1,47 +0,0 @@
name: Validate PR title
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
permissions:
pull-requests: write
jobs:
validate-pr:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v5
id: lint_pr_title
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: marocchino/sticky-pull-request-comment@v2
# When the previous steps fails, the workflow would stop. By adding this
# condition you can continue the execution with the populated error message.
if: always() && (steps.lint_pr_title.outputs.error_message != null)
with:
header: pr-title-lint-error
message: |
Hey @${{github.actor}} thank you for opening this pull request! :wave:
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) and it looks like your proposed title needs to be adjusted.
Details:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@v2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 🙏
-42
View File
@@ -1,42 +0,0 @@
name: Fly Staging Deploy
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
on:
push:
branches:
- main
jobs:
deploy:
name: 🚀 Deploy
environment: staging
runs-on: ubuntu-latest
concurrency:
group: staging
cancel-in-progress: true
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
- name: 👀 Read app name
uses: SebRollen/toml-action@v1.2.0
id: app_name
with:
file: "fly.toml"
field: "app"
- name: 🚚 Move Dockerfile to root
run: |
mv ./docker/Dockerfile ./Dockerfile
- name: 🪰 Setup flyctl
uses: superfly/flyctl-actions/setup-flyctl@master
with:
version: latest
- name: 🚀 Deploy Staging
run:
flyctl deploy --vm-cpu-kind=performance --remote-only --build-arg COMMIT_SHA=${{ github.sha }}
--app ${{ steps.app_name.outputs.value }}-staging
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
+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.5
+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");
+1303
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;
}
+109
View File
@@ -0,0 +1,109 @@
// 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 + Σ OUTSTANDING option.quantity
// - 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";
// Only OUTSTANDING options dilute the cap table. Options in a TERMINAL state are
// excluded from the fully-diluted math and every ownership %:
// - EXERCISED → already converted to shares (counted in `share`); counting the
// option too would DOUBLE-COUNT the same equity.
// - EXPIRED / CANCELLED → void; the shares will never be issued.
// DRAFT + ACTIVE remain (granted / pending grants that still dilute). Summing ALL
// option rows regardless of status inflated fullyDilutedShares and understated
// every stakeholder's true ownership — a correctness bug on a cap-table surface.
const DILUTIVE_OPTION_STATUS = `status NOT IN ('EXERCISED','EXPIRED','CANCELLED')`;
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 = ? AND ${DILUTIVE_OPTION_STATUS}`,
[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 AND op.${DILUTIVE_OPTION_STATUS}), 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"]
}