money scenarios: contract timing for salary extras (13th month in Nov, holiday pay in May, bonus in Aug, net estimates from the contract)
All checks were successful
Deploy production / deploy (push) Successful in 2m32s

This commit is contained in:
2026-10-24 14:46:32 +00:00
parent 8a29d5ad59
commit c827deb395
3 changed files with 42 additions and 19 deletions

View File

@@ -52,7 +52,7 @@
{ key: 'raisePercent', label: 'Yearly raise', unit: '%' }, { key: 'raisePercent', label: 'Yearly raise', unit: '%' },
{ key: 'raiseMonth', label: 'Raise month', unit: '' }, { key: 'raiseMonth', label: 'Raise month', unit: '' },
{ key: 'raiseFromYear', label: 'Raises from', 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: 'sideStartEuros', label: 'Side income now (gross)', unit: '€/mo' },
{ key: 'sideEndEuros', label: 'Side income at end (gross)', unit: '€/mo' } { key: 'sideEndEuros', label: 'Side income at end (gross)', unit: '€/mo' }
] ]

View File

@@ -77,8 +77,18 @@ describe('runScenario', () => {
const result = runScenario(inputs, flat); const result = runScenario(inputs, flat);
const november = result.points[1]; const november = result.points[1];
expect(november.cash).toBe(500000); expect(november.cash).toBe(500000);
// 300k salary + 10k benefit + 28k one-off − 3k gym − 90k pre-move household. // 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 - 3000 - 90000, 0); 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', () => { it('orders the default scenarios from pessimistic to optimistic', () => {
const [pessimistic, realistic, optimistic] = ( const [pessimistic, realistic, optimistic] = (

View File

@@ -33,7 +33,7 @@ export type ScenarioParams = {
raisePercent: number; raisePercent: number;
raiseMonth: number; raiseMonth: number;
raiseFromYear: 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; bonusPercentOfTarget: number;
/** Side income, gross euros per month, ramping linearly to the end value. */ /** Side income, gross euros per month, ramping linearly to the end value. */
sideStartEuros: number; sideStartEuros: number;
@@ -95,7 +95,7 @@ export const defaultScenarios: Record<ScenarioId, ScenarioParams> = {
raisePercent: 3.5, raisePercent: 3.5,
raiseMonth: 10, raiseMonth: 10,
raiseFromYear: 2028, raiseFromYear: 2028,
bonusPercentOfTarget: 50, bonusPercentOfTarget: 100,
sideStartEuros: 250, sideStartEuros: 250,
sideEndEuros: 500, sideEndEuros: 500,
spendGrowthPercent: 2.5, spendGrowthPercent: 2.5,
@@ -141,10 +141,19 @@ export const scenarioFacts = {
contractEnd: '2027-09-30', contractEnd: '2027-09-30',
/** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */ /** Extra net per extra gross euro: about 50% marginal tax after 2.65% pension. */
marginalNet: (1 - 0.0265) * 0.5, marginalNet: (1 - 0.0265) * 0.5,
/** Holiday pay (8%) and 13th month accrue monthly; paid half net in May and December. */ /**
holidayPayRate: 0.08, * Contract extras, net per full year at the contract gross (scaled with later pay): holiday
/** Target bonus as a share of yearly gross (12.96 months), paid half net in September. */ * pay paid in May and the 13th month in November, both accrued per month worked. The first
bonusTarget: 0.05, * 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, sideTaxRate: 0.4,
bufferCents: 500000, bufferCents: 500000,
/** Joint household contribution before the move, per month (Apr–Oct 2026 average). */ /** 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 cash = inputs.spendableCents;
let invested = inputs.investedCents; let invested = inputs.investedCents;
let gross = baseGross; let gross = baseGross;
let accruedHoliday = baseGross * facts.holidayPayRate; let accruedHoliday = (facts.holidayNetYearCents / 12) * facts.holidayMonthsAccrued;
let accrued13 = baseGross / 12; let accrued13 = (facts.thirteenthNetYearCents / 12) * facts.thirteenthMonthsAccrued;
let investedAtYearStart = invested; let investedAtYearStart = invested;
let contributedThisYear = 0; let contributedThisYear = 0;
let box3Due = 0; let box3Due = 0;
@@ -357,25 +366,29 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
gross *= 1 + pct(params.raisePercent); gross *= 1 + pct(params.raisePercent);
let income = employed ? netFromGross(gross) : params.gapNetEuros * 100; let income = employed ? netFromGross(gross) : params.gapNetEuros * 100;
if (employed) { if (employed) {
accruedHoliday += gross * facts.holidayPayRate; accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
accrued13 += gross / 12; accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
} }
if (month === 5) { if (month === 5) {
income += accruedHoliday * 0.5; income += accruedHoliday;
accruedHoliday = 0; accruedHoliday = 0;
} }
if (month === 12) { if (month === 11) {
income += accrued13 * 0.5; income += accrued13;
accrued13 = 0; accrued13 = 0;
} }
// A job change pays out what is still accrued when the contract ends. // A job change pays out what is still accrued when the contract ends.
if (params.gapMonths > 0 && first === addMonths(facts.contractEnd, 0)) { if (params.gapMonths > 0 && first === addMonths(facts.contractEnd, 0)) {
income += (accruedHoliday + accrued13) * 0.5; income += accruedHoliday + accrued13;
accruedHoliday = 0; accruedHoliday = 0;
accrued13 = 0; accrued13 = 0;
} }
if (month === 9 && first >= addMonths(facts.contractEnd, 0) && employed) if (month === 8 && year >= facts.firstBonusYear && employed)
income += pct(params.bonusPercentOfTarget) * facts.bonusTarget * gross * 12.96 * 0.5; 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 ramp = Math.min(1, Math.max(0, (index - 3) / Math.max(1, totalMonths - 4)));
const sideGross = params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp; const sideGross = params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
income += sideGross * 100 * (1 - facts.sideTaxRate); income += sideGross * 100 * (1 - facts.sideTaxRate);