diff --git a/src/cli.test.ts b/src/cli.test.ts index c54773b..a4c21ba 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest'; import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; @@ -11,6 +11,8 @@ const testFile = path.join(testDir, 'captable.json'); describe('CLI Integration Tests', () => { beforeEach(() => { + // Clean up any existing files first + fs.rmSync(testFile, { force: true }); if (!fs.existsSync(testDir)) { fs.mkdirSync(testDir, { recursive: true }); } @@ -18,31 +20,43 @@ describe('CLI Integration Tests', () => { }); afterEach(() => { - if (fs.existsSync(testFile)) { - fs.unlinkSync(testFile); - } + // Switch back to the repo dir to release testDir handles (prevents EBUSY on Windows) process.chdir(__dirname); }); + afterAll(() => { + // Clean up the test directory after all tests complete + // Be defensive: ensure we are not inside testDir before removing it + try { + process.chdir(__dirname); + } catch {} + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + // Helper to filter out transient noise from command output + function stripNoise(output: string): string { + return output + .split('\n') + .filter((line: string) => !/^(npm WARN|npm notice|npx:)/i.test(line)) + .join('\n') + .trim(); + } + const runCLI = (args: string): string => { try { - const output = execSync(`npx tsx ${cliPath} ${args}`, { + const output = execSync(`node --import tsx "${cliPath}" ${args}`, { encoding: 'utf8', cwd: testDir, stdio: 'pipe', }); - // Filter out npm warnings - return output - .split('\n') - .filter((line) => !line.startsWith('npm warn')) - .join('\n'); + // Filter out npm warnings/notices and npx chatter + return stripNoise(output); } catch (error: any) { const errorOutput = error.stdout || error.stderr || error.message; - // Filter out npm warnings from error output too - return errorOutput - .split('\n') - .filter((line) => !line.startsWith('npm warn')) - .join('\n'); + // Filter out npm warnings/notices and npx chatter from error output too + return stripNoise(errorOutput); } }; @@ -298,4 +312,143 @@ describe('CLI Integration Tests', () => { expect(output).toContain('Cannot issue'); }); }); + + describe('safes command', () => { + beforeEach(() => { + runCLI('init --name "Test Inc" --authorized 10000000 --state DE'); + }); + + it('should list all SAFEs', () => { + runCLI('stakeholder --name "Investor 1"'); + runCLI('stakeholder --name "Investor 2"'); + const model = JSON.parse(fs.readFileSync(testFile, 'utf8')); + const investor1Id = model.stakeholders[0].id; + const investor2Id = model.stakeholders[1].id; + + runCLI(`safe --holder ${investor1Id} --amount 100000 --post-money --cap 5000000`); + runCLI(`safe --holder ${investor2Id} --amount 250000`); + + const output = runCLI('safes'); + + expect(output).toContain('Investor 1'); + expect(output).toContain('100,000'); + expect(output).toContain('Investor 2'); + expect(output).toContain('250,000'); + }); + + it('should handle no SAFEs gracefully', () => { + const output = runCLI('safes'); + expect(output).toContain('No SAFEs'); + }); + }); + + describe('report command', () => { + beforeEach(() => { + runCLI('init --name "Test Inc" --authorized 10000000 --pool 1000000 --state DE'); + }); + + it('should generate stakeholder report', () => { + runCLI('stakeholder --name "Alice" --email alice@test.com'); + const model = JSON.parse(fs.readFileSync(testFile, 'utf8')); + const aliceId = model.stakeholders[0].id; + runCLI(`issue --holder ${aliceId} --qty 1000000`); + + const output = runCLI(`report stakeholder ${aliceId}`); + + expect(output).toContain('Alice'); + expect(output).toContain('alice@test.com'); + expect(output).toContain('1,000,000'); + }); + + it('should generate security class report', () => { + const output = runCLI('report security sc_pool'); + + expect(output).toContain('Option Pool'); + expect(output).toContain('OPTION_POOL'); + }); + }); + + describe('list command', () => { + beforeEach(() => { + runCLI('init --name "Test Inc" --authorized 10000000 --pool 1000000 --state DE'); + }); + + it('should list stakeholders', () => { + runCLI('stakeholder --name "Person 1"'); + runCLI('stakeholder --name "Company 1" --entity'); + + const output = runCLI('list stakeholders'); + + expect(output).toContain('Person 1'); + expect(output).toContain('person'); + expect(output).toContain('Company 1'); + expect(output).toContain('entity'); + }); + + it('should list securities', () => { + const output = runCLI('list securities'); + + expect(output).toContain('Common Stock'); + expect(output).toContain('COMMON'); + expect(output).toContain('Option Pool'); + expect(output).toContain('OPTION_POOL'); + }); + }); + + describe('validate command', () => { + beforeEach(() => { + runCLI('init --name "Test Inc" --authorized 10000000 --state DE'); + }); + + it('should validate a valid cap table', () => { + runCLI('stakeholder --name "Founder"'); + const model = JSON.parse(fs.readFileSync(testFile, 'utf8')); + const founderId = model.stakeholders[0].id; + runCLI(`issue --holder ${founderId} --qty 1000000`); + + const output = runCLI('validate'); + + expect(output).toContain('valid'); + expect(output).not.toContain('error'); + expect(output).not.toContain('warning'); + }); + + it('should validate with extended validation', () => { + const output = runCLI('validate --extended'); + + expect(output).toContain('valid'); + // Extended validation performs additional business rule checks + }); + }); + + describe('schema command', () => { + it('should export schema to default file', () => { + const schemaFile = path.join(testDir, 'captable.schema.json'); + + // Remove schema file if it exists + fs.rmSync(schemaFile, { force: true }); + + const output = runCLI('schema'); + + expect(output).toContain('Schema exported'); + expect(fs.existsSync(schemaFile)).toBe(true); + + const schema = JSON.parse(fs.readFileSync(schemaFile, 'utf8')); + expect(schema).toHaveProperty('$schema'); + expect(schema).toHaveProperty('definitions'); + }); + + it('should export schema to custom file', () => { + const customFile = 'custom-schema.json'; + + // Remove custom file if it exists + fs.rmSync(path.join(testDir, customFile), { force: true }); + + const output = runCLI(`schema --output ${customFile}`); + + expect(output).toContain('Schema exported'); + expect(output).toContain('custom-schema.json'); + expect(fs.existsSync(path.join(testDir, customFile))).toBe(true); + }); + }); }); diff --git a/src/init-wizard-interactive.test.ts b/src/init-wizard-interactive.test.ts new file mode 100644 index 0000000..3213515 --- /dev/null +++ b/src/init-wizard-interactive.test.ts @@ -0,0 +1,397 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { runInitWizard } from './init-wizard.js'; +import * as prompts from '@inquirer/prompts'; + +// Mock all the prompt functions +vi.mock('@inquirer/prompts', () => ({ + input: vi.fn(), + select: vi.fn(), + confirm: vi.fn(), + number: vi.fn(), +})); + +describe('runInitWizard - Interactive Flow', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('C-Corporation flow', () => { + it('should handle C-Corp with pool percentage and founders', async () => { + // Mock the prompt responses in order + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + // Company basics + mockInput.mockResolvedValueOnce('Tech Startup Inc.'); // company name + mockInput.mockResolvedValueOnce('2024-01-15'); // formation date + mockSelect.mockResolvedValueOnce('C_CORP'); // entity type + mockInput.mockResolvedValueOnce('DE'); // jurisdiction + mockInput.mockResolvedValueOnce('USD'); // currency + + // Shares + mockNumber.mockResolvedValueOnce(10000000); // authorized shares + mockNumber.mockResolvedValueOnce(0.00001); // par value + + // Option pool + mockConfirm.mockResolvedValueOnce(true); // create pool + mockSelect.mockResolvedValueOnce('percent'); // pool type + mockNumber.mockResolvedValueOnce(10); // pool percentage + + // Founders + mockConfirm.mockResolvedValueOnce(true); // add founders + mockInput.mockResolvedValueOnce('Alice Smith'); // founder 1 name + mockInput.mockResolvedValueOnce('alice@techstartup.com'); // founder 1 email + mockNumber.mockResolvedValueOnce(4000000); // founder 1 shares + mockConfirm.mockResolvedValueOnce(true); // add another + mockInput.mockResolvedValueOnce('Bob Jones'); // founder 2 name + mockInput.mockResolvedValueOnce(''); // founder 2 email (empty) + mockNumber.mockResolvedValueOnce(5000000); // founder 2 shares + mockConfirm.mockResolvedValueOnce(false); // no more founders + + const result = await runInitWizard(); + + expect(result).toEqual({ + name: 'Tech Startup Inc.', + formationDate: '2024-01-15', + entityType: 'C_CORP', + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue: 0.00001, + poolSize: undefined, + poolPct: 10, + founders: [ + { name: 'Alice Smith', email: 'alice@techstartup.com', shares: 4000000 }, + { name: 'Bob Jones', email: undefined, shares: 5000000 }, + ], + }); + }); + + it('should handle C-Corp with absolute pool size and no founders', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('Simple Corp'); // company name + mockInput.mockResolvedValueOnce('2024-06-01'); // formation date + mockSelect.mockResolvedValueOnce('C_CORP'); // entity type + mockInput.mockResolvedValueOnce('CA'); // jurisdiction + mockInput.mockResolvedValueOnce('USD'); // currency + mockNumber.mockResolvedValueOnce(5000000); // authorized shares + mockNumber.mockResolvedValueOnce(0.001); // par value + mockConfirm.mockResolvedValueOnce(true); // create pool + mockSelect.mockResolvedValueOnce('absolute'); // pool type + mockNumber.mockResolvedValueOnce(500000); // pool size + mockConfirm.mockResolvedValueOnce(false); // no founders + + const result = await runInitWizard(); + + expect(result).toEqual({ + name: 'Simple Corp', + formationDate: '2024-06-01', + entityType: 'C_CORP', + jurisdiction: 'CA', + currency: 'USD', + authorized: 5000000, + parValue: 0.001, + poolSize: 500000, + poolPct: undefined, + founders: [], + }); + }); + + it('should handle C-Corp without pool', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('No Pool Corp'); // company name + mockInput.mockResolvedValueOnce('2023-12-31'); // formation date + mockSelect.mockResolvedValueOnce('C_CORP'); // entity type + mockInput.mockResolvedValueOnce('NY'); // jurisdiction + mockInput.mockResolvedValueOnce('EUR'); // currency + mockNumber.mockResolvedValueOnce(1000000); // authorized shares + mockNumber.mockResolvedValueOnce(0.01); // par value + mockConfirm.mockResolvedValueOnce(false); // no pool + mockConfirm.mockResolvedValueOnce(false); // no founders + + const result = await runInitWizard(); + + expect(result).toEqual({ + name: 'No Pool Corp', + formationDate: '2023-12-31', + entityType: 'C_CORP', + jurisdiction: 'NY', + currency: 'EUR', + authorized: 1000000, + parValue: 0.01, + poolSize: undefined, + poolPct: undefined, + founders: [], + }); + }); + }); + + describe('S-Corporation flow', () => { + it('should handle S-Corp setup', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('Small Business Corp'); // company name + mockInput.mockResolvedValueOnce('2024-03-15'); // formation date + mockSelect.mockResolvedValueOnce('S_CORP'); // entity type + mockInput.mockResolvedValueOnce('TX'); // jurisdiction + mockInput.mockResolvedValueOnce('USD'); // currency + mockNumber.mockResolvedValueOnce(1000000); // authorized shares + mockNumber.mockResolvedValueOnce(0.0001); // par value + mockConfirm.mockResolvedValueOnce(true); // create pool + mockSelect.mockResolvedValueOnce('percent'); // pool type + mockNumber.mockResolvedValueOnce(15); // pool percentage + mockConfirm.mockResolvedValueOnce(true); // add founders + mockInput.mockResolvedValueOnce('John Doe'); // founder name + mockInput.mockResolvedValueOnce('john@scorp.com'); // founder email + mockNumber.mockResolvedValueOnce(850000); // founder shares + mockConfirm.mockResolvedValueOnce(false); // no more founders + + const result = await runInitWizard(); + + expect(result).toEqual({ + name: 'Small Business Corp', + formationDate: '2024-03-15', + entityType: 'S_CORP', + jurisdiction: 'TX', + currency: 'USD', + authorized: 1000000, + parValue: 0.0001, + poolSize: undefined, + poolPct: 15, + founders: [{ name: 'John Doe', email: 'john@scorp.com', shares: 850000 }], + }); + }); + }); + + describe('LLC flow', () => { + it('should handle LLC setup without par value', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('Tech Ventures LLC'); // company name + mockInput.mockResolvedValueOnce('2024-02-01'); // formation date + mockSelect.mockResolvedValueOnce('LLC'); // entity type + mockInput.mockResolvedValueOnce('WY'); // jurisdiction + mockInput.mockResolvedValueOnce('USD'); // currency + mockNumber.mockResolvedValueOnce(1000000); // authorized units + // No par value prompt for LLC + mockConfirm.mockResolvedValueOnce(false); // no pool (typical for LLC) + mockConfirm.mockResolvedValueOnce(true); // add members + mockInput.mockResolvedValueOnce('Member One'); // member name + mockInput.mockResolvedValueOnce('member1@llc.com'); // member email + mockNumber.mockResolvedValueOnce(600000); // member units + mockConfirm.mockResolvedValueOnce(true); // add another + mockInput.mockResolvedValueOnce('Member Two'); // member name + mockInput.mockResolvedValueOnce('member2@llc.com'); // member email + mockNumber.mockResolvedValueOnce(400000); // member units + mockConfirm.mockResolvedValueOnce(false); // no more members + + const result = await runInitWizard(); + + expect(result).toEqual({ + name: 'Tech Ventures LLC', + formationDate: '2024-02-01', + entityType: 'LLC', + jurisdiction: 'WY', + currency: 'USD', + authorized: 1000000, + parValue: undefined, // LLC doesn't have par value + poolSize: undefined, + poolPct: undefined, + founders: [ + { name: 'Member One', email: 'member1@llc.com', shares: 600000 }, + { name: 'Member Two', email: 'member2@llc.com', shares: 400000 }, + ], + }); + }); + + it('should handle LLC with pool (unusual but possible)', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('Innovative LLC'); // company name + mockInput.mockResolvedValueOnce('2024-07-01'); // formation date + mockSelect.mockResolvedValueOnce('LLC'); // entity type + mockInput.mockResolvedValueOnce('NV'); // jurisdiction + mockInput.mockResolvedValueOnce('GBP'); // currency + mockNumber.mockResolvedValueOnce(500000); // authorized units + mockConfirm.mockResolvedValueOnce(true); // create pool (unusual for LLC) + mockSelect.mockResolvedValueOnce('absolute'); // pool type + mockNumber.mockResolvedValueOnce(50000); // pool size + mockConfirm.mockResolvedValueOnce(false); // no founders + + const result = await runInitWizard(); + + expect(result).toEqual({ + name: 'Innovative LLC', + formationDate: '2024-07-01', + entityType: 'LLC', + jurisdiction: 'NV', + currency: 'GBP', + authorized: 500000, + parValue: undefined, + poolSize: 50000, + poolPct: undefined, + founders: [], + }); + }); + }); + + describe('Edge cases and validation', () => { + it('should handle undefined/default values', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + // Return undefined for some optional fields + mockInput.mockResolvedValueOnce('Default Corp'); // company name + mockInput.mockResolvedValueOnce('2024-01-01'); // formation date + mockSelect.mockResolvedValueOnce('C_CORP'); // entity type + mockInput.mockResolvedValueOnce('DE'); // jurisdiction + mockInput.mockResolvedValueOnce('USD'); // currency + mockNumber.mockResolvedValueOnce(undefined); // authorized shares (use default) + mockNumber.mockResolvedValueOnce(undefined); // par value (leave blank -> undefined) + mockConfirm.mockResolvedValueOnce(false); // no pool + mockConfirm.mockResolvedValueOnce(false); // no founders + + const result = await runInitWizard(); + + expect(result.authorized).toBe(10000000); // Should use default + expect(result.parValue).toBeUndefined(); // Should be undefined when not provided + }); + + it('should skip founders with zero or undefined shares', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('Test Corp'); // company name + mockInput.mockResolvedValueOnce('2024-01-01'); // formation date + mockSelect.mockResolvedValueOnce('C_CORP'); // entity type + mockInput.mockResolvedValueOnce('DE'); // jurisdiction + mockInput.mockResolvedValueOnce('USD'); // currency + mockNumber.mockResolvedValueOnce(10000000); // authorized shares + mockNumber.mockResolvedValueOnce(0.00001); // par value + mockConfirm.mockResolvedValueOnce(false); // no pool + mockConfirm.mockResolvedValueOnce(true); // add founders + + // First founder with zero shares (should be skipped) + mockInput.mockResolvedValueOnce('Zero Founder'); // founder name + mockInput.mockResolvedValueOnce('zero@test.com'); // founder email + mockNumber.mockResolvedValueOnce(0); // zero shares + mockConfirm.mockResolvedValueOnce(true); // add another + + // Second founder with undefined shares (should be skipped) + mockInput.mockResolvedValueOnce('Undefined Founder'); // founder name + mockInput.mockResolvedValueOnce('undefined@test.com'); // founder email + mockNumber.mockResolvedValueOnce(undefined); // undefined shares + mockConfirm.mockResolvedValueOnce(true); // add another + + // Third founder with valid shares + mockInput.mockResolvedValueOnce('Valid Founder'); // founder name + mockInput.mockResolvedValueOnce('valid@test.com'); // founder email + mockNumber.mockResolvedValueOnce(1000000); // valid shares + mockConfirm.mockResolvedValueOnce(false); // no more founders + + const result = await runInitWizard(); + + // Should only have the valid founder + expect(result.founders).toHaveLength(1); + expect(result.founders[0]).toEqual({ + name: 'Valid Founder', + email: 'valid@test.com', + shares: 1000000, + }); + }); + + it('should handle pool percentage validation edge cases', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('Edge Case Corp'); // company name + mockInput.mockResolvedValueOnce('2024-01-01'); // formation date + mockSelect.mockResolvedValueOnce('C_CORP'); // entity type + mockInput.mockResolvedValueOnce('DE'); // jurisdiction + mockInput.mockResolvedValueOnce('USD'); // currency + mockNumber.mockResolvedValueOnce(10000000); // authorized shares + mockNumber.mockResolvedValueOnce(0.00001); // par value + mockConfirm.mockResolvedValueOnce(true); // create pool + mockSelect.mockResolvedValueOnce('percent'); // pool type + mockNumber.mockResolvedValueOnce(99); // 99% pool (edge case but valid) + mockConfirm.mockResolvedValueOnce(false); // no founders + + const result = await runInitWizard(); + + expect(result.poolPct).toBe(99); // Should accept 99% + }); + + it('should handle international settings', async () => { + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + mockInput.mockResolvedValueOnce('International Corp'); // company name + mockInput.mockResolvedValueOnce('2024-01-01'); // formation date + mockSelect.mockResolvedValueOnce('C_CORP'); // entity type + mockInput.mockResolvedValueOnce('JP'); // Japan jurisdiction + mockInput.mockResolvedValueOnce('JPY'); // Japanese Yen + mockNumber.mockResolvedValueOnce(1000000); // authorized shares + mockNumber.mockResolvedValueOnce(50); // par value in JPY + mockConfirm.mockResolvedValueOnce(false); // no pool + mockConfirm.mockResolvedValueOnce(false); // no founders + + const result = await runInitWizard(); + + expect(result.jurisdiction).toBe('JP'); + expect(result.currency).toBe('JPY'); + expect(result.parValue).toBe(50); + }); + }); + + describe('Console output', () => { + it('should log the wizard header', async () => { + const consoleSpy = vi.spyOn(console, 'log'); + const mockInput = vi.mocked(prompts.input); + const mockSelect = vi.mocked(prompts.select); + const mockConfirm = vi.mocked(prompts.confirm); + const mockNumber = vi.mocked(prompts.number); + + // Minimal setup + mockInput.mockResolvedValue('Test'); + mockSelect.mockResolvedValue('C_CORP'); + mockConfirm.mockResolvedValue(false); + mockNumber.mockResolvedValue(1000000); + + await runInitWizard(); + + expect(consoleSpy).toHaveBeenCalledWith('\n🧭 Captan Initialization Wizard\n'); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/init-wizard.test.ts b/src/init-wizard.test.ts index e76efdc..9a8ac12 100644 --- a/src/init-wizard.test.ts +++ b/src/init-wizard.test.ts @@ -4,7 +4,7 @@ import { calculatePoolFromPercentage, buildModelFromWizard, } from './init-wizard.js'; -import { EntityType } from './model.js'; +import { EntityType, getEntityDefaults } from './model.js'; describe('init-wizard', () => { describe('parseFounderString', () => { @@ -62,6 +62,26 @@ describe('init-wizard', () => { expect(result.shares).toBe(0); }); + it('handles non-numeric quantity in simple format', () => { + const result = parseFounderString('Alice:abc'); + expect(result.name).toBe('Alice'); + expect(result.shares).toBe(0); + }); + + it('handles non-numeric quantity with email format', () => { + const result = parseFounderString('Bob:bob@example.com:xyz'); + expect(result.name).toBe('Bob'); + expect(result.email).toBe('bob@example.com'); + expect(result.shares).toBe(0); + }); + + it('handles non-numeric quantity with price', () => { + const result = parseFounderString('Charlie:charlie@test.com:abc@0.01'); + expect(result.name).toBe('Charlie'); + expect(result.email).toBe('charlie@test.com'); + expect(result.shares).toBe(0); + }); + it('handles price in email format', () => { const result = parseFounderString('Price Test:test@example.com:1000000@0.001'); expect(result.name).toBe('Price Test'); @@ -104,7 +124,7 @@ describe('init-wizard', () => { it('handles very small percentages', () => { const pool = calculatePoolFromPercentage(10000000, 0.1); - expect(pool).toBe(Math.round((10000000 * 0.001) / 0.999)); + expect(pool).toBe(Math.floor((10000000 * 0.1) / (100 - 0.1))); }); it('handles fractional results', () => { @@ -113,17 +133,54 @@ describe('init-wizard', () => { // pool = 10M * 0.15 / (1 - 0.15) = 1764705.88... expect(pool).toBe(1764705); }); + + describe('edge cases', () => { + it('returns 0 for zero percentage', () => { + expect(calculatePoolFromPercentage(1000000, 0)).toBe(0); + }); + + it('returns 0 for negative percentage', () => { + expect(calculatePoolFromPercentage(1000000, -10)).toBe(0); + }); + + it('returns 0 for 100% pool (prevents division by zero)', () => { + expect(calculatePoolFromPercentage(1000000, 100)).toBe(0); + }); + + it('returns 0 for percentage greater than 100%', () => { + expect(calculatePoolFromPercentage(1000000, 150)).toBe(0); + }); + + it('handles small positive percentages correctly', () => { + expect(calculatePoolFromPercentage(1000000, 0.1)).toBe( + Math.floor((1000000 * 0.1) / (100 - 0.1)) + ); + }); + + it('handles 99% pool correctly', () => { + // 99% pool: 1M * 99 / (100 - 99) = 1M * 99 / 1 = 99M exactly + expect(calculatePoolFromPercentage(1000000, 99)).toBe(99000000); + }); + + it('handles fractional pool percentages correctly', () => { + // 12.5% pool: 1M * 12.5 / (100 - 12.5) = 1M * 12.5 / 87.5 = 142,857 + expect(calculatePoolFromPercentage(1000000, 12.5)).toBe(142857); + // 7.5% pool: 2M * 7.5 / (100 - 7.5) = 2M * 7.5 / 92.5 = 162,162 + expect(calculatePoolFromPercentage(2000000, 7.5)).toBe(162162); + }); + }); }); describe('buildModelFromWizard', () => { it('builds C-Corp model correctly', () => { const result = { name: 'Test Corp', + formationDate: '2024-01-01', entityType: 'C_CORP' as EntityType, jurisdiction: 'DE', currency: 'USD', authorized: 10000000, - parValue: 0.0001, + parValue: 0.00001, poolSize: 2000000, poolPct: undefined, founders: [ @@ -142,7 +199,7 @@ describe('init-wizard', () => { // Check common stock const common = model.securityClasses.find((sc) => sc.kind === 'COMMON'); expect(common?.authorized).toBe(10000000); - expect(common?.parValue).toBe(0.0001); + expect(common?.parValue).toBeCloseTo(0.00001, 10); expect(common?.label).toBe('Common Stock'); // Check pool @@ -164,6 +221,7 @@ describe('init-wizard', () => { it('builds LLC model correctly', () => { const result = { name: 'Test LLC', + formationDate: '2024-01-01', entityType: 'LLC' as EntityType, jurisdiction: 'CA', currency: 'USD', @@ -192,30 +250,32 @@ describe('init-wizard', () => { it('calculates pool from percentage', () => { const result = { name: 'Test Corp', + formationDate: '2024-01-01', entityType: 'C_CORP' as EntityType, jurisdiction: 'DE', currency: 'USD', authorized: 10000000, - parValue: 0.0001, + parValue: 0.00001, poolSize: undefined, - poolPct: 20, + poolPct: 10, founders: [{ name: 'Alice', shares: 8000000 }], }; const model = buildModelFromWizard(result); const pool = model.securityClasses.find((sc) => sc.kind === 'OPTION_POOL'); - expect(pool?.authorized).toBe(2000000); // 20% of total + expect(pool?.authorized).toBe(888888); // 10% of total: 8M * 0.1 / 0.9 = 888,888 }); it('handles no founders', () => { const result = { name: 'Empty Corp', + formationDate: '2024-01-01', entityType: 'C_CORP' as EntityType, jurisdiction: 'DE', currency: 'USD', authorized: 10000000, - parValue: 0.0001, + parValue: 0.00001, poolSize: 1000000, poolPct: undefined, founders: [], @@ -234,6 +294,7 @@ describe('init-wizard', () => { it('builds S-Corp model correctly', () => { const result = { name: 'Test S-Corp', + formationDate: '2024-01-01', entityType: 'S_CORP' as EntityType, jurisdiction: 'NY', currency: 'USD', @@ -259,6 +320,7 @@ describe('init-wizard', () => { it('handles no pool for non-corp entities', () => { const result = { name: 'No Pool LLC', + formationDate: '2024-01-01', entityType: 'LLC' as EntityType, jurisdiction: 'TX', currency: 'USD', @@ -278,11 +340,12 @@ describe('init-wizard', () => { it('handles founders with email and without email mixed', () => { const result = { name: 'Mixed Corp', + formationDate: '2024-01-01', entityType: 'C_CORP' as EntityType, jurisdiction: 'DE', currency: 'EUR', authorized: 10000000, - parValue: 0.0001, + parValue: 0.00001, poolSize: undefined, poolPct: 10, founders: [ @@ -309,13 +372,14 @@ describe('init-wizard', () => { it('handles both poolSize and poolPct with poolSize taking precedence', () => { const result = { name: 'Both Pool Corp', + formationDate: '2024-01-01', entityType: 'C_CORP' as EntityType, jurisdiction: 'DE', currency: 'USD', authorized: 10000000, - parValue: 0.0001, + parValue: 0.00001, poolSize: 3000000, // This should take precedence - poolPct: 20, // This would be 2000000 if calculated + poolPct: 10, // This would be 2000000 if calculated founders: [{ name: 'Dan', shares: 7000000 }], }; @@ -333,7 +397,7 @@ describe('init-wizard', () => { jurisdiction: 'DE', currency: 'USD', authorized: 10000000, - parValue: 0.0001, + parValue: 0.00001, poolSize: undefined, poolPct: undefined, founders: [], @@ -344,4 +408,209 @@ describe('init-wizard', () => { expect(model.company.formationDate).toBe('2024-06-15'); }); }); + + describe('runInitWizard integration', () => { + // Note: These would need proper mocking of inquirer prompts in a real test environment + // For now, we'll test the related functions that are called by the wizard + + it('should correctly process wizard results using entity default values', () => { + const defaults = getEntityDefaults('C_CORP'); + const cCorpResult = { + name: 'Test C-Corp', + formationDate: '2024-01-01', + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: defaults.authorized, + parValue: defaults.parValue, + poolPct: defaults.poolPct, + founders: [], + }; + + const model = buildModelFromWizard(cCorpResult); + const common = model.securityClasses.find((sc) => sc.kind === 'COMMON'); + + expect(common?.parValue).toBeCloseTo(0.00001, 10); + expect(common?.authorized).toBe(10000000); + const pool = model.securityClasses.find((sc) => sc.kind === 'OPTION_POOL'); + expect(pool).toBeUndefined(); + }); + + it('should handle decimal par values correctly', () => { + const result = { + name: 'Decimal Par Corp', + formationDate: '2024-01-01', + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue: 0.00001, // Very small decimal + founders: [{ name: 'Founder', shares: 8000000 }], + }; + + const model = buildModelFromWizard(result); + const common = model.securityClasses.find((sc) => sc.kind === 'COMMON'); + const issuance = model.issuances[0]; + + expect(common?.parValue).toBeCloseTo(0.00001, 10); + expect(issuance?.pps).toBeCloseTo(0.00001, 10); // Price per share should match par value + }); + + it('should handle various decimal par value sizes', () => { + const testCases = [ + { parValue: 0.00001, description: 'five decimal places' }, + { parValue: 0.0001, description: 'four decimal places' }, + { parValue: 0.001, description: 'three decimal places' }, + { parValue: 0.01, description: 'two decimal places' }, + ]; + + testCases.forEach(({ parValue, description }) => { + const result = { + name: `Test Corp ${description}`, + formationDate: '2024-01-01', + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue, + founders: [], + }; + + const model = buildModelFromWizard(result); + const common = model.securityClasses.find((sc) => sc.kind === 'COMMON'); + + expect(common?.parValue).toBeCloseTo(parValue, 10); + }); + }); + + it('should create appropriate pool sizes with new 10% default', () => { + const result = { + name: 'Pool Test Corp', + formationDate: '2024-01-01', + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue: 0.00001, + poolPct: 10, // Using new default + founders: [{ name: 'Founder', shares: 9000000 }], + }; + + const model = buildModelFromWizard(result); + const pool = model.securityClasses.find((sc) => sc.kind === 'OPTION_POOL'); + + // With 9M founder shares and 10% pool: 9M * 0.1 / 0.9 = 1M + expect(pool?.authorized).toBe(1000000); + }); + + it('should handle edge case pool percentages', () => { + const testCases = [ + { poolPct: 5, founderShares: 9500000, expectedPool: 500000 }, // 9.5M * 0.05 / 0.95 = 500K + { poolPct: 15, founderShares: 8500000, expectedPool: 1500000 }, // 8.5M * 0.15 / 0.85 = 1.5M + { poolPct: 25, founderShares: 7500000, expectedPool: 2500000 }, // 7.5M * 0.25 / 0.75 = 2.5M + ]; + + testCases.forEach(({ poolPct, founderShares, expectedPool }) => { + const result = { + name: `Pool ${poolPct}% Corp`, + formationDate: '2024-01-01', + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue: 0.00001, + poolPct, + founders: [{ name: 'Founder', shares: founderShares }], + }; + + const model = buildModelFromWizard(result); + const pool = model.securityClasses.find((sc) => sc.kind === 'OPTION_POOL'); + + expect(pool?.authorized).toBe(expectedPool); + }); + }); + + it('should handle zero par value (though not recommended)', () => { + const result = { + name: 'Zero Par Corp', + formationDate: '2024-01-01', + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue: 0, + founders: [{ name: 'Founder', shares: 10000000 }], + }; + + const model = buildModelFromWizard(result); + const common = model.securityClasses.find((sc) => sc.kind === 'COMMON'); + const issuance = model.issuances[0]; + + expect(common?.parValue).toBe(0); + expect(issuance?.pps).toBe(0); + }); + + it('should generate unique IDs for all entities', () => { + const result = { + name: 'ID Test Corp', + formationDate: '2024-01-01', + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue: 0.00001, + poolSize: 1000000, + founders: [ + { name: 'Founder 1', shares: 4000000 }, + { name: 'Founder 2', shares: 5000000 }, + ], + }; + + const model = buildModelFromWizard(result); + + // Check that all IDs are unique + const allIds = [ + model.company.id, + ...model.stakeholders.map((s) => s.id), + ...model.securityClasses.map((sc) => sc.id), + ...model.issuances.map((i) => i.id), + ]; + + const uniqueIds = new Set(allIds); + expect(uniqueIds.size).toBe(allIds.length); + + // Check ID formats + expect(model.company.id).toMatch(/^comp_[a-f0-9-]+$/); + model.stakeholders.forEach((s) => { + expect(s.id).toMatch(/^sh_[a-f0-9-]+$/); + }); + model.issuances.forEach((i) => { + expect(i.id).toMatch(/^is_[a-f0-9-]+$/); + }); + }); + + it('should properly set issuance dates to formation date', () => { + const formationDate = '2023-05-15'; + const result = { + name: 'Date Test Corp', + formationDate, + entityType: 'C_CORP' as EntityType, + jurisdiction: 'DE', + currency: 'USD', + authorized: 10000000, + parValue: 0.00001, + founders: [ + { name: 'Founder 1', shares: 3000000 }, + { name: 'Founder 2', shares: 7000000 }, + ], + }; + + const model = buildModelFromWizard(result); + + expect(model.company.formationDate).toBe(formationDate); + model.issuances.forEach((issuance) => { + expect(issuance.date).toBe(formationDate); + }); + }); + }); }); diff --git a/src/init-wizard.ts b/src/init-wizard.ts index e878416..af5e346 100644 --- a/src/init-wizard.ts +++ b/src/init-wizard.ts @@ -61,6 +61,10 @@ export async function runInitWizard(): Promise { const authorized = await number({ message: `Authorized ${defaults.unitsName.toLowerCase()}:`, default: defaults.authorized, + validate: (val) => + val !== undefined && Number.isInteger(val) && val > 0 + ? true + : `Authorized ${defaults.unitsName.toLowerCase()} must be a positive integer`, }); let parValue: number | undefined; @@ -68,6 +72,12 @@ export async function runInitWizard(): Promise { parValue = await number({ message: 'Par value per share:', default: defaults.parValue, + step: 'any', + min: 0, + validate: (val) => + val === undefined || (typeof val === 'number' && val >= 0) + ? true + : 'Par value must be a non-negative number', }); } @@ -91,13 +101,28 @@ export async function runInitWizard(): Promise { if (poolType === 'percent') { poolPct = await number({ - message: 'Pool percentage (e.g., 20 for 20%):', + message: `Pool percentage (e.g., ${defaults.poolPct} for ${defaults.poolPct}%):`, default: defaults.poolPct, + step: 'any', + min: 0, + max: 99.999999, + validate: (val) => + val === undefined || (typeof val === 'number' && val >= 0 && val < 100) + ? true + : 'Pool percentage must be between 0 and 100 (exclusive)', }); } else { poolSize = await number({ message: `Number of ${defaults.unitsName.toLowerCase()} for pool:`, - default: Math.floor((authorized || 10000000) * 0.2), + default: (() => { + const baseAuthorized = authorized && authorized > 0 ? authorized : defaults.authorized; + return Math.floor(baseAuthorized * (defaults.poolPct / 100)); + })(), + min: 0, + validate: (val) => + val === undefined || (typeof val === 'number' && val >= 0) + ? true + : 'Pool size must be a non-negative number', }); } } @@ -118,17 +143,20 @@ export async function runInitWizard(): Promise { const founderEmail = await input({ message: 'Founder email (optional):', - default: undefined, }); const founderShares = await number({ message: `Number of ${defaults.unitsName.toLowerCase()}:`, + validate: (val) => + val !== undefined && Number.isInteger(val) && val > 0 + ? true + : `Enter a positive integer number of ${defaults.unitsName.toLowerCase()}`, }); - if (founderShares && founderShares > 0) { + if (founderShares) { founders.push({ name: founderName, - email: founderEmail || undefined, + email: founderEmail?.trim() ? founderEmail.trim() : undefined, shares: founderShares, }); } @@ -166,12 +194,14 @@ export function parseFounderString(founderStr: string): FounderInput { if (parts.length === 2) { // "Name:qty" or "Name:qty@pps" const [name, qtyPart] = parts; - const qty = parseInt(qtyPart.split('@')[0].replace(/,/g, '')); + const parsed = parseInt(qtyPart.split('@')[0].replace(/,/g, ''), 10); + const qty = Number.isNaN(parsed) ? 0 : parsed; return { name: name.trim(), shares: qty }; } else if (parts.length === 3) { // "Name:email:qty" or "Name:email:qty@pps" const [name, email, qtyPart] = parts; - const qty = parseInt(qtyPart.split('@')[0].replace(/,/g, '')); + const parsed = parseInt(qtyPart.split('@')[0].replace(/,/g, ''), 10); + const qty = Number.isNaN(parsed) ? 0 : parsed; return { name: name.trim(), email: email.trim(), @@ -189,8 +219,13 @@ export function calculatePoolFromPercentage(founderShares: number, poolPct: numb // Pool = (P/100) * (F + Pool) // Pool * (1 - P/100) = F * (P/100) // Pool = F * (P/100) / (1 - P/100) - const ratio = poolPct / 100; - return Math.floor((founderShares * ratio) / (1 - ratio)); + if (poolPct <= 0) return 0; + if (poolPct >= 100 - Number.EPSILON) { + // 100% pool is undefined (infinite); require explicit correction upstream + return 0; + } + // Algebraic simplification avoids subtractive cancellation from (1 - P/100) + return Math.floor((founderShares * poolPct) / (100 - poolPct)); } export function buildModelFromWizard(result: WizardResult): FileModel { @@ -241,7 +276,7 @@ export function buildModelFromWizard(result: WizardResult): FileModel { securityClassId: 'sc_common', stakeholderId, qty: founder.shares, - pps: result.parValue || 0, + pps: result.parValue ?? 0, date: model.company.formationDate!, }); totalFounderShares += founder.shares; diff --git a/src/model.test.ts b/src/model.test.ts index 659089f..b3bbd70 100644 --- a/src/model.test.ts +++ b/src/model.test.ts @@ -7,6 +7,7 @@ import { Vesting, convertSAFE, SAFE, + getEntityDefaults, } from './model.js'; describe('monthsBetween', () => { @@ -611,6 +612,51 @@ describe('calcCap', () => { }); }); +describe('getEntityDefaults', () => { + it.each([['C_CORP' as const], ['S_CORP' as const]])( + 'returns correct defaults for %s', + (entityType) => { + const defaults = getEntityDefaults(entityType); + + expect(defaults.authorized).toBe(10000000); + expect(defaults.parValue).toBeCloseTo(0.00001, 10); + expect(defaults.unitsName).toBe('Shares'); + expect(defaults.holderName).toBe('Stockholder'); + expect(defaults.poolPct).toBe(10); + } + ); + + it('returns correct defaults for LLC', () => { + const defaults = getEntityDefaults('LLC'); + + expect(defaults.authorized).toBe(1000000); + expect(defaults.parValue).toBeUndefined(); + expect(defaults.unitsName).toBe('Units'); + expect(defaults.holderName).toBe('Member'); + expect(defaults.poolPct).toBe(0); + }); + + it('C_CORP and S_CORP have identical defaults', () => { + const cCorpDefaults = getEntityDefaults('C_CORP'); + const sCorpDefaults = getEntityDefaults('S_CORP'); + + expect(cCorpDefaults).toEqual(sCorpDefaults); + }); + + it('LLC defaults differ from corporation defaults', () => { + const cCorpDefaults = getEntityDefaults('C_CORP'); + const llcDefaults = getEntityDefaults('LLC'); + + expect(llcDefaults.authorized).toBeLessThan(cCorpDefaults.authorized); + expect(llcDefaults.parValue).toBeUndefined(); + expect(cCorpDefaults.parValue).toBeDefined(); + expect(llcDefaults.unitsName).toBe('Units'); + expect(cCorpDefaults.unitsName).toBe('Shares'); + expect(llcDefaults.poolPct).toBe(0); + expect(cCorpDefaults.poolPct).toBeGreaterThan(0); + }); +}); + describe('convertSAFE', () => { const baseSAFE: SAFE = { id: 'safe_123', diff --git a/src/model.ts b/src/model.ts index be0d6ef..57115b3 100644 --- a/src/model.ts +++ b/src/model.ts @@ -268,10 +268,10 @@ export function getEntityDefaults(entityType: EntityType) { case 'S_CORP': return { authorized: 10000000, - parValue: 0.0001, + parValue: 0.00001, unitsName: 'Shares', holderName: 'Stockholder', - poolPct: 20, + poolPct: 10, }; case 'LLC': return {