• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

acossta / captan / 17088133500

20 Aug 2025 03:59AM UTC coverage: 77.174% (-0.4%) from 77.606%
17088133500

Pull #19

github

web-flow
Merge e3de9188f into 8798ab685
Pull Request #19: Fix decimal par value input and update startup defaults

493 of 562 branches covered (87.72%)

Branch coverage included in aggregate %.

6 of 21 new or added lines in 2 files covered. (28.57%)

11 existing lines in 1 file now uncovered.

2019 of 2693 relevant lines covered (74.97%)

2955.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

47.86
/src/init-wizard.ts
1
import { input, select, confirm, number } from '@inquirer/prompts';
2✔
2
import { EntityType, FileModel, getEntityDefaults } from './model.js';
2✔
3
import { randomUUID } from 'node:crypto';
2✔
4

5
export interface FounderInput {
6
  name: string;
7
  email?: string;
8
  shares: number;
9
}
10

11
export interface WizardResult {
12
  name: string;
13
  formationDate: string;
14
  entityType: EntityType;
15
  jurisdiction: string;
16
  currency: string;
17
  authorized: number;
18
  parValue?: number;
19
  poolSize?: number;
20
  poolPct?: number;
21
  founders: FounderInput[];
22
}
23

24
export async function runInitWizard(): Promise<WizardResult> {
×
25
  console.log('\n🧭 Captan Initialization Wizard\n');
×
26

27
  // Company basics
28
  const name = await input({
×
29
    message: 'Company name:',
×
30
    default: 'Acme, Inc.',
×
31
  });
×
32

33
  const formationDate = await input({
×
34
    message: 'Incorporation date (YYYY-MM-DD):',
×
35
    default: new Date().toISOString().slice(0, 10),
×
36
  });
×
37

38
  const entityType = (await select({
×
39
    message: 'Entity type:',
×
40
    choices: [
×
41
      { value: 'C_CORP', name: 'C-Corporation (standard for VC-backed startups)' },
×
42
      { value: 'S_CORP', name: 'S-Corporation' },
×
43
      { value: 'LLC', name: 'LLC (Limited Liability Company)' },
×
44
    ],
×
45
  })) as EntityType;
×
46

47
  const defaults = getEntityDefaults(entityType);
×
48
  const isCorp = entityType === 'C_CORP' || entityType === 'S_CORP';
×
49

50
  const jurisdiction = await input({
×
51
    message: 'State of incorporation:',
×
52
    default: 'DE',
×
53
  });
×
54

55
  const currency = await input({
×
56
    message: 'Currency:',
×
57
    default: 'USD',
×
58
  });
×
59

60
  // Shares/Units
61
  const authorized = await number({
×
62
    message: `Authorized ${defaults.unitsName.toLowerCase()}:`,
×
63
    default: defaults.authorized,
×
64
  });
×
65

66
  let parValue: number | undefined;
×
67
  if (isCorp) {
×
UNCOV
68
    parValue = await number({
×
UNCOV
69
      message: 'Par value per share:',
×
70
      default: defaults.parValue,
×
NEW
71
      step: 'any',
×
NEW
72
      validate: (val) =>
×
NEW
73
        val === undefined || (typeof val === 'number' && val >= 0)
×
NEW
74
          ? true
×
NEW
75
          : 'Par value must be a non-negative number',
×
76
    });
×
77
  }
×
78

79
  // Option pool
80
  let poolSize: number | undefined;
×
81
  let poolPct: number | undefined;
×
82

UNCOV
83
  const createPool = await confirm({
×
UNCOV
84
    message: 'Create an option pool?',
×
85
    default: isCorp,
×
86
  });
×
87

88
  if (createPool) {
×
89
    const poolType = await select({
×
90
      message: 'How would you like to specify the pool size?',
×
91
      choices: [
×
UNCOV
92
        { value: 'percent', name: 'As percentage of fully diluted equity' },
×
93
        { value: 'absolute', name: `As absolute number of ${defaults.unitsName.toLowerCase()}` },
×
94
      ],
×
95
    });
×
96

97
    if (poolType === 'percent') {
×
98
      poolPct = await number({
×
NEW
99
        message: `Pool percentage (e.g., ${defaults.poolPct} for ${defaults.poolPct}%):`,
×
100
        default: defaults.poolPct,
×
NEW
UNCOV
101
        validate: (val) =>
×
NEW
102
          val === undefined || (typeof val === 'number' && val >= 0 && val < 100)
×
NEW
103
            ? true
×
NEW
104
            : 'Pool percentage must be between 0 and 100 (exclusive)',
×
105
      });
×
106
    } else {
×
107
      poolSize = await number({
×
108
        message: `Number of ${defaults.unitsName.toLowerCase()} for pool:`,
×
NEW
109
        default: Math.floor((authorized || 10000000) * (defaults.poolPct / 100)),
×
NEW
110
        validate: (val) =>
×
NEW
111
          val === undefined || (typeof val === 'number' && val >= 0)
×
NEW
112
            ? true
×
NEW
113
            : 'Pool size must be a non-negative number',
×
114
      });
×
115
    }
×
116
  }
×
117

118
  // Founders
119
  const founders: FounderInput[] = [];
×
120
  const addFounders = await confirm({
×
121
    message: 'Add founders now?',
×
122
    default: true,
×
123
  });
×
124

125
  if (addFounders) {
×
126
    let addMore = true;
×
127
    while (addMore) {
×
128
      const founderName = await input({
×
UNCOV
129
        message: 'Founder name:',
×
UNCOV
130
      });
×
131

132
      const founderEmail = await input({
×
133
        message: 'Founder email (optional):',
×
134
        default: undefined,
×
135
      });
×
136

137
      const founderShares = await number({
×
138
        message: `Number of ${defaults.unitsName.toLowerCase()}:`,
×
139
      });
×
140

141
      if (founderShares && founderShares > 0) {
×
142
        founders.push({
×
UNCOV
143
          name: founderName,
×
144
          email: founderEmail || undefined,
×
145
          shares: founderShares,
×
146
        });
×
147
      }
×
148

149
      addMore = await confirm({
×
150
        message: 'Add another founder?',
×
151
        default: false,
×
152
      });
×
153
    }
×
UNCOV
154
  }
×
155

156
  return {
×
157
    name,
×
158
    formationDate,
×
159
    entityType,
×
160
    jurisdiction,
×
161
    currency,
×
162
    authorized: authorized || defaults.authorized,
×
UNCOV
163
    parValue,
×
164
    poolSize,
×
165
    poolPct,
×
166
    founders,
×
167
  };
×
168
}
×
169

