From c827deb3952f2020607b0cd38bac0a1f3c382421 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Sat, 24 Oct 2026 14:46:32 +0000 Subject: [PATCH] money scenarios: contract timing for salary extras (13th month in Nov, holiday pay in May, bonus in Aug, net estimates from the contract) --- src/lib/money-tracker/ScenarioDetails.svelte | 2 +- src/lib/money-tracker/scenarios.test.ts | 14 +++++- src/lib/money-tracker/scenarios.ts | 45 +++++++++++++------- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/lib/money-tracker/ScenarioDetails.svelte b/src/lib/money-tracker/ScenarioDetails.svelte index 0e72048..5ed8cc6 100644 --- a/src/lib/money-tracker/ScenarioDetails.svelte +++ b/src/lib/money-tracker/ScenarioDetails.svelte @@ -52,7 +52,7 @@ { key: 'raisePercent', label: 'Yearly raise', unit: '%' }, { key: 'raiseMonth', label: 'Raise month', unit: '' }, { key: 'raiseFromYear', label: 'Raises from', unit: '' }, - { key: 'bonusPercentOfTarget', label: 'Bonus paid of target', unit: '%' }, + { key: 'bonusPercentOfTarget', label: 'Bonus paid of estimate', unit: '%' }, { key: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' }, { key: 'sideEndEuros', label: 'Side income at end (gross)', unit: '€/mo' } ] diff --git a/src/lib/money-tracker/scenarios.test.ts b/src/lib/money-tracker/scenarios.test.ts index d9004a4..5c948e5 100644 --- a/src/lib/money-tracker/scenarios.test.ts +++ b/src/lib/money-tracker/scenarios.test.ts @@ -77,8 +77,18 @@ describe('runScenario', () => { const result = runScenario(inputs, flat); const november = result.points[1]; expect(november.cash).toBe(500000); - // 300k salary + 10k benefit + 28k one-off − 3k gym − 90k pre-move household. - expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 - 3000 - 90000, 0); + // 300k salary + 10k benefit + 28k one-off + 13th month 3/12 − 3k gym − 90k pre-move household. + expect(november.total).toBeCloseTo(350000 + 300000 + 10000 + 28000 + 51250 - 3000 - 90000, 0); + }); + it('pays contract extras in May (holiday pay) and August (bonus)', () => { + const delta = (points: { total: number }[], index: number) => + points[index].total - points[index - 1].total; + const flatRun = runScenario(inputs, flat).points; + // Index 7 is May 2027: holiday pay 8/12 of €1,950. + expect(delta(flatRun, 7) - delta(flatRun, 6)).toBeCloseTo(130000, 0); + const bonus = runScenario(inputs, { ...flat, bonusPercentOfTarget: 100 }).points; + // Index 10 is August 2027: the first bonus, €850. + expect(delta(bonus, 10) - delta(flatRun, 10)).toBeCloseTo(85000, 0); }); it('orders the default scenarios from pessimistic to optimistic', () => { const [pessimistic, realistic, optimistic] = ( diff --git a/src/lib/money-tracker/scenarios.ts b/src/lib/money-tracker/scenarios.ts index 25e0d52..869f10e 100644 --- a/src/lib/money-tracker/scenarios.ts +++ b/src/lib/money-tracker/scenarios.ts @@ -33,7 +33,7 @@ export type ScenarioParams = { raisePercent: number; raiseMonth: number; raiseFromYear: number; - /** Share of the 5% target bonus actually paid each September, 0–100. */ + /** Share of the contract's bonus estimate actually paid each August, 0–100. */ bonusPercentOfTarget: number; /** Side income, gross euros per month, ramping linearly to the end value. */ sideStartEuros: number; @@ -95,7 +95,7 @@ export const defaultScenarios: Record = { raisePercent: 3.5, raiseMonth: 10, raiseFromYear: 2028, - bonusPercentOfTarget: 50, + bonusPercentOfTarget: 100, sideStartEuros: 250, sideEndEuros: 500, spendGrowthPercent: 2.5, @@ -141,10 +141,19 @@ export const scenarioFacts = { contractEnd: '2027-09-30', /** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */ marginalNet: (1 - 0.0265) * 0.5, - /** Holiday pay (8%) and 13th month accrue monthly; paid half net in May and December. */ - holidayPayRate: 0.08, - /** Target bonus as a share of yearly gross (12.96 months), paid half net in September. */ - bonusTarget: 0.05, + /** + * Contract extras, net per full year at the contract gross (scaled with later pay): holiday + * pay paid in May and the 13th month in November, both accrued per month worked. The first + * ones are pro rata (13th month 3/12 in Nov 2026, holiday pay 8/12 in May 2027). The bonus + * is paid in August after the fiscal year, only while employed: once €850 (FY27), then €1,300. + */ + holidayNetYearCents: 195000, + thirteenthNetYearCents: 205000, + holidayMonthsAccrued: 1, + thirteenthMonthsAccrued: 2, + firstBonusCents: 85000, + firstBonusYear: 2027, + bonusNetYearCents: 130000, sideTaxRate: 0.4, bufferCents: 500000, /** Joint household contribution before the move, per month (Apr–Oct 2026 average). */ @@ -301,8 +310,8 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce let cash = inputs.spendableCents; let invested = inputs.investedCents; let gross = baseGross; - let accruedHoliday = baseGross * facts.holidayPayRate; - let accrued13 = baseGross / 12; + let accruedHoliday = (facts.holidayNetYearCents / 12) * facts.holidayMonthsAccrued; + let accrued13 = (facts.thirteenthNetYearCents / 12) * facts.thirteenthMonthsAccrued; let investedAtYearStart = invested; let contributedThisYear = 0; let box3Due = 0; @@ -357,25 +366,29 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce gross *= 1 + pct(params.raisePercent); let income = employed ? netFromGross(gross) : params.gapNetEuros * 100; if (employed) { - accruedHoliday += gross * facts.holidayPayRate; - accrued13 += gross / 12; + accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross); + accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross); } if (month === 5) { - income += accruedHoliday * 0.5; + income += accruedHoliday; accruedHoliday = 0; } - if (month === 12) { - income += accrued13 * 0.5; + if (month === 11) { + income += accrued13; accrued13 = 0; } // A job change pays out what is still accrued when the contract ends. if (params.gapMonths > 0 && first === addMonths(facts.contractEnd, 0)) { - income += (accruedHoliday + accrued13) * 0.5; + income += accruedHoliday + accrued13; accruedHoliday = 0; accrued13 = 0; } - if (month === 9 && first >= addMonths(facts.contractEnd, 0) && employed) - income += pct(params.bonusPercentOfTarget) * facts.bonusTarget * gross * 12.96 * 0.5; + if (month === 8 && year >= facts.firstBonusYear && employed) + income += + pct(params.bonusPercentOfTarget) * + (year === facts.firstBonusYear + ? facts.firstBonusCents + : facts.bonusNetYearCents * (gross / baseGross)); const ramp = Math.min(1, Math.max(0, (index - 3) / Math.max(1, totalMonths - 4))); const sideGross = params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp; income += sideGross * 100 * (1 - facts.sideTaxRate);