# Model: side-business — v1.0.1 (as-implemented)

Engine: `calcSideBusiness(inputs: SideBusinessInputs)` in
`src/lib/engine.ts`. After-tax value of side/freelance income, the extra
wealth it builds over a horizon, and a rough FIRE-acceleration estimate.

> **Unit warning:** all rates are annual decimals (`0.153` = 15.3% SE
> tax). Income and expense inputs are **monthly** USD.

## Inputs

| Name | Type | Unit | Domain |
|---|---|---|---|
| sideIncomeMonthly | number | USD/**mo** gross | 200–20,000 |
| selfEmploymentTaxRate | number | decimal | 0.10–0.20 |
| incomeTaxRate | number | decimal | 0.10–0.37 |
| businessExpensesMonthly | number | USD/**mo** | 0–5,000 |
| currentSavings | number | USD | 0–1,000,000 |
| currentMonthlySavings | number | USD/**mo** | 200–10,000 |
| annualReturn | number | decimal/yr | 0.02–0.12 (no UI slider; default 0.07) |
| yearsHorizon | integer | years | 5–40 |
| currentAge | integer | years | 18–70 (no UI slider; default 35) |

> **Removed in v1.0.1 (breaking):** `currentSalary` and `retirementAge`
> no longer exist. Both were dead inputs (`currentSalary` was never
> even destructured; `retirementAge` was destructured but never used),
> and neither had a UI slider. Callers must stop sending them — the API
> rejects unknown fields.

Keep `annualReturn > 0` in vectors: `annualReturn = 0` makes
`monthlyReturn = 0` and the annuity factor divides 0/0 → NaN.

## Computation

Let `r = annualReturn`, `r_m = r/12`, `n = yearsHorizon`.

1. `grossSideIncome = sideIncomeMonthly − businessExpensesMonthly`
   (USD/mo; despite the name this is **net of expenses** — see issues).
   May be negative.
2. `taxRate = selfEmploymentTaxRate + incomeTaxRate` (plain sum of
   decimals, up to 0.57 in-domain).
3. `netSideIncome = grossSideIncome × (1 − taxRate)` (USD/mo; negative
   when expenses exceed income).
4. `additionalMonthlySavings = max(0, netSideIncome)` — 100% of net side
   income is assumed saved (hardwired).
5. Closed-form horizon wealth (monthly-annuity convention):
   `wealthAtHorizonWithout = currentSavings × (1 + r)^n +
   currentMonthlySavings × ((1 + r_m)^(12n) − 1) / r_m`;
   `wealthAtHorizonWith` is the same with
   `currentMonthlySavings + additionalMonthlySavings` as the deposit.
   `additionalWealth = wealthAtHorizonWith − wealthAtHorizonWithout`.
6. FIRE acceleration: set `targetWealth = wealthAtHorizonWithout`. Then
   iterate `bal = currentSavings`, `yearsWithSide = 0`;
   `while (bal < targetWealth && yearsWithSide < 100) { bal = bal ×
   (1 + r) + (currentMonthlySavings + additionalMonthlySavings) × 12;
   yearsWithSide++ }`. Note this loop uses **annual** compounding of the
   yearly deposit (`monthly × 12` added after growth, no intra-year
   return) while the target came from the **monthly-annuity** closed form
   — deliberately mismatched conventions; see issues.
   `fireAccelerationYears = max(0, yearsHorizon − yearsWithSide)`.
7. `yearlyData`, index y = 0..yearsHorizon: both balances start at
   `currentSavings`; each iteration **records first**
   (`{year: currentAge + y, without: withoutBal, with: withBal}`) then
   updates `bal = bal × (1 + r) + deposit × 12` (deposits as in step 6;
   `without` uses `currentMonthlySavings` alone). No rounding anywhere in
   this model; no wall-clock dependence (`year` is age-based).

## Output keys

`grossSideIncome`, `netSideIncome`, `additionalMonthlySavings`,
`fireAccelerationYears`, `wealthAtHorizonWithout`, `wealthAtHorizonWith`,
`additionalWealth`, `yearlyData` (fields `{year, without, with}` — note
`with` is a reserved-looking but legal JSON/JS key). `taxRate`,
`monthlyReturn`, and `targetWealth` are intermediates, not outputs.

## Assumptions & exclusions (part of the contract)

- SE tax is applied to the full net-of-expenses amount: no 92.35%
  SE-base factor, no employer-half deduction, no QBI — the flat
  `SE + income` sum is the contract.
- The entire after-tax side income is invested (0% lifestyle inflation).
- Headline wealth outputs use monthly annuity compounding; the chart and
  the FIRE loop use annual deposit compounding. The chart's final point
  is therefore **below** `wealthAtHorizonWith(out)` for the same inputs.

## Known model issues (v1.0.1)

- **`grossSideIncome` is misnamed** — it is income *minus expenses*
  (i.e. pre-tax net), not gross.
- **Mixed compounding conventions** (step 6): because the annual-deposit
  loop grows slower than the monthly-annuity target, `yearsWithSide`
  is biased high and `fireAccelerationYears` clamps to 0 for small side
  incomes even though wealth strictly increased. The `max(0, …)` hides
  the inconsistency.
- Loop cap: `yearsWithSide` stops at 100; with the clamp the output is
  then 0 (there is no explicit "never" sentinel).
- `annualReturn = 0` → NaN in the annuity factors (out-of-domain).
- Negative `netSideIncome` is reported as-is but contributes nothing to
  savings (floored at step 4) — the model never charges the loss.

## Changelog

- **1.0.1 (2026-08-13, product-approved): BREAKING — removed dead
  inputs `currentSalary` and `retirementAge`** (accepted, never used;
  no UI sliders). No numeric output changes.
- 1.0.0: initial as-implemented spec.

## Verification

Vectors: `oracle/vectors/side-business.json` (seeded fuzz + boundary
cases). Tolerance: relative 1e-9 / absolute 1e-6 per numeric field;
`yearlyData` element-wise.

Suggested curated cases:

```jsonc
// expenses exceed income: negative gross/net, additionalMonthlySavings = 0,
// fireAccelerationYears = 0 (loop still runs against the closed-form target)
{ "sideIncomeMonthly": 200, "selfEmploymentTaxRate": 0.153,
  "incomeTaxRate": 0.22, "businessExpensesMonthly": 5000, "currentSavings": 50000,
  "currentMonthlySavings": 1500, "annualReturn": 0.07, "yearsHorizon": 20,
  "currentAge": 35 }

// max combined tax (0.57) at max side income, long horizon — compounding-convention gap largest
{ "sideIncomeMonthly": 20000, "selfEmploymentTaxRate": 0.20,
  "incomeTaxRate": 0.37, "businessExpensesMonthly": 0, "currentSavings": 0,
  "currentMonthlySavings": 200, "annualReturn": 0.12, "yearsHorizon": 40,
  "currentAge": 18 }

// exact break-even: income = expenses → grossSideIncome and netSideIncome
// exactly 0, additionalMonthlySavings 0, both paths identical
{ "sideIncomeMonthly": 1000, "selfEmploymentTaxRate": 0.153,
  "incomeTaxRate": 0.22, "businessExpensesMonthly": 1000, "currentSavings": 50000,
  "currentMonthlySavings": 1500, "annualReturn": 0.07, "yearsHorizon": 20,
  "currentAge": 35 }
```
