UNPKG

captan

Version:

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

225 lines 8.73 kB
import { zodToJsonSchema } from 'zod-to-json-schema'; import { FileModelSchema } from './model.js'; import { z } from 'zod'; // Schema version tracking export const CURRENT_SCHEMA_VERSION = 1; export const MIN_SUPPORTED_VERSION = 1; export const MAX_SUPPORTED_VERSION = 1; // Version history for documentation export const SCHEMA_VERSIONS = [ { version: 1, releaseDate: '2024-01-01', breaking: false, changes: [ 'Initial schema version', 'Support for stakeholders, securities, issuances, options, and SAFEs', 'Basic validation rules', ], }, ]; export function generateJsonSchema() { const jsonSchema = zodToJsonSchema(FileModelSchema, { name: 'CaptableSchema', $refStrategy: 'none', errorMessages: true, }); const schema = { $schema: 'https://json-schema.org/draft-07/schema#', $id: 'https://github.com/acossta/captan/schemas/captable.schema.json', title: 'Captan Captable Schema', description: 'JSON Schema for Captan captable.json files', ...jsonSchema, }; return schema; } export function getSchemaString() { return JSON.stringify(generateJsonSchema(), null, 2); } export function validateCaptable(data) { try { FileModelSchema.parse(data); return { valid: true }; } catch (error) { if (error instanceof z.ZodError) { const errors = error.errors.map((e) => { const path = e.path.join('.'); return `${path}: ${e.message}`; }); return { valid: false, errors }; } return { valid: false, errors: [error instanceof Error ? error.message : String(error)], }; } } /** * Perform extended validation including business rules and cross-entity checks */ /** * Check if a schema version is supported */ export function isVersionSupported(version) { return version >= MIN_SUPPORTED_VERSION && version <= MAX_SUPPORTED_VERSION; } /** * Get migration instructions for upgrading between versions */ export function getMigrationInstructions(_fromVersion, _toVersion) { const instructions = []; // Add migration instructions as we add new versions // Example for future versions: // if (_fromVersion < 2 && _toVersion >= 2) { // instructions.push('Add new required field X to all entities'); // } return instructions; } export function validateCaptableExtended(data) { // First, perform basic schema validation const basicValidation = validateCaptable(data); if (!basicValidation.valid) { return { valid: false, errors: basicValidation.errors, }; } const model = data; const errors = []; const warnings = []; // Check schema version if (!isVersionSupported(model.version)) { if (model.version < MIN_SUPPORTED_VERSION) { errors.push(`Schema version ${model.version} is too old. Minimum supported version is ${MIN_SUPPORTED_VERSION}. Please migrate your data.`); } else if (model.version > MAX_SUPPORTED_VERSION) { errors.push(`Schema version ${model.version} is newer than supported. Maximum supported version is ${MAX_SUPPORTED_VERSION}. Please update captan.`); } } // Warn if not using current version if (model.version !== CURRENT_SCHEMA_VERSION && model.version >= MIN_SUPPORTED_VERSION) { warnings.push({ path: 'version', message: `Schema version ${model.version} is supported but outdated. Current version is ${CURRENT_SCHEMA_VERSION}.`, severity: 'info', }); } // Build ID maps for reference checking const stakeholderIds = new Set(model.stakeholders.map((s) => s.id)); const securityClassIds = new Set(model.securityClasses.map((sc) => sc.id)); // Check for duplicate IDs const allIds = [ ...model.stakeholders.map((s) => s.id), ...model.securityClasses.map((sc) => sc.id), ...model.issuances.map((i) => i.id), ...model.optionGrants.map((og) => og.id), ...model.safes.map((s) => s.id), ...model.valuations.map((v) => v.id), ]; const idCounts = new Map(); for (const id of allIds) { idCounts.set(id, (idCounts.get(id) || 0) + 1); } for (const [id, count] of idCounts) { if (count > 1) { errors.push(`Duplicate ID found: ${id} appears ${count} times`); } } // Validate issuance references model.issuances.forEach((issuance, idx) => { if (!stakeholderIds.has(issuance.stakeholderId)) { errors.push(`issuances[${idx}]: Invalid stakeholderId reference: ${issuance.stakeholderId}`); } if (!securityClassIds.has(issuance.securityClassId)) { errors.push(`issuances[${idx}]: Invalid securityClassId reference: ${issuance.securityClassId}`); } }); // Validate option grant references model.optionGrants.forEach((grant, idx) => { if (!stakeholderIds.has(grant.stakeholderId)) { errors.push(`optionGrants[${idx}]: Invalid stakeholderId reference: ${grant.stakeholderId}`); } // Validate vesting dates if (grant.vesting) { const vestingStart = new Date(grant.vesting.start); const grantDate = new Date(grant.grantDate); if (vestingStart < grantDate) { warnings.push({ path: `optionGrants[${idx}].vesting.start`, message: 'Vesting start date is before grant date', severity: 'warning', }); } } }); // Validate SAFE references model.safes.forEach((safe, idx) => { if (!stakeholderIds.has(safe.stakeholderId)) { errors.push(`safes[${idx}]: Invalid stakeholderId reference: ${safe.stakeholderId}`); } // Business rule: SAFE should have either cap or discount if (!safe.cap && !safe.discount) { warnings.push({ path: `safes[${idx}]`, message: 'SAFE has neither cap nor discount specified', severity: 'warning', }); } }); // Check total issued shares vs authorized const issuedByClass = new Map(); model.issuances.forEach((issuance) => { const current = issuedByClass.get(issuance.securityClassId) || 0; issuedByClass.set(issuance.securityClassId, current + issuance.qty); }); model.securityClasses.forEach((sc) => { if (sc.kind !== 'OPTION_POOL') { const issued = issuedByClass.get(sc.id) || 0; if (issued > sc.authorized) { errors.push(`Security class ${sc.label}: Issued shares (${issued}) exceed authorized (${sc.authorized})`); } } }); // Check option pool usage const optionPools = model.securityClasses.filter((sc) => sc.kind === 'OPTION_POOL'); const totalGranted = model.optionGrants.reduce((sum, grant) => sum + grant.qty, 0); const totalPoolAuthorized = optionPools.reduce((sum, pool) => sum + pool.authorized, 0); if (totalGranted > totalPoolAuthorized) { errors.push(`Total option grants (${totalGranted}) exceed option pool authorized (${totalPoolAuthorized})`); } // Check for orphaned stakeholders (no equity) const stakeholdersWithEquity = new Set([ ...model.issuances.map((i) => i.stakeholderId), ...model.optionGrants.map((og) => og.stakeholderId), ...model.safes.map((s) => s.stakeholderId), ]); model.stakeholders.forEach((stakeholder) => { if (!stakeholdersWithEquity.has(stakeholder.id)) { warnings.push({ path: `stakeholder.${stakeholder.id}`, message: `Stakeholder "${stakeholder.name}" has no equity`, severity: 'info', }); } }); // Validate dates are chronological if (model.company.formationDate) { const formationDate = new Date(model.company.formationDate); model.issuances.forEach((issuance, idx) => { if (new Date(issuance.date) < formationDate) { warnings.push({ path: `issuances[${idx}].date`, message: 'Issuance date is before company formation date', severity: 'warning', }); } }); } return { valid: errors.length === 0, errors: errors.length > 0 ? errors : undefined, warnings: warnings.length > 0 ? warnings : undefined, }; } //# sourceMappingURL=schema.js.map