@robotixai/calculator-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,747 @@
1
+ /**
2
+ * Advanced Mode Cash Waterfall Engine
3
+ *
4
+ * Tracks individual financial items (cash, investments, properties, loans,
5
+ * salary, pensions, etc.) separately with a cash account as the central
6
+ * reservoir. Implements the 9-step cash waterfall per spec section 14.
7
+ *
8
+ * Resolution order per year:
9
+ * 1. Income phase (salary, pension, SS, rental, cash yield)
10
+ * 2. Mandatory debits (mortgages, loan interest/principal)
11
+ * 3. Investment sales (single/staggered profit-taking)
12
+ * 4. Liquidity events
13
+ * 5. Contributions (pre-retirement)
14
+ * 6. Withdrawal demand (post-retirement)
15
+ * 7. Tax settlement
16
+ * 8. Insolvency check
17
+ * 9. Investment growth (per-item rates, fees, performance fees)
18
+ */
19
+ import { CadenceMultiplier } from './defaults';
20
+ import { calculateTax } from './tax';
21
+ import { calculateWithdrawal, } from './withdrawal';
22
+ // =============================================================================
23
+ // Helper Functions
24
+ // =============================================================================
25
+ /**
26
+ * Standard amortization payment formula.
27
+ * GUARD: if rate === 0, return principal / term to avoid division by zero.
28
+ * GUARD: if term <= 0, return 0 (perpetual loan — interest-only).
29
+ */
30
+ function amortizationPayment(principal, annualRate, term) {
31
+ if (term <= 0)
32
+ return 0;
33
+ if (principal <= 0)
34
+ return 0;
35
+ if (annualRate === 0)
36
+ return principal / term;
37
+ const r = annualRate;
38
+ return principal * (r / (1 - Math.pow(1 + r, -term)));
39
+ }
40
+ /**
41
+ * Resolve a staggered step value for the given age, falling back to a default.
42
+ * Uses Array.find — first matching step wins (overlap behavior per ADR-012).
43
+ */
44
+ function resolveStaggeredStep(steps, age) {
45
+ return steps.find((s) => age >= s.start_age && age <= s.end_age);
46
+ }
47
+ /**
48
+ * Resolve salary income for a given age, handling flat vs staggered income_steps
49
+ * and applying raises.
50
+ */
51
+ function resolveSalaryIncome(item, age, currentAge) {
52
+ var _a;
53
+ if (!item.enabled)
54
+ return 0;
55
+ if (age < item.income_start_age || age > item.income_end_age)
56
+ return 0;
57
+ // Base income: flat or staggered
58
+ let baseIncome;
59
+ if (item.income_mode === 'staggered' && item.income_steps.length > 0) {
60
+ const step = resolveStaggeredStep(item.income_steps, age);
61
+ if (!step)
62
+ return 0;
63
+ const freq = (_a = step.frequency) !== null && _a !== void 0 ? _a : item.income_frequency;
64
+ baseIncome = step.amount * (freq === 'Monthly' ? 12 : 1);
65
+ }
66
+ else {
67
+ baseIncome = item.income_amount * (item.income_frequency === 'Monthly' ? 12 : 1);
68
+ }
69
+ // Apply raises
70
+ const yearsWorked = age - Math.max(item.income_start_age, currentAge);
71
+ if (yearsWorked > 0) {
72
+ if (item.salary_raise_mode === 'staggered' && item.salary_raise_steps.length > 0) {
73
+ // For staggered raises, compound year by year from start
74
+ let salary = item.income_amount * (item.income_frequency === 'Monthly' ? 12 : 1);
75
+ for (let a = Math.max(item.income_start_age, currentAge) + 1; a <= age; a++) {
76
+ const raiseStep = resolveStaggeredStep(item.salary_raise_steps, a);
77
+ const raisePct = raiseStep ? raiseStep.raise_pct : 0;
78
+ salary *= 1 + raisePct / 100;
79
+ }
80
+ baseIncome = salary;
81
+ }
82
+ else {
83
+ // Flat raise: compound
84
+ baseIncome *= Math.pow(1 + item.salary_raise_pct / 100, yearsWorked);
85
+ }
86
+ }
87
+ // Add bonus
88
+ baseIncome *= 1 + item.salary_bonus_pct / 100;
89
+ return baseIncome;
90
+ }
91
+ /**
92
+ * Resolve cash yield rate for the given age (flat or staggered).
93
+ */
94
+ function resolveCashYield(item, age) {
95
+ if (item.cash_yield_mode === 'staggered' && item.cash_yield_steps.length > 0) {
96
+ const step = resolveStaggeredStep(item.cash_yield_steps, age);
97
+ return step ? step.rate_pct : 0;
98
+ }
99
+ return item.rate_pct;
100
+ }
101
+ /**
102
+ * Resolve contribution amount for an investment item at the given age.
103
+ * Returns annual contribution amount.
104
+ */
105
+ function resolveContributions(item, age, currentAge) {
106
+ var _a, _b;
107
+ // Check age bounds
108
+ const startAge = (_a = item.contrib_start_age) !== null && _a !== void 0 ? _a : currentAge;
109
+ const endAge = (_b = item.contrib_end_age) !== null && _b !== void 0 ? _b : 120;
110
+ if (age < startAge || age > endAge)
111
+ return 0;
112
+ // Check invest_start_age — no contributions before the investment enters the portfolio
113
+ if (item.invest_start_age != null && age < item.invest_start_age)
114
+ return 0;
115
+ if (item.contrib_mode === 'staggered' && item.contrib_steps.length > 0) {
116
+ const step = resolveStaggeredStep(item.contrib_steps, age);
117
+ if (!step)
118
+ return 0;
119
+ // Use the parent's cadence to annualize
120
+ return step.amount * CadenceMultiplier[item.contrib_cadence];
121
+ }
122
+ // Flat mode with annual increase
123
+ const yearsFromStart = age - startAge;
124
+ const baseAnnual = item.contrib_amount * CadenceMultiplier[item.contrib_cadence];
125
+ return baseAnnual * Math.pow(1 + item.contrib_increase_pct / 100, yearsFromStart);
126
+ }
127
+ // =============================================================================
128
+ // Income Category Helpers
129
+ // =============================================================================
130
+ const INCOME_CATEGORIES = new Set([
131
+ 'Pension',
132
+ 'Social Security',
133
+ 'Annuity',
134
+ 'Retirement Income',
135
+ 'Other',
136
+ ]);
137
+ // =============================================================================
138
+ // Main Projection
139
+ // =============================================================================
140
+ export function runAdvancedProjection(scenario, overrideReturns) {
141
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1;
142
+ const { current_age, retirement_age, end_age, inflation_pct, inflation_enabled, financial_items, liquidity_events, enable_taxes, effective_tax_rate_pct, tax_jurisdiction, tax_config, black_swan_enabled, black_swan_age, black_swan_loss_pct, desired_estate, } = scenario;
143
+ const items = financial_items.filter((item) => item.enabled);
144
+ // -------------------------------------------------------------------------
145
+ // Initialize state
146
+ // -------------------------------------------------------------------------
147
+ const cashItem = items.find((i) => i.category === 'Cash');
148
+ let cashBalance = (_a = cashItem === null || cashItem === void 0 ? void 0 : cashItem.current_value) !== null && _a !== void 0 ? _a : 0;
149
+ // Investment balances indexed by position in original financial_items array
150
+ const investmentBalances = new Map();
151
+ const highWaterMarks = new Map();
152
+ const costBasis = new Map();
153
+ const loanBalances = new Map();
154
+ // Property/Collectables value tracking
155
+ const propertyValues = new Map();
156
+ for (let idx = 0; idx < financial_items.length; idx++) {
157
+ const item = financial_items[idx];
158
+ if (!item.enabled)
159
+ continue;
160
+ if (item.category === 'Investment') {
161
+ investmentBalances.set(idx, item.current_value);
162
+ highWaterMarks.set(idx, item.current_value);
163
+ costBasis.set(idx, item.current_value);
164
+ }
165
+ else if (item.category === 'Property' || item.category === 'Collectables') {
166
+ propertyValues.set(idx, item.current_value);
167
+ costBasis.set(idx, item.current_value);
168
+ }
169
+ else if (item.category === 'Loan') {
170
+ loanBalances.set(idx, item.loan_opening_principal);
171
+ // Credit opening principal to cash if configured
172
+ if (item.loan_credit_at_start && current_age === item.loan_start_age) {
173
+ cashBalance += item.loan_opening_principal;
174
+ }
175
+ }
176
+ }
177
+ // -------------------------------------------------------------------------
178
+ // Accumulators
179
+ // -------------------------------------------------------------------------
180
+ const timeline = [];
181
+ let cpiIndex = 1.0;
182
+ let gkState = null;
183
+ let firstShortfallAge = null;
184
+ let totalContributions = 0;
185
+ let totalWithdrawals = 0;
186
+ let totalFees = 0;
187
+ let totalTaxes = 0;
188
+ // -------------------------------------------------------------------------
189
+ // Year-by-year loop
190
+ // -------------------------------------------------------------------------
191
+ for (let age = current_age; age <= end_age; age++) {
192
+ const yearIndex = age - current_age;
193
+ // Snapshot start-of-year balances
194
+ const startCash = cashBalance;
195
+ const startInvestments = sumMap(investmentBalances);
196
+ const startProperties = sumMap(propertyValues);
197
+ const startLoans = sumMap(loanBalances);
198
+ const startBalance = startCash + startInvestments + startProperties - startLoans;
199
+ // Per-year accumulators
200
+ let yearIncome = 0;
201
+ let yearIncomeTaxes = 0;
202
+ let yearContributions = 0;
203
+ let yearWithdrawals = 0;
204
+ let yearDesiredSpending = 0;
205
+ let yearFees = 0;
206
+ let yearTaxes = 0;
207
+ let yearGrowth = 0;
208
+ let yearLiquidityNet = 0;
209
+ let yearLoanInterest = 0;
210
+ let yearLoanPrincipalRepaid = 0;
211
+ let yearMortgagePaid = 0;
212
+ let yearCashYield = 0;
213
+ let shortfallMandatory = 0;
214
+ let shortfallContributions = 0;
215
+ let shortfallWithdrawals = 0;
216
+ let yearTaxableIncome = 0;
217
+ // =====================================================================
218
+ // 1. INCOME PHASE
219
+ // =====================================================================
220
+ // --- Salary items ---
221
+ for (let idx = 0; idx < financial_items.length; idx++) {
222
+ const item = financial_items[idx];
223
+ if (!item.enabled || item.category !== 'Salary')
224
+ continue;
225
+ const gross = resolveSalaryIncome(item, age, current_age);
226
+ if (gross <= 0)
227
+ continue;
228
+ const tax = item.taxable ? gross * (item.tax_rate / 100) : 0;
229
+ const net = gross - tax;
230
+ yearIncome += gross;
231
+ yearIncomeTaxes += tax;
232
+ if (item.taxable)
233
+ yearTaxableIncome += gross;
234
+ // Route income
235
+ if (item.income_destination !== 'cash' &&
236
+ item.reinvest_target_item_index != null &&
237
+ investmentBalances.has(item.reinvest_target_item_index)) {
238
+ const targetIdx = item.reinvest_target_item_index;
239
+ investmentBalances.set(targetIdx, ((_b = investmentBalances.get(targetIdx)) !== null && _b !== void 0 ? _b : 0) + net);
240
+ costBasis.set(targetIdx, ((_c = costBasis.get(targetIdx)) !== null && _c !== void 0 ? _c : 0) + net);
241
+ }
242
+ else {
243
+ cashBalance += net;
244
+ }
245
+ }
246
+ // --- Pension/SS/Annuity/RetirementIncome/Other items ---
247
+ for (let idx = 0; idx < financial_items.length; idx++) {
248
+ const item = financial_items[idx];
249
+ if (!item.enabled || !INCOME_CATEGORIES.has(item.category))
250
+ continue;
251
+ if (age < item.income_start_age || age > item.income_end_age)
252
+ continue;
253
+ let income;
254
+ if (item.income_mode === 'staggered' && item.income_steps.length > 0) {
255
+ const step = resolveStaggeredStep(item.income_steps, age);
256
+ if (!step)
257
+ continue;
258
+ const freq = (_d = step.frequency) !== null && _d !== void 0 ? _d : item.income_frequency;
259
+ income = step.amount * (freq === 'Monthly' ? 12 : 1);
260
+ }
261
+ else {
262
+ income = item.income_amount * (item.income_frequency === 'Monthly' ? 12 : 1);
263
+ }
264
+ if (item.inflation_adjusted)
265
+ income *= cpiIndex;
266
+ const tax = item.taxable ? income * (item.tax_rate / 100) : 0;
267
+ const net = income - tax;
268
+ yearIncome += income;
269
+ yearIncomeTaxes += tax;
270
+ if (item.taxable)
271
+ yearTaxableIncome += income;
272
+ cashBalance += net;
273
+ }
274
+ // --- Property rental income ---
275
+ for (let idx = 0; idx < financial_items.length; idx++) {
276
+ const item = financial_items[idx];
277
+ if (!item.enabled || item.category !== 'Property')
278
+ continue;
279
+ if (item.rental_amount <= 0)
280
+ continue;
281
+ if (age < item.rental_start_age || age > item.rental_end_age)
282
+ continue;
283
+ // Skip rental if property doesn't exist yet
284
+ if (item.purchase_age != null && age < item.purchase_age)
285
+ continue;
286
+ // Skip rental if property already sold
287
+ if (item.profit_taking_mode === 'single' &&
288
+ item.sell_at_age != null &&
289
+ age > item.sell_at_age)
290
+ continue;
291
+ let rental = item.rental_amount * (item.rental_frequency === 'Monthly' ? 12 : 1);
292
+ if (item.rental_inflation_adjusted)
293
+ rental *= cpiIndex;
294
+ const tax = item.rental_taxable ? rental * (item.rental_tax_rate / 100) : 0;
295
+ const net = rental - tax;
296
+ yearIncome += rental;
297
+ yearIncomeTaxes += tax;
298
+ if (item.rental_taxable)
299
+ yearTaxableIncome += rental;
300
+ cashBalance += net;
301
+ }
302
+ // --- Cash yield ---
303
+ if (cashItem && cashBalance > 0) {
304
+ const cashRate = resolveCashYield(cashItem, age);
305
+ const yieldAmount = cashBalance * (cashRate / 100);
306
+ cashBalance += yieldAmount;
307
+ yearCashYield += yieldAmount;
308
+ yearIncome += yieldAmount;
309
+ }
310
+ // =====================================================================
311
+ // 2. MANDATORY DEBITS
312
+ // =====================================================================
313
+ // --- Property mortgages ---
314
+ for (let idx = 0; idx < financial_items.length; idx++) {
315
+ const item = financial_items[idx];
316
+ if (!item.enabled || item.category !== 'Property')
317
+ continue;
318
+ if (item.mortgage_payment <= 0)
319
+ continue;
320
+ if (age > item.mortgage_end_age)
321
+ continue;
322
+ const payment = item.mortgage_payment * (item.mortgage_frequency === 'Monthly' ? 12 : 1);
323
+ cashBalance -= payment;
324
+ yearMortgagePaid += payment;
325
+ }
326
+ // --- Loan payments ---
327
+ for (let idx = 0; idx < financial_items.length; idx++) {
328
+ const item = financial_items[idx];
329
+ if (!item.enabled || item.category !== 'Loan')
330
+ continue;
331
+ const balance = (_e = loanBalances.get(idx)) !== null && _e !== void 0 ? _e : 0;
332
+ if (balance <= 0 && !item.loan_draws.some((d) => d.age === age))
333
+ continue;
334
+ // Interest
335
+ const interest = balance * (item.loan_interest_rate_pct / 100);
336
+ cashBalance -= interest;
337
+ yearLoanInterest += interest;
338
+ // Principal
339
+ let principalPayment = 0;
340
+ if (item.loan_payment_mode === 'principal_and_interest' &&
341
+ item.loan_term_years > 0) {
342
+ const elapsed = age - item.loan_start_age;
343
+ const remainingTerm = Math.max(1, item.loan_term_years - elapsed);
344
+ const annualPayment = amortizationPayment(balance, item.loan_interest_rate_pct / 100, remainingTerm);
345
+ principalPayment = Math.max(0, annualPayment - interest);
346
+ }
347
+ else if (item.loan_payment_mode !== 'principal_and_interest') {
348
+ // Interest-only mode: optional fixed principal payment
349
+ principalPayment = item.loan_annual_principal_payment;
350
+ }
351
+ // Cap principal payment at remaining balance
352
+ principalPayment = Math.min(principalPayment, Math.max(0, balance));
353
+ cashBalance -= principalPayment;
354
+ loanBalances.set(idx, balance - principalPayment);
355
+ yearLoanPrincipalRepaid += principalPayment;
356
+ // Loan draws at this age
357
+ for (const draw of item.loan_draws) {
358
+ if (draw.age === age && age >= item.loan_start_age) {
359
+ loanBalances.set(idx, ((_f = loanBalances.get(idx)) !== null && _f !== void 0 ? _f : 0) + draw.amount);
360
+ if (item.loan_credit_at_start) {
361
+ cashBalance += draw.amount;
362
+ }
363
+ }
364
+ }
365
+ // Lump repayments at this age
366
+ for (const repay of item.loan_lump_repayments) {
367
+ if (repay.age === age) {
368
+ const currentBal = (_g = loanBalances.get(idx)) !== null && _g !== void 0 ? _g : 0;
369
+ const actual = Math.min(repay.amount, Math.max(0, currentBal));
370
+ cashBalance -= actual;
371
+ loanBalances.set(idx, currentBal - actual);
372
+ yearLoanPrincipalRepaid += actual;
373
+ }
374
+ }
375
+ }
376
+ // Track mandatory shortfall if cash went negative from mandatory debits
377
+ if (cashBalance < 0) {
378
+ shortfallMandatory = Math.abs(Math.min(0, cashBalance - startCash + yearIncome - yearIncomeTaxes + yearCashYield));
379
+ }
380
+ // =====================================================================
381
+ // 3. INVESTMENT SALES
382
+ // =====================================================================
383
+ for (let idx = 0; idx < financial_items.length; idx++) {
384
+ const item = financial_items[idx];
385
+ if (!item.enabled)
386
+ continue;
387
+ if (item.category !== 'Investment' &&
388
+ item.category !== 'Property' &&
389
+ item.category !== 'Collectables')
390
+ continue;
391
+ // Guard: skip sale if asset doesn't exist yet
392
+ if (item.purchase_age != null && age < item.purchase_age)
393
+ continue;
394
+ const isInvestment = item.category === 'Investment';
395
+ const currentValue = isInvestment
396
+ ? (_h = investmentBalances.get(idx)) !== null && _h !== void 0 ? _h : 0
397
+ : (_j = propertyValues.get(idx)) !== null && _j !== void 0 ? _j : 0;
398
+ const currentBasis = (_k = costBasis.get(idx)) !== null && _k !== void 0 ? _k : 0;
399
+ if (item.profit_taking_mode === 'single' && item.sell_at_age === age) {
400
+ const proceeds = (_l = item.sale_amount_override) !== null && _l !== void 0 ? _l : currentValue;
401
+ const gain = proceeds - currentBasis;
402
+ const tax = item.taxable_on_sale
403
+ ? Math.max(0, gain) * (item.sale_tax_rate / 100)
404
+ : 0;
405
+ cashBalance += proceeds - tax;
406
+ yearTaxes += tax;
407
+ if (item.taxable_on_sale && gain > 0)
408
+ yearTaxableIncome += gain;
409
+ if (isInvestment) {
410
+ investmentBalances.set(idx, 0);
411
+ }
412
+ else {
413
+ propertyValues.set(idx, 0);
414
+ }
415
+ costBasis.set(idx, 0);
416
+ }
417
+ if (item.profit_taking_mode === 'staggered') {
418
+ for (const step of item.profit_taking_steps) {
419
+ // Step matches if age === step.age, or within range if end_age is set
420
+ const matches = step.end_age != null
421
+ ? age >= step.age && age <= step.end_age
422
+ : age === step.age;
423
+ if (!matches)
424
+ continue;
425
+ const bal = isInvestment
426
+ ? (_m = investmentBalances.get(idx)) !== null && _m !== void 0 ? _m : 0
427
+ : (_o = propertyValues.get(idx)) !== null && _o !== void 0 ? _o : 0;
428
+ const basis = (_p = costBasis.get(idx)) !== null && _p !== void 0 ? _p : 0;
429
+ const sellAmount = bal * (step.pct / 100);
430
+ const proportionalBasis = basis * (step.pct / 100);
431
+ const gain = sellAmount - proportionalBasis;
432
+ const tax = item.taxable_on_sale
433
+ ? Math.max(0, gain) * (item.sale_tax_rate / 100)
434
+ : 0;
435
+ cashBalance += sellAmount - tax;
436
+ yearTaxes += tax;
437
+ if (item.taxable_on_sale && gain > 0)
438
+ yearTaxableIncome += gain;
439
+ if (isInvestment) {
440
+ investmentBalances.set(idx, bal - sellAmount);
441
+ }
442
+ else {
443
+ propertyValues.set(idx, bal - sellAmount);
444
+ }
445
+ costBasis.set(idx, basis - proportionalBasis);
446
+ }
447
+ }
448
+ }
449
+ // =====================================================================
450
+ // 4. LIQUIDITY EVENTS
451
+ // =====================================================================
452
+ for (const event of liquidity_events) {
453
+ if (!event.enabled)
454
+ continue;
455
+ if (age < event.start_age || age > event.end_age)
456
+ continue;
457
+ // One-Time fires only at start_age
458
+ if (event.recurrence === 'One-Time' && age !== event.start_age)
459
+ continue;
460
+ let amount = event.amount;
461
+ if (event.recurrence === 'Monthly')
462
+ amount *= 12;
463
+ const tax = event.taxable ? amount * (event.tax_rate / 100) : 0;
464
+ if (event.type === 'Credit') {
465
+ cashBalance += amount - tax;
466
+ yearLiquidityNet += amount - tax;
467
+ if (event.taxable)
468
+ yearTaxableIncome += amount;
469
+ }
470
+ else {
471
+ cashBalance -= amount;
472
+ yearLiquidityNet -= amount;
473
+ // Tax on debit events still applied
474
+ yearTaxes += tax;
475
+ }
476
+ }
477
+ // =====================================================================
478
+ // 5. CONTRIBUTIONS (age < retirement_age)
479
+ // =====================================================================
480
+ if (age < retirement_age) {
481
+ for (let idx = 0; idx < financial_items.length; idx++) {
482
+ const item = financial_items[idx];
483
+ if (!item.enabled || item.category !== 'Investment')
484
+ continue;
485
+ if (item.contrib_amount <= 0 && item.contrib_steps.length === 0)
486
+ continue;
487
+ let contrib = resolveContributions(item, age, current_age);
488
+ if (contrib <= 0)
489
+ continue;
490
+ // Cap at available cash
491
+ if (contrib > Math.max(0, cashBalance)) {
492
+ shortfallContributions += contrib - Math.max(0, cashBalance);
493
+ contrib = Math.max(0, cashBalance);
494
+ }
495
+ cashBalance -= contrib;
496
+ investmentBalances.set(idx, ((_q = investmentBalances.get(idx)) !== null && _q !== void 0 ? _q : 0) + contrib);
497
+ costBasis.set(idx, ((_r = costBasis.get(idx)) !== null && _r !== void 0 ? _r : 0) + contrib);
498
+ yearContributions += contrib;
499
+ }
500
+ }
501
+ // =====================================================================
502
+ // 6. WITHDRAWAL DEMAND (age >= retirement_age)
503
+ // =====================================================================
504
+ if (age >= retirement_age) {
505
+ // Total portfolio for withdrawal calculation
506
+ const totalPortfolio = cashBalance + sumMap(investmentBalances) + sumMap(propertyValues);
507
+ const priorEndBalance = yearIndex > 0 && timeline.length > 0
508
+ ? timeline[timeline.length - 1].end_balance_nominal
509
+ : totalPortfolio;
510
+ const withdrawalResult = calculateWithdrawal(scenario, {
511
+ age,
512
+ currentBalance: totalPortfolio,
513
+ priorEndBalance,
514
+ availableBalance: totalPortfolio,
515
+ cpiIndex,
516
+ gkState,
517
+ });
518
+ if (withdrawalResult.gkState) {
519
+ gkState = withdrawalResult.gkState;
520
+ }
521
+ const desiredWithdrawal = withdrawalResult.withdrawal;
522
+ yearDesiredSpending = desiredWithdrawal;
523
+ // Withdraw from cash first
524
+ const actualFromCash = Math.min(desiredWithdrawal, Math.max(0, cashBalance));
525
+ cashBalance -= actualFromCash;
526
+ const remainingDemand = desiredWithdrawal - actualFromCash;
527
+ if (remainingDemand > 0) {
528
+ shortfallWithdrawals = remainingDemand;
529
+ }
530
+ yearWithdrawals = actualFromCash;
531
+ if (yearWithdrawals > 0)
532
+ yearTaxableIncome += yearWithdrawals;
533
+ }
534
+ // =====================================================================
535
+ // 7. TAX SETTLEMENT
536
+ // =====================================================================
537
+ if (enable_taxes && yearTaxableIncome > 0) {
538
+ let jurisdictionTax = 0;
539
+ if (tax_config) {
540
+ jurisdictionTax = calculateTax(yearTaxableIncome, tax_config, tax_jurisdiction);
541
+ }
542
+ else {
543
+ // Custom / flat rate fallback
544
+ jurisdictionTax = yearTaxableIncome * (effective_tax_rate_pct / 100);
545
+ }
546
+ // Subtract already-withheld income taxes to avoid double-counting
547
+ const netTaxDue = Math.max(0, jurisdictionTax - yearIncomeTaxes);
548
+ cashBalance -= netTaxDue;
549
+ yearTaxes += netTaxDue;
550
+ }
551
+ // =====================================================================
552
+ // 8. INSOLVENCY CHECK
553
+ // =====================================================================
554
+ const insolvency = cashBalance < 0;
555
+ if (insolvency && firstShortfallAge === null) {
556
+ firstShortfallAge = age;
557
+ }
558
+ // =====================================================================
559
+ // 9. INVESTMENT GROWTH
560
+ // =====================================================================
561
+ // --- Black swan event ---
562
+ const blackSwanActive = black_swan_enabled && age === black_swan_age;
563
+ for (let idx = 0; idx < financial_items.length; idx++) {
564
+ const item = financial_items[idx];
565
+ if (!item.enabled || item.category !== 'Investment')
566
+ continue;
567
+ const balance = (_s = investmentBalances.get(idx)) !== null && _s !== void 0 ? _s : 0;
568
+ if (balance <= 0)
569
+ continue;
570
+ let returnRate = overrideReturns != null
571
+ ? ((_t = overrideReturns[yearIndex]) !== null && _t !== void 0 ? _t : item.rate_pct / 100)
572
+ : item.rate_pct / 100;
573
+ // Apply black swan
574
+ if (blackSwanActive) {
575
+ returnRate -= black_swan_loss_pct / 100;
576
+ }
577
+ const grossReturn = balance * returnRate;
578
+ // Management fee
579
+ const mgmtFee = balance * (item.fee_pct / 100);
580
+ // Performance fee (high-water mark)
581
+ let perfFee = 0;
582
+ const hwm = (_u = highWaterMarks.get(idx)) !== null && _u !== void 0 ? _u : 0;
583
+ const postGrowthValue = balance + grossReturn - mgmtFee;
584
+ if (postGrowthValue > hwm && item.perf_fee_pct > 0) {
585
+ const gainAboveHWM = postGrowthValue - hwm;
586
+ perfFee = gainAboveHWM * (item.perf_fee_pct / 100);
587
+ highWaterMarks.set(idx, postGrowthValue - perfFee);
588
+ }
589
+ const newBalance = balance + grossReturn - mgmtFee - perfFee;
590
+ investmentBalances.set(idx, Math.max(0, newBalance));
591
+ yearGrowth += grossReturn;
592
+ yearFees += mgmtFee + perfFee;
593
+ }
594
+ // --- Property/Collectables appreciation ---
595
+ for (let idx = 0; idx < financial_items.length; idx++) {
596
+ const item = financial_items[idx];
597
+ if (!item.enabled)
598
+ continue;
599
+ if (item.category !== 'Property' && item.category !== 'Collectables')
600
+ continue;
601
+ const value = (_v = propertyValues.get(idx)) !== null && _v !== void 0 ? _v : 0;
602
+ if (value <= 0)
603
+ continue;
604
+ // Skip growth if not yet purchased
605
+ if (item.purchase_age != null && age < item.purchase_age)
606
+ continue;
607
+ let growthRate = item.rate_pct / 100;
608
+ if (blackSwanActive) {
609
+ growthRate -= black_swan_loss_pct / 100;
610
+ }
611
+ const appreciation = value * growthRate;
612
+ propertyValues.set(idx, value + appreciation);
613
+ yearGrowth += appreciation;
614
+ }
615
+ // =====================================================================
616
+ // Build TimelineRow
617
+ // =====================================================================
618
+ // Update CPI for this year
619
+ if (inflation_enabled) {
620
+ cpiIndex *= 1 + inflation_pct / 100;
621
+ }
622
+ const endCash = cashBalance;
623
+ const endInvestments = sumMap(investmentBalances);
624
+ const endProperties = sumMap(propertyValues);
625
+ const endDebt = sumMap(loanBalances);
626
+ const endBalanceNominal = endCash + endInvestments + endProperties - endDebt;
627
+ const endBalanceReal = cpiIndex > 0 ? endBalanceNominal / cpiIndex : endBalanceNominal;
628
+ // Liquid vs illiquid classification
629
+ let endLiquid = endCash;
630
+ let endIlliquid = 0;
631
+ for (let idx = 0; idx < financial_items.length; idx++) {
632
+ const item = financial_items[idx];
633
+ if (!item.enabled)
634
+ continue;
635
+ if (item.category === 'Investment') {
636
+ const bal = (_w = investmentBalances.get(idx)) !== null && _w !== void 0 ? _w : 0;
637
+ if (item.is_liquid)
638
+ endLiquid += bal;
639
+ else
640
+ endIlliquid += bal;
641
+ }
642
+ else if (item.category === 'Property' || item.category === 'Collectables') {
643
+ const val = (_x = propertyValues.get(idx)) !== null && _x !== void 0 ? _x : 0;
644
+ if (item.is_liquid)
645
+ endLiquid += val;
646
+ else
647
+ endIlliquid += val;
648
+ }
649
+ }
650
+ totalContributions += yearContributions;
651
+ totalWithdrawals += yearWithdrawals;
652
+ totalFees += yearFees;
653
+ totalTaxes += yearTaxes + yearIncomeTaxes;
654
+ const row = {
655
+ age,
656
+ start_balance_nominal: startBalance,
657
+ contributions: yearContributions,
658
+ liquidity_net: yearLiquidityNet,
659
+ income: yearIncome,
660
+ withdrawals: yearWithdrawals,
661
+ desired_spending: yearDesiredSpending,
662
+ fees: yearFees,
663
+ taxes: yearTaxes,
664
+ income_taxes: yearIncomeTaxes,
665
+ growth: yearGrowth,
666
+ end_balance_nominal: endBalanceNominal,
667
+ cpi_index: cpiIndex,
668
+ end_balance_real: endBalanceReal,
669
+ end_cash_nominal: endCash,
670
+ end_debt_nominal: endDebt,
671
+ end_investments_nominal: endInvestments + endProperties,
672
+ end_liquid_nominal: endLiquid,
673
+ end_illiquid_nominal: endIlliquid,
674
+ loan_interest: yearLoanInterest,
675
+ loan_principal_repaid: yearLoanPrincipalRepaid,
676
+ mortgage_paid: yearMortgagePaid,
677
+ cash_yield: yearCashYield,
678
+ insolvency,
679
+ shortfall_mandatory: shortfallMandatory,
680
+ shortfall_contributions: shortfallContributions,
681
+ shortfall_withdrawals: shortfallWithdrawals,
682
+ };
683
+ timeline.push(row);
684
+ }
685
+ // -------------------------------------------------------------------------
686
+ // Compute Metrics
687
+ // -------------------------------------------------------------------------
688
+ const lastRow = timeline[timeline.length - 1];
689
+ const terminalNominal = (_y = lastRow === null || lastRow === void 0 ? void 0 : lastRow.end_balance_nominal) !== null && _y !== void 0 ? _y : 0;
690
+ const terminalReal = (_z = lastRow === null || lastRow === void 0 ? void 0 : lastRow.end_balance_real) !== null && _z !== void 0 ? _z : 0;
691
+ // Estate value: sum of projected values * estate_pct, minus debt
692
+ let estateValue = 0;
693
+ for (let idx = 0; idx < financial_items.length; idx++) {
694
+ const item = financial_items[idx];
695
+ if (!item.enabled || item.estate_pct <= 0)
696
+ continue;
697
+ let projectedValue = 0;
698
+ if (item.category === 'Investment') {
699
+ projectedValue = (_0 = investmentBalances.get(idx)) !== null && _0 !== void 0 ? _0 : 0;
700
+ }
701
+ else if (item.category === 'Property' || item.category === 'Collectables') {
702
+ projectedValue = (_1 = propertyValues.get(idx)) !== null && _1 !== void 0 ? _1 : 0;
703
+ }
704
+ else if (item.category === 'Cash') {
705
+ projectedValue = cashBalance;
706
+ }
707
+ estateValue += projectedValue * (item.estate_pct / 100);
708
+ }
709
+ estateValue -= sumMap(loanBalances);
710
+ estateValue = Math.max(0, estateValue);
711
+ // Readiness score: only meaningful for fixed-dollar withdrawal
712
+ let readinessScore = 100;
713
+ if (scenario.withdrawal_method === 'Fixed real-dollar amount') {
714
+ const totalDesired = timeline
715
+ .filter((r) => r.age >= retirement_age)
716
+ .reduce((sum, r) => sum + r.desired_spending, 0);
717
+ const totalActual = timeline
718
+ .filter((r) => r.age >= retirement_age)
719
+ .reduce((sum, r) => sum + r.withdrawals, 0);
720
+ readinessScore =
721
+ totalDesired > 0
722
+ ? Math.min(200, (totalActual / totalDesired) * 100)
723
+ : 100;
724
+ }
725
+ const metrics = {
726
+ terminal_nominal: terminalNominal,
727
+ terminal_real: terminalReal,
728
+ first_shortfall_age: firstShortfallAge,
729
+ readiness_score: readinessScore,
730
+ total_contributions: totalContributions,
731
+ total_withdrawals: totalWithdrawals,
732
+ total_fees: totalFees,
733
+ total_taxes: totalTaxes,
734
+ estate_value: estateValue,
735
+ };
736
+ return { timeline, metrics };
737
+ }
738
+ // =============================================================================
739
+ // Utility
740
+ // =============================================================================
741
+ function sumMap(map) {
742
+ let total = 0;
743
+ for (const v of map.values()) {
744
+ total += v;
745
+ }
746
+ return total;
747
+ }