UNPKG

captan

Version:

Captan — Command your ownership. A tiny, hackable CLI cap table tool.

756 lines 31.4 kB
import { randomUUID } from 'node:crypto'; import { getEntityDefaults } from './model.js'; import { StakeholderService } from './services/stakeholder-service.js'; import { SecurityService } from './services/security-service.js'; import { EquityService } from './services/equity-service.js'; import { ReportingService } from './services/reporting-service.js'; import { AuditService } from './services/audit-service.js'; import { SAFEService } from './services/safe-service.js'; import { runInitWizard, parseFounderString, calculatePoolFromPercentage, buildModelFromWizard, } from './init-wizard.js'; import * as store from './store.js'; import { getSchemaString, validateCaptable, validateCaptableExtended, } from './schema.js'; import fs from 'node:fs'; // Handler functions export async function handleInit(options) { if (store.exists('captable.json')) { return { success: false, message: '❌ captable.json already exists', }; } let model; try { if (options.wizard) { const wizardResult = await runInitWizard(); model = buildModelFromWizard(wizardResult); } else { const entityTypeStr = (options.type || 'c-corp').toUpperCase().replace('-', '_'); const entityType = (entityTypeStr === 'C_CORP' || entityTypeStr === 'S_CORP' || entityTypeStr === 'LLC' ? entityTypeStr : 'C_CORP'); const defaults = getEntityDefaults(entityType); const isCorp = entityType === 'C_CORP' || entityType === 'S_CORP'; model = { version: 1, company: { id: `comp_${randomUUID()}`, name: options.name || 'Untitled, Inc.', formationDate: options.date || new Date().toISOString().slice(0, 10), entityType, jurisdiction: options.state || 'DE', currency: options.currency || 'USD', }, stakeholders: [], securityClasses: [], issuances: [], optionGrants: [], safes: [], valuations: [], audit: [], }; // Add common stock/units model.securityClasses.push({ id: 'sc_common', kind: 'COMMON', label: isCorp ? 'Common Stock' : 'Common Units', authorized: Number(options.authorized || defaults.authorized), parValue: isCorp ? Number(options.par ?? defaults.parValue) : undefined, }); // Parse and add founders let totalFounderShares = 0; if (options.founder) { for (const founderStr of options.founder) { const founder = parseFounderString(founderStr); const stakeholder = { id: `sh_${randomUUID()}`, name: founder.name, type: 'person', email: founder.email, }; model.stakeholders.push(stakeholder); if (founder.shares > 0) { model.issuances.push({ id: `is_${randomUUID()}`, securityClassId: 'sc_common', stakeholderId: stakeholder.id, qty: founder.shares, date: options.date || new Date().toISOString().slice(0, 10), }); totalFounderShares += founder.shares; } } } // Add option pool if specified if ((options.pool || options.poolPct) && isCorp) { const poolSize = options.poolPct ? calculatePoolFromPercentage(totalFounderShares, Number(options.poolPct)) : Number(options.pool); if (poolSize > 0) { model.securityClasses.push({ id: 'sc_pool', kind: 'OPTION_POOL', label: 'Option Pool', authorized: poolSize, }); } } } // Add audit log entry const auditService = new AuditService(model); auditService.logAction('INIT', { company: model.company.name }); // Save model store.save(model); // Also create schema file for IDE support (skip in test environment) // Check if we're in a test environment by looking for test-output in cwd const isTestEnv = process.cwd().includes('test-output') || process.env.NODE_ENV === 'test'; if (!isTestEnv) { const schemaString = getSchemaString(); fs.writeFileSync('captable.schema.json', schemaString); return { success: true, message: `✅ Created captable.json and captable.schema.json for "${model.company.name}"`, data: model, }; } return { success: true, message: `✅ Created captable.json for "${model.company.name}"`, data: model, }; } catch (error) { return { success: false, message: `❌ Failed to initialize: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleStakeholder(options) { try { const model = store.load(); const stakeholderService = new StakeholderService(model); const auditService = new AuditService(model); const stakeholder = stakeholderService.addStakeholder(options.name, options.entity ? 'entity' : 'person', options.email); auditService.logAction('STAKEHOLDER_ADDED', { name: stakeholder.name, type: stakeholder.type }); store.save(model); return { success: true, message: `✅ Added stakeholder "${stakeholder.name}" (${stakeholder.id})`, data: stakeholder, }; } catch (error) { return { success: false, message: `❌ Failed to add stakeholder: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleIssue(options) { try { const model = store.load(); const equityService = new EquityService(model); const securityService = new SecurityService(model); const stakeholderService = new StakeholderService(model); const auditService = new AuditService(model); const securityClassId = options.securityClass || securityService.listByKind('COMMON')[0]?.id; if (!securityClassId) { throw new Error('No common stock/units class found'); } const issuance = equityService.issueShares(securityClassId, options.stakeholder, Number(options.qty), options.price ? Number(options.price) : undefined, options.date); const stakeholder = stakeholderService.getStakeholder(options.stakeholder); const securityClass = securityService.getSecurityClass(securityClassId); auditService.logAction('SHARES_ISSUED', { stakeholder: stakeholder?.name || options.stakeholder, qty: issuance.qty, class: securityClass?.label || 'shares', }); store.save(model); return { success: true, message: `✅ Issued ${issuance.qty.toLocaleString()} ${securityClass?.label} to ${stakeholder?.name}`, data: issuance, }; } catch (error) { return { success: false, message: `❌ Failed to issue shares: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleGrant(options) { try { const model = store.load(); const equityService = new EquityService(model); const securityService = new SecurityService(model); const stakeholderService = new StakeholderService(model); const auditService = new AuditService(model); const poolId = options.pool || securityService.listByKind('OPTION_POOL')[0]?.id; if (!poolId) { throw new Error('No option pool found. Create one with "security:add --kind pool"'); } const vesting = options.vestMonths ? { monthsTotal: Number(options.vestMonths), cliffMonths: Number(options.vestCliff || 0), start: options.vestStart || options.date || new Date().toISOString().slice(0, 10), } : undefined; const grant = equityService.grantOptions(options.stakeholder, Number(options.qty), Number(options.exercise), options.date, vesting); const stakeholder = stakeholderService.getStakeholder(options.stakeholder); auditService.logAction('OPTIONS_GRANTED', { stakeholder: stakeholder?.name || options.stakeholder, qty: grant.qty, exercise: grant.exercise, }); store.save(model); return { success: true, message: `✅ Granted ${grant.qty.toLocaleString()} options to ${stakeholder?.name} at $${grant.exercise}/share`, data: grant, }; } catch (error) { return { success: false, message: `❌ Failed to grant options: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleSAFE(options) { try { const model = store.load(); const safeService = new SAFEService(model); const stakeholderService = new StakeholderService(model); const auditService = new AuditService(model); const safe = safeService.addSAFE({ stakeholderId: options.stakeholder, amount: Number(options.amount), cap: options.cap ? Number(options.cap) : undefined, discount: options.discount ? 1 - Number(options.discount) / 100 : undefined, type: options.postMoney ? 'post' : undefined, date: options.date, note: options.note, }); const stakeholder = stakeholderService.getStakeholder(options.stakeholder); auditService.logAction('SAFE_ADDED', { stakeholder: stakeholder?.name || options.stakeholder, amount: safe.amount, cap: safe.cap, discount: safe.discount, }); store.save(model); const terms = []; if (safe.cap) terms.push(`cap: $${safe.cap.toLocaleString()}`); if (safe.discount) terms.push(`discount: ${Math.round((1 - safe.discount) * 100)}%`); return { success: true, message: `✅ Added SAFE for ${stakeholder?.name}: $${safe.amount.toLocaleString()}${terms.length ? ` (${terms.join(', ')})` : ''}`, data: safe, }; } catch (error) { return { success: false, message: `❌ Failed to add SAFE: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleChart(options) { try { const model = store.load(); const reportingService = new ReportingService(model); const capTable = reportingService.generateCapTable(options.date); // Format the cap table as a chart const lines = [ `📊 Cap Table Summary - ${model.company.name}`, `As of: ${options.date || new Date().toISOString().slice(0, 10)}`, '', 'Name'.padEnd(25) + 'Outstanding'.padStart(15) + ' %'.padStart(8), '─'.repeat(48), ]; for (const row of capTable.rows) { lines.push(row.name.padEnd(25) + row.outstanding.toLocaleString().padStart(15) + (row.pctOutstanding * 100).toFixed(2).padStart(7) + '%'); } lines.push('─'.repeat(48)); lines.push('Total'.padEnd(25) + capTable.totals.outstandingTotal.toLocaleString().padStart(15) + ' 100.00%'); return { success: true, message: lines.join('\n'), data: capTable, }; } catch (error) { return { success: false, message: `❌ Failed to generate chart: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleExport(format, options) { try { const model = store.load(); const reportingService = new ReportingService(model); let output; switch (format) { case 'json': output = reportingService.exportJSON(); break; case 'csv': output = reportingService.exportCSV(options.includeOptions !== false); break; case 'summary': output = reportingService.generateSummary(); break; default: throw new Error(`Unknown export format: ${format}`); } return { success: true, message: output, data: output, }; } catch (error) { return { success: false, message: `❌ Failed to export: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleReport(options) { try { const model = store.load(); const reportingService = new ReportingService(model); let output; switch (options.type) { case 'stakeholder': output = reportingService.generateStakeholderReport(options.id); break; case 'class': case 'security': output = reportingService.generateSecurityClassReport(options.id); break; case 'summary': { // Generate a full cap table summary report const capTable = reportingService.generateCapTable(); const lines = [ `📊 Cap Table Summary - ${model.company.name}`, `As of: ${new Date().toISOString().slice(0, 10)}`, '', 'Stakeholder'.padEnd(25) + 'Outstanding'.padStart(15) + ' %'.padStart(8), '─'.repeat(48), ]; for (const row of capTable.rows) { lines.push(row.name.padEnd(25) + row.outstanding.toLocaleString().padStart(15) + (row.pctOutstanding * 100).toFixed(2).padStart(7) + '%'); } lines.push('─'.repeat(48)); lines.push('Total Outstanding'.padEnd(25) + capTable.totals.outstandingTotal.toLocaleString().padStart(15) + ' 100.00%'); lines.push(''); lines.push('Fully Diluted:'.padEnd(25) + capTable.totals.fd.totalFD.toLocaleString().padStart(15)); output = lines.join('\n'); break; } default: throw new Error(`Unknown report type: ${options.type}`); } return { success: true, message: output, data: output, }; } catch (error) { return { success: false, message: `❌ Failed to generate report: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleLog(options) { try { const model = store.load(); const auditService = new AuditService(model); const limit = options.limit ? Number(options.limit) : 20; let logs = auditService.getRecentActions(limit); // Filter by action if specified if (options.action) { logs = logs.filter((log) => log.action === options.action); } const output = ['📋 Audit Log', '']; for (const log of logs) { const timestamp = new Date(log.ts).toLocaleString(); const details = log.data ? JSON.stringify(log.data) : ''; output.push(`[${timestamp}] ${log.action}: ${details}`); } if (logs.length === 0) { output.push(options.action ? `No audit logs found for action: ${options.action}` : 'No audit logs found'); } return { success: true, message: output.join('\n'), data: logs, }; } catch (error) { return { success: false, message: `❌ Failed to retrieve logs: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleList(options) { try { const model = store.load(); const output = []; switch (options.type) { case 'stakeholders': { const stakeholderService = new StakeholderService(model); const stakeholders = stakeholderService.listStakeholders(); output.push('📋 Stakeholders\n'); for (const sh of stakeholders) { output.push(` ${sh.name} (${sh.id}) - ${sh.type}${sh.email ? ` - ${sh.email}` : ''}`); } if (stakeholders.length === 0) { output.push(' No stakeholders found'); } break; } case 'classes': { const securityService = new SecurityService(model); const classes = securityService.listSecurityClasses(); output.push('📋 Security Classes\n'); for (const sc of classes) { output.push(` ${sc.label} (${sc.id}) - ${sc.kind} - Authorized: ${sc.authorized.toLocaleString()}${sc.parValue !== undefined ? ` - Par: $${sc.parValue}` : ''}`); } if (classes.length === 0) { output.push(' No security classes found'); } break; } case 'safes': { const safeService = new SAFEService(model); const stakeholderService = new StakeholderService(model); const safes = safeService.listSAFEs(); output.push('📋 SAFEs\n'); for (const safe of safes) { const stakeholder = stakeholderService.getStakeholder(safe.stakeholderId); const terms = []; if (safe.cap) terms.push(`Cap: $${safe.cap.toLocaleString()}`); if (safe.discount) terms.push(`Discount: ${Math.round((1 - safe.discount) * 100)}%`); output.push(` ${stakeholder?.name || safe.stakeholderId}: $${safe.amount.toLocaleString()}${terms.length ? ` (${terms.join(', ')})` : ''} - ${safe.date}`); } if (safes.length === 0) { output.push(' No SAFEs found'); } break; } default: throw new Error(`Unknown list type: ${options.type}`); } return { success: true, message: output.join('\n'), data: output, }; } catch (error) { return { success: false, message: `❌ Failed to list: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleEnlist() { try { const model = store.load(); const stakeholderService = new StakeholderService(model); const stakeholders = stakeholderService.listStakeholders(); const output = stakeholders.map((sh) => `${sh.id}\t${sh.name}`).join('\n'); return { success: true, message: output || 'No stakeholders found', data: stakeholders, }; } catch (error) { return { success: false, message: `❌ Failed to list stakeholders: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleConvert(options) { try { const model = store.load(); const safeService = new SAFEService(model); const stakeholderService = new StakeholderService(model); // Check if there are any SAFEs to convert if (model.safes.length === 0) { return { success: false, message: '❌ No SAFEs to convert', }; } // Calculate price per share if not provided directly let pricePerShare; if (options.price) { pricePerShare = Number(options.price); } else if (options.preMoney) { // Calculate price from pre-money valuation const preMoneyValuation = Number(options.preMoney); const currentShares = model.issuances.reduce((sum, issuance) => { const securityClass = model.securityClasses.find((sc) => sc.id === issuance.securityClassId); if (securityClass && securityClass.kind !== 'OPTION_POOL') { return sum + issuance.qty; } return sum; }, 0); pricePerShare = currentShares > 0 ? preMoneyValuation / currentShares : 1; } else { throw new Error('Must provide either --price or --pre-money'); } // Simulate conversion to get the results const conversions = safeService.simulateConversion({ preMoneyValuation: options.preMoney ? Number(options.preMoney) : 0, newMoneyRaised: options.newMoney ? Number(options.newMoney) : 0, pricePerShare, }); // Calculate ownership percentages const currentShares = model.issuances.reduce((sum, issuance) => { const securityClass = model.securityClasses.find((sc) => sc.id === issuance.securityClassId); if (securityClass && securityClass.kind !== 'OPTION_POOL') { return sum + issuance.qty; } return sum; }, 0); const totalNewShares = conversions.reduce((sum, c) => sum + c.sharesIssued, 0); const postMoneyShares = currentShares + totalNewShares; const dilution = (totalNewShares / postMoneyShares) * 100; if (options.dryRun) { // Preview mode - don't actually convert const output = ['🔄 SAFE Conversion Preview\n']; for (const conversion of conversions) { const stakeholder = stakeholderService.getStakeholder(conversion.stakeholderId); const safe = model.safes.find((s) => s.stakeholderId === conversion.stakeholderId); const ownershipPct = (conversion.sharesIssued / postMoneyShares) * 100; output.push(`${stakeholder?.name || conversion.stakeholderId}:`); output.push(` Investment: $${safe?.amount.toLocaleString()}`); output.push(` Shares: ${conversion.sharesIssued.toLocaleString()} at $${conversion.conversionPrice.toFixed(2)}/share (${conversion.conversionReason})`); output.push(` New ownership: ${ownershipPct.toFixed(2)}%`); output.push(''); } output.push(`Total new shares: ${totalNewShares.toLocaleString()}`); output.push(`Post-money shares: ${postMoneyShares.toLocaleString()}`); output.push(`Dilution to existing: ${dilution.toFixed(2)}%`); return { success: true, message: output.join('\n'), data: { conversions, totalNewShares, postMoneyShares, dilution }, }; } else { // Actual conversion mode - execute the conversion const securityService = new SecurityService(model); const equityService = new EquityService(model); const commonClasses = securityService.listByKind('COMMON'); if (commonClasses.length === 0) { throw new Error('No common stock class found for SAFE conversion'); } const commonClass = commonClasses[0]; const issuedConversions = []; for (const conversion of conversions) { // Issue shares for the conversion equityService.issueShares(commonClass.id, conversion.stakeholderId, conversion.sharesIssued, conversion.conversionPrice, options.date); issuedConversions.push({ stakeholderId: conversion.stakeholderId, sharesIssued: conversion.sharesIssued, conversionPrice: conversion.conversionPrice, conversionReason: conversion.conversionReason, }); } // Remove converted SAFEs model.safes = []; const output = ['🔄 SAFE Conversions Executed\n']; for (const conversion of issuedConversions) { const stakeholder = stakeholderService.getStakeholder(conversion.stakeholderId); const ownershipPct = (conversion.sharesIssued / postMoneyShares) * 100; output.push(` ${stakeholder?.name || conversion.stakeholderId}: ${conversion.sharesIssued.toLocaleString()} shares at $${conversion.conversionPrice.toFixed(2)}/share (${conversion.conversionReason}) - ${ownershipPct.toFixed(2)}% ownership`); } output.push(`\nTotal dilution: ${dilution.toFixed(2)}%`); store.save(model); return { success: true, message: output.join('\n'), data: issuedConversions, }; } } catch (error) { return { success: false, message: `❌ Failed to convert SAFEs: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleSecurityAdd(kind, label, authorized, parValue) { try { const model = store.load(); const securityService = new SecurityService(model); const auditService = new AuditService(model); const kindMap = { common: 'COMMON', preferred: 'PREF', pool: 'OPTION_POOL', }; const mappedKind = kindMap[kind.toLowerCase()] || kind; const securityClass = securityService.addSecurityClass(mappedKind, label, Number(authorized), parValue ? Number(parValue) : undefined); auditService.logAction('SECURITY_CLASS_ADDED', { label, kind: mappedKind, authorized: Number(authorized), }); store.save(model); return { success: true, message: `✅ Added security class "${label}" (${securityClass.id})`, data: securityClass, }; } catch (error) { return { success: false, message: `❌ Failed to add security class: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleSafes() { try { const model = store.load(); const safeService = new SAFEService(model); const stakeholderService = new StakeholderService(model); const safes = safeService.listSAFEs(); const output = ['📋 SAFEs Outstanding\n']; let totalAmount = 0; for (const safe of safes) { const stakeholder = stakeholderService.getStakeholder(safe.stakeholderId); const terms = []; if (safe.cap) terms.push(`Cap: $${safe.cap.toLocaleString()}`); if (safe.discount) terms.push(`Discount: ${Math.round((1 - safe.discount) * 100)}%`); if (safe.type === 'post') terms.push('Post-money'); output.push(` ${stakeholder?.name || safe.stakeholderId}:`, ` Amount: $${safe.amount.toLocaleString()}`, ` Terms: ${terms.join(', ') || 'None'}`, ` Date: ${safe.date}`, safe.note ? ` Note: ${safe.note}` : '', ''); totalAmount += safe.amount; } if (safes.length > 0) { output.push(`Total: $${totalAmount.toLocaleString()} across ${safes.length} SAFEs`); } else { output.push('No SAFEs outstanding'); } return { success: true, message: output.filter(Boolean).join('\n'), data: safes, }; } catch (error) { return { success: false, message: `❌ Failed to list SAFEs: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleValidate(options) { try { const file = options.file || 'captable.json'; if (!fs.existsSync(file)) { return { success: false, message: `❌ File not found: ${file}`, }; } const content = fs.readFileSync(file, 'utf8'); let data; try { data = JSON.parse(content); } catch (error) { return { success: false, message: `❌ Invalid JSON in ${file}: ${error instanceof Error ? error.message : String(error)}`, }; } const result = options.extended ? validateCaptableExtended(data) : validateCaptable(data); if (result.valid) { let message = `✅ ${file} is valid`; // Add warnings if extended validation was used if (options.extended && 'warnings' in result && result.warnings) { const extendedResult = result; if (extendedResult.warnings) { const warningMessages = extendedResult.warnings .map((w) => ` ⚠️ ${w.path}: ${w.message}`) .join('\n'); if (warningMessages) { message += `\n\nWarnings:\n${warningMessages}`; } } } return { success: true, message, }; } else { const errorMessages = result.errors?.map((e) => ` • ${e}`).join('\n') || ''; return { success: false, message: `❌ Validation failed for ${file}:\n${errorMessages}`, }; } } catch (error) { return { success: false, message: `❌ Failed to validate: ${error instanceof Error ? error.message : String(error)}`, }; } } export function handleSchema(options) { try { const schemaString = getSchemaString(); const outputFile = options.output || 'captable.schema.json'; fs.writeFileSync(outputFile, schemaString); return { success: true, message: `✅ Schema exported to ${outputFile}`, }; } catch (error) { return { success: false, message: `❌ Failed to export schema: ${error instanceof Error ? error.message : String(error)}`, }; } } //# sourceMappingURL=cli-handlers.js.map