170
export function parseFounderString(founderStr: string): FounderInput {
2✔
171
  // Formats:
172
  // "Name:qty"
173
  // "Name:qty@pps" (pps ignored for simplicity)
174
  // "Name:email:qty"
175
  // "Name:email:qty@pps"
176

177
  const parts = founderStr.split(':');
20✔
178

179
  if (parts.length === 2) {
20✔
180
    // "Name:qty" or "Name:qty@pps"
181
    const [name, qtyPart] = parts;
12✔
182
    const qty = parseInt(qtyPart.split('@')[0].replace(/,/g, ''));
12✔
183
    return { name: name.trim(), shares: qty };
12✔
184
  } else if (parts.length === 3) {
20✔
185
    // "Name:email:qty" or "Name:email:qty@pps"
186
    const [name, email, qtyPart] = parts;
6✔
187
    const qty = parseInt(qtyPart.split('@')[0].replace(/,/g, ''));
6✔
188
    return {
6✔
189
      name: name.trim(),
6✔
190
      email: email.trim(),
6✔
191
      shares: qty,
6✔
192
    };
6✔
193
  }
6✔
194

195
  throw new Error(`Invalid founder format: ${founderStr}`);
2✔
196
}
2✔
197

198
export function calculatePoolFromPercentage(founderShares: number, poolPct: number): number {
2✔
199
  // Pool as percentage of fully diluted (founders + pool)
200
  // If founders have F shares and we want pool to be P% of total:
201
  // Pool / (Founders + Pool) = P/100
202
  // Pool = (P/100) * (F + Pool)
203
  // Pool * (1 - P/100) = F * (P/100)
204
  // Pool = F * (P/100) / (1 - P/100)
205
  if (poolPct <= 0) return 0;
38✔
206
  const ratio = poolPct / 100;
32✔
207
  if (ratio >= 1) {
38✔
208
    // 100% pool is undefined (infinite); require explicit correction upstream
209
    return 0;
4✔
210
  }
4✔
211
  return Math.floor((founderShares * ratio) / (1 - ratio));
28✔
212
}
28✔
213

214
export function buildModelFromWizard(result: WizardResult): FileModel {
2✔
215
  const model: FileModel = {
44✔
216
    version: 1,
44✔
217
    company: {
44✔
218
      id: `comp_${randomUUID()}`,
44✔
219
      name: result.name,
44✔
220
      formationDate: result.formationDate,
44✔
221
      entityType: result.entityType,
44✔
222
      jurisdiction: result.jurisdiction,
44✔
223
      currency: result.currency,
44✔
224
    },
44✔
225
    stakeholders: [],
44✔
226
    securityClasses: [],
44✔
227
    issuances: [],
44✔
228
    optionGrants: [],
44✔
229
    safes: [],
44✔
230
    valuations: [],
44✔
231
    audit: [],
44✔
232
  };
44✔
233

234
  const isCorp = result.entityType === 'C_CORP' || result.entityType === 'S_CORP';
44✔
235

236
  // Add common stock/units class
237
  model.securityClasses.push({
44✔
238
    id: 'sc_common',
44✔
239
    kind: 'COMMON',
44✔
240
    label: isCorp ? 'Common Stock' : 'Common Units',
44✔
241
    authorized: result.authorized,
44✔
242
    parValue: result.parValue,
44✔
243
  });
44✔
244

245
  // Add founders
246
  let totalFounderShares = 0;
44✔
247
  for (const founder of result.founders) {
44✔
248
    const stakeholderId = `sh_${randomUUID()}`;
40✔
249
    model.stakeholders.push({
40✔
250
      id: stakeholderId,
40✔
251
      type: 'person',
40✔
252
      name: founder.name,
40✔
253
      email: founder.email,
40✔
254
    });
40✔
255

256
    if (founder.shares > 0) {
40✔
257
      model.issuances.push({
40✔
258
        id: `is_${randomUUID()}`,
40✔
259
        securityClassId: 'sc_common',
40✔
260
        stakeholderId,
40✔
261
        qty: founder.shares,
40✔
262
        pps: result.parValue || 0,
40✔
263
        date: model.company.formationDate!,
40✔
264
      });
40✔
265
      totalFounderShares += founder.shares;
40✔
266
    }
40✔
267
  }
40✔
268

269
  // Calculate and add pool
270
  let poolQty = result.poolSize;
44✔
271
  if (!poolQty && result.poolPct && totalFounderShares > 0) {
44✔
272
    poolQty = calculatePoolFromPercentage(totalFounderShares, result.poolPct);
12✔
273
  }
12✔
274

275
  if (poolQty && poolQty > 0) {
44✔
276
    const currentYear = new Date().getFullYear();
22✔
277
    model.securityClasses.push({
22✔
278
      id: 'sc_pool',
22✔
279
      kind: 'OPTION_POOL',
22✔
280
      label: `${currentYear} Stock Option Plan`,
22✔
281
      authorized: poolQty,
22✔
282
    });
22✔
283
  }
22✔
284

285
  return model;
44✔
286
}
44✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc