diff --git a/findstr b/findstr
new file mode 100644
index 00000000..d3aed7fc
Binary files /dev/null and b/findstr differ
diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj
new file mode 100644
index 00000000..ba4dc97e
--- /dev/null
+++ b/src/Application/Application.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/BvgCalculator/Bvg.cs b/src/Application/Bvg/Bvg.cs
similarity index 89%
rename from src/BvgCalculator/Bvg.cs
rename to src/Application/Bvg/Bvg.cs
index cc254147..974bd877 100644
--- a/src/BvgCalculator/Bvg.cs
+++ b/src/Application/Bvg/Bvg.cs
@@ -1,9 +1,7 @@
-using System;
-using System.Collections.Generic;
-using PensionCoach.Tools.CommonTypes;
+using Domain.Enums;
using PensionCoach.Tools.CommonUtils;
-namespace PensionCoach.Tools.BvgCalculator
+namespace Application.Bvg
{
///
///
@@ -191,31 +189,32 @@ public static decimal GetUwsRateBvg(int year, Gender gender)
}
private static readonly Dictionary AhvMaxPensionDictionary = new()
- {
- {1969, null},
- {1970, 0M},
- {1984, 0M},
- {1985, 16560M},
- {1987, 17280M},
- {1989, 18000M},
- {1991, 19200M},
- {1992, 21600M},
- {1994, 22560M},
- {1996, 23280M},
- {1998, 23880M},
- {1999, 24120M},
- {2000, 24120M},
- {2002, 24720M},
- {2004, 25320M},
- {2007, 25800M},
- {2009, 26520M},
- {2011, 27360M},
- {2013, 27840M},
- {2015, 28080M},
- {2019, 28200M},
- {2021, 28440M},
- {9999, 28680M},
- };
+ {
+ {1969, null},
+ {1970, 0},
+ {1984, 0},
+ {1985, 16560},
+ {1987, 17280},
+ {1989, 18000},
+ {1991, 19200},
+ {1992, 21600},
+ {1994, 22560},
+ {1996, 23280},
+ {1998, 23880},
+ {1999, 24120},
+ {2000, 24120},
+ {2002, 24720},
+ {2004, 25320},
+ {2007, 25800},
+ {2009, 26520},
+ {2011, 27360},
+ {2013, 27840},
+ {2015, 28080},
+ {2019, 28200},
+ {2021, 28440},
+ {2023, 28680},
+ {9999, 29400},
+ };
private const decimal MinimalSalaryFactor = 0.125M;
private const decimal SalaryEntryThresholdFactor = 0.75M;
@@ -268,7 +267,8 @@ private static decimal GetUwsRateBvgFemale(int year)
{2014, 0.0150M},
{2016, 0.0175M},
{2017, 0.0125M},
- {9999, 0.0100M},
+ {2023, 0.0100M},
+ {9999, 0.0125M},
};
}
}
diff --git a/src/Application/Bvg/BvgCalculator.cs b/src/Application/Bvg/BvgCalculator.cs
new file mode 100644
index 00000000..334b7db2
--- /dev/null
+++ b/src/Application/Bvg/BvgCalculator.cs
@@ -0,0 +1,366 @@
+using Application.Bvg.Models;
+using Application.Extensions;
+using Domain.Enums;
+using Domain.Models.Bvg;
+using FluentValidation;
+using FluentValidation.Results;
+using LanguageExt;
+using PensionCoach.Tools.CommonUtils;
+
+namespace Application.Bvg;
+
+public class BvgCalculator(
+ BvgRetirementDateCalculator retirementDateCalculator,
+ IBvgRetirementCredits retirementCredits,
+ IValidator bvgPersonValidator)
+ : IBvgCalculator
+{
+ public Either Calculate(int calculationYear, decimal retirementCapitalEndOfYear, BvgPerson person)
+ {
+ Option validationResult = bvgPersonValidator.Validate(person);
+
+ return validationResult
+ .Where(r => !r.IsValid)
+ .Map>(r =>
+ {
+ var errorMessageLine = string.Join(";", r.Errors.Select(x => x.ErrorMessage));
+ return $"validation failed: {errorMessageLine}";
+ })
+ .IfNone(true)
+ .Bind(_ => CalculateInternal(calculationYear, retirementCapitalEndOfYear, person));
+ }
+
+ public Either InsuredSalary(int calculationYear, BvgPerson person)
+ {
+ BvgSalary salary = GetBvgSalary(calculationYear, person);
+
+ return salary.InsuredSalary;
+ }
+
+ public Either InsuredSalaries(int calculationYear, BvgPerson person)
+ {
+ DateTime currentDate = new(calculationYear, 1, 1);
+ DateTime retirementDate = retirementDateCalculator.DateOfRetirement(person.Gender, person.DateOfBirth);
+ DateTime technicalBirthdate = person.DateOfBirth.GetBirthdateTechnical();
+ TechnicalAge birthdateAsAge = TechnicalAge.From(technicalBirthdate.Year, technicalBirthdate.Month);
+
+ List salaries = [];
+ decimal salary = InsuredSalary(calculationYear, person).IfLeft(decimal.Zero);
+ while (currentDate <= retirementDate)
+ {
+ TechnicalAge age = TechnicalAge.From(currentDate.Year, currentDate.Month) - birthdateAsAge;
+ salaries.Add(new BvgTimeSeriesPoint(age, currentDate, salary));
+ currentDate = currentDate.AddYears(1);
+ }
+
+ return salaries.ToArray();
+ }
+
+ public Either RetirementCreditFactors(int calculationYear, BvgPerson person)
+ {
+ DateTime technicalBirthdate = person.DateOfBirth.GetBirthdateTechnical();
+ TechnicalAge birthdateAsAge = TechnicalAge.From(technicalBirthdate.Year, technicalBirthdate.Month);
+
+ List points = [];
+ foreach (var xBvg in Enumerable.Range(Bvg.EntryAgeBvg, Bvg.FinalAge - Bvg.EntryAgeBvg + 1))
+ {
+ DateTime calculationDate = new DateTime(person.DateOfBirth.Year + xBvg, 1, 1);
+ TechnicalAge age = TechnicalAge.From(calculationDate.Year, calculationDate.Month) - birthdateAsAge;
+ points.Add(new BvgTimeSeriesPoint(age, calculationDate, retirementCredits.GetRate(xBvg)));
+ }
+
+ return points.ToArray();
+ }
+
+ public Either RetirementCredits(int calculationYear, BvgPerson person)
+ {
+ return from factors in RetirementCreditFactors(calculationYear, person)
+ from salaries in InsuredSalaries(calculationYear, person)
+ select Combine(factors, salaries).ToArray();
+
+ IEnumerable Combine(BvgTimeSeriesPoint[] factors, BvgTimeSeriesPoint[] salaries)
+ {
+ return from f in factors
+ from s in salaries
+ where f.Date == s.Date
+ select f with { Value = f.Value * s.Value };
+ }
+ }
+
+ private Either CalculateInternal(int calculationYear, decimal retirementCapitalEndOfYear, BvgPerson person)
+ {
+ BvgSalary salary = GetBvgSalary(calculationYear, person);
+
+ decimal retirementCreditFactor = GetRetirementCreditFactor(person, calculationYear);
+ decimal retirementCredit = salary.InsuredSalary * retirementCreditFactor;
+
+ IReadOnlyCollection retirementCreditSequence =
+ GetRetirementCreditSequence(person, calculationYear, salary);
+
+ IReadOnlyCollection retirementCapitalSequence =
+ GetRetirementCapitalSequence(retirementCapitalEndOfYear, calculationYear, person, retirementCreditSequence);
+
+ decimal finalRetirementCapital =
+ GetFinalRetirementCapital(retirementCapitalSequence);
+
+ decimal finalRetirementCapitalWithoutInterest =
+ GetFinalRetirementCapitalWithoutInterest(retirementCapitalSequence);
+
+ DateTime dateOfProcess = new DateTime(calculationYear, 1, 1);
+
+ decimal retirementPension = GetRetirementPension(retirementCapitalEndOfYear, person, dateOfProcess, retirementCreditSequence);
+
+ // reset risk benefits to 0 if below salary threshold
+ decimal disabilityPension = 0;
+ decimal partnerPension = 0;
+ decimal childPension = 0;
+ decimal orphanPension = 0;
+
+ if (salary.EffectiveSalary > Bvg.GetEntranceThreshold(Bvg.GetPensionMaximum(calculationYear)))
+ {
+ disabilityPension = GetDisabilityPension(retirementCapitalSequence, person, dateOfProcess);
+ partnerPension = GetPartnerPension(retirementCapitalSequence, dateOfProcess, person);
+ childPension = GetChildPensionForDisabled(retirementCapitalSequence, person, dateOfProcess);
+ orphanPension = childPension;
+ }
+
+ Either result = new BvgCalculationResult
+ {
+ DateOfRetirement = GetRetirementDate(person.DateOfBirth, person.Gender),
+ EffectiveSalary = salary.EffectiveSalary,
+ InsuredSalary = salary.InsuredSalary,
+ RetirementCredit = retirementCredit,
+ RetirementCreditFactor = retirementCreditFactor,
+ RetirementPension = retirementPension,
+ RetirementCapitalEndOfYear = retirementCapitalEndOfYear,
+ FinalRetirementCapital = finalRetirementCapital,
+ FinalRetirementCapitalWithoutInterest = finalRetirementCapitalWithoutInterest,
+ DisabilityPension = disabilityPension,
+ PartnerPension = partnerPension,
+ OrphanPension = orphanPension,
+ ChildPensionForDisabled = childPension,
+ RetirementCreditSequence = retirementCreditSequence,
+ RetirementCapitalSequence = retirementCapitalSequence
+ };
+
+ return result;
+ }
+
+ public bool IsRetired(BvgPerson person, DateTime dateOfProcess)
+ {
+ DateTime retiredAt = GetRetirementDate(person.DateOfBirth, person.Gender);
+
+ return (new DateTime(retiredAt.Year, retiredAt.Month, 1).AddDays(-1) < dateOfProcess.Date);
+ }
+
+ public DateTime GetRetirementDate(DateTime dateOfBirth, Gender gender)
+ {
+ return retirementDateCalculator.DateOfRetirement(gender, dateOfBirth);
+ }
+
+ public (int Years, int Months) GetRetirementAge(Gender typeOfGender, DateTime birthdate)
+ {
+ return retirementDateCalculator.RetirementAge(typeOfGender, birthdate);
+ }
+
+ private BvgSalary GetBvgSalary(int calculationYear, BvgPerson person)
+ {
+ decimal workingAbilityDegree = decimal.One - person.DisabilityDegree;
+
+ BvgSalary salary = new BvgSalary();
+
+ bool isRetired = IsRetired(person, new DateTime(calculationYear));
+
+ if (isRetired && person.DisabilityDegree == decimal.One)
+ {
+ return salary;
+ }
+
+ salary.ReportedSalary = person.ReportedSalary;
+ salary.EffectiveSalary = person.ReportedSalary * workingAbilityDegree;
+ salary.InsuredSalary = isRetired ? 0M : GetInsuredSalary(person, calculationYear);
+
+ return salary;
+ }
+
+ private decimal GetRetirementPension(
+ decimal retirementEndOfYear,
+ BvgPerson personDetails,
+ DateTime dateOfProcess,
+ IReadOnlyCollection retirementCreditSequence)
+ {
+ IEnumerable retirementCapitalSequence =
+ GetRetirementCapitalSequence(retirementEndOfYear, dateOfProcess.Year, personDetails, retirementCreditSequence);
+
+ RetirementCapital latestElement = retirementCapitalSequence.First();
+
+ return MathUtils.Round((latestElement.Value) * Bvg.GetUwsRateBvg(dateOfProcess.Year, personDetails.Gender));
+ }
+
+ private decimal GetPartnerPension(
+ IReadOnlyCollection retirementCapitalSequence,
+ DateTime dateOfProcess,
+ BvgPerson personDetails)
+ {
+ decimal capital = GetFinalRetirementCapitalWithoutInterest(retirementCapitalSequence);
+
+ return MathUtils.Round(capital * Bvg.FactorPartnersPension *
+ Bvg.GetUwsRateBvg(dateOfProcess.Year, personDetails.Gender));
+ }
+
+ private decimal GetChildPensionForDisabled(
+ IReadOnlyCollection retirementCapitalSequence,
+ BvgPerson personDetails,
+ DateTime dateOfProcess)
+ {
+ decimal capital = GetFinalRetirementCapitalWithoutInterest(retirementCapitalSequence);
+
+ return MathUtils.Round(capital * Bvg.FactorChildPension *
+ Bvg.GetUwsRateBvg(dateOfProcess.Year, personDetails.Gender));
+ }
+
+ private decimal GetDisabilityPension(
+ IReadOnlyCollection retirementCapitalSequence, BvgPerson personDetails, DateTime dateOfProcess)
+ {
+ decimal capital = GetFinalRetirementCapitalWithoutInterest(retirementCapitalSequence);
+
+ return MathUtils.Round(capital * Bvg.GetUwsRateBvg(dateOfProcess.Year, personDetails.Gender));
+ }
+
+ private decimal GetFinalRetirementCapital(
+ IReadOnlyCollection retirementCapitalSequence)
+ {
+ RetirementCapital final = retirementCapitalSequence.First();
+
+ return MathUtils.Round(final.Value);
+ }
+
+ private decimal GetFinalRetirementCapitalWithoutInterest(IReadOnlyCollection retirementCapitalSequence)
+ {
+ RetirementCapital final = retirementCapitalSequence.First();
+
+ return MathUtils.Round(final.ValueWithoutInterest);
+ }
+
+ private IReadOnlyCollection GetRetirementCapitalSequence(
+ decimal retirementEndOfYear,
+ int calculationYear,
+ BvgPerson personDetails,
+ IReadOnlyCollection retirementCreditSequence)
+ {
+ // Date of retirement
+ DateTime dateOfRetirement = GetRetirementDate(personDetails.DateOfBirth, personDetails.Gender);
+
+ // Interest rates
+ decimal iBvg = Bvg.GetInterestRate(calculationYear);
+
+ // Retirement assets at end of insurance period Bvg portion
+ int age = calculationYear - personDetails.DateOfBirth.Year;
+ var retirementAgeBvg = GetRetirementAge(personDetails.Gender, personDetails.DateOfBirth);
+
+ return BvgCapitalCalculationHelper.GetRetirementCapitalSequence(calculationYear,
+ dateOfRetirement,
+ age,
+ retirementAgeBvg.Years,
+ iBvg,
+ retirementEndOfYear,
+ retirementCreditSequence);
+ }
+
+ private IReadOnlyCollection GetRetirementCreditSequence(
+ BvgPerson personDetails,
+ int calculationYear,
+ BvgSalary salaryDetails)
+ {
+ int xsBvg = GetRetirementAge(personDetails.Gender, personDetails.DateOfBirth).Years;
+ int xBvg = personDetails.DateOfBirth.GetBvgAge(calculationYear);
+
+ BvgRetirementCreditsTable bvgRetirementCreditTable = new BvgRetirementCreditsTable();
+
+ return Enumerable.Range(xBvg, xsBvg - xBvg + 1)
+ .Select(x =>
+ new RetirementCredit(bvgRetirementCreditTable.GetRate(x) * salaryDetails.InsuredSalary, x))
+ .ToList();
+ }
+
+ private decimal GetRetirementCreditFactor(BvgPerson person, int calculationYear)
+ {
+ int xBvg = calculationYear - person.DateOfBirth.Year;
+
+ return retirementCredits.GetRate(xBvg);
+ }
+
+ private static decimal GetInsuredSalary(BvgPerson person, int calculationYear)
+ {
+ const decimal fullEmployedDegree = 1.0M;
+
+ Option insuredSalary = 0M;
+
+ if (person.DisabilityDegree == fullEmployedDegree)
+ {
+ return insuredSalary.IfNone(0M);
+ }
+
+ if (person.DisabilityDegree > 0M)
+ {
+ insuredSalary = GetInsuredSalaryWhenDisabled();
+ }
+ else
+ {
+ insuredSalary = GetInsuredSalaryWhenNotDisabled();
+ }
+
+ return MathUtils.Round5(insuredSalary.IfNone(0M));
+
+ Option GetInsuredSalaryWhenNotDisabled()
+ {
+ decimal ahvMax = Bvg.GetPensionMaximum(calculationYear);
+
+ return Prelude.Some(person.ReportedSalary)
+
+ // check salary entrance level
+ .Where(v => v > Bvg.GetEntranceThreshold(ahvMax))
+
+ // restrict by BVG salary max
+ .Map(v => Math.Min(v, Bvg.GetMaximumSalary(ahvMax)))
+
+ // reduce by coordination deduction
+ .Map(v => v - GetCoordinationDeduction())
+
+ .Map(v => Math.Max(v, Bvg.GetMinimumSalary(ahvMax)))
+ .Map(v => Math.Round(v, 0, MidpointRounding.AwayFromZero));
+ }
+
+ Option GetInsuredSalaryWhenDisabled()
+ {
+ decimal minSalary = Bvg.GetMinimumSalary(Bvg.GetPensionMaximum(calculationYear));
+
+ Option disabilityDegree = person.DisabilityDegree;
+
+ return disabilityDegree
+ .Where(v => v is > 0 and < decimal.One)
+ .Map(v => fullEmployedDegree - v)
+
+ // scale salary up
+ .Map(v => person.ReportedSalary / v)
+
+ // check salary entrance level
+ .Where(v => v > Bvg.GetEntranceThreshold(Bvg.GetPensionMaximum(calculationYear)))
+
+ .Map(v => Math.Min(v, Bvg.GetMaximumSalary(Bvg.GetPensionMaximum(calculationYear))))
+
+ // reduce by coordination deduction
+ .Map(v => v - GetCoordinationDeduction())
+
+ // restrict by BVG salary max
+ .Map(v => v * (fullEmployedDegree - person.DisabilityDegree))
+ .Map(v => v < minSalary ? minSalary : v)
+ .Map(MathUtils.Round5);
+ }
+
+ decimal GetCoordinationDeduction()
+ {
+ return Bvg.GetCoordinationDeduction(Bvg.GetPensionMaximum(calculationYear));
+ }
+ }
+}
diff --git a/src/BvgCalculator/BvgCapitalCalculationHelper.cs b/src/Application/Bvg/BvgCapitalCalculationHelper.cs
similarity index 67%
rename from src/BvgCalculator/BvgCapitalCalculationHelper.cs
rename to src/Application/Bvg/BvgCapitalCalculationHelper.cs
index 28eb66f8..6c0bf1a5 100644
--- a/src/BvgCalculator/BvgCapitalCalculationHelper.cs
+++ b/src/Application/Bvg/BvgCapitalCalculationHelper.cs
@@ -1,11 +1,9 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using PensionCoach.Tools.BvgCalculator.Models;
+using Application.Extensions;
+using Domain.Models.Bvg;
using PensionCoach.Tools.CommonUtils;
using static LanguageExt.Prelude;
-namespace PensionCoach.Tools.BvgCalculator
+namespace Application.Bvg
{
///
///
@@ -15,36 +13,35 @@ public static class BvgCapitalCalculationHelper
///
/// Gets the retirement capital sequence.
///
- ///
+ ///
/// The date of retirement.
/// The age BVG.
/// The retirement age BVG.
/// The i BVG.
- /// The actuarial reserve accounting year.
+ /// The actuarial reserve accounting year.
/// The retirement credit sequence.
///
public static IReadOnlyCollection GetRetirementCapitalSequence(
- DateTime processDate,
+ int calculationYear,
DateTime retirementDate,
int ageBvg,
int retirementAgeBvg,
decimal iBvg,
- PredecessorRetirementCapital predecessorCapital,
+ decimal retirementCapitalEndOfYear,
IReadOnlyCollection retirementCreditSequence)
{
// Begin of financial year = January 1 fo the financial year
- DateTime beginOfFinancialYear = new DateTime(processDate.Year, 1, 1);
+ DateTime beginOfFinancialYear = new DateTime(calculationYear, 1, 1);
DateTime endOfFinancialYear = beginOfFinancialYear.AddYears(1);
if (retirementDate <= endOfFinancialYear)
{
- decimal aghBoYProRata = predecessorCapital.BeginOfYearAmount;
- decimal aghEoYProRata = predecessorCapital.EndOfYearAmount;
+ decimal aghEoYProRata = retirementCapitalEndOfYear;
RetirementCapital aghProRataBoY =
new RetirementCapital(beginOfFinancialYear,
- aghBoYProRata,
- aghBoYProRata);
+ decimal.Zero,
+ decimal.Zero);
RetirementCapital aghProRataEoY =
new RetirementCapital(endOfFinancialYear,
aghEoYProRata,
@@ -59,27 +56,21 @@ public static IReadOnlyCollection GetRetirementCapitalSequenc
return List(aghProRataEndOfPeriod);
}
+ RetirementCapital retirementCapitalItem = new (endOfFinancialYear, retirementCapitalEndOfYear, retirementCapitalEndOfYear);
- RetirementCapital retirementCapitalEndOfYear =
- new RetirementCapital(
- endOfFinancialYear,
- predecessorCapital.EndOfYearAmount,
- predecessorCapital.EndOfYearAmount)
- .Round();
-
- var retirementAssets = List(retirementCapitalEndOfYear);
+ var retirementAssets = List(retirementCapitalItem);
retirementAssets = retirementAssets.AddRange(
- GetProjection(
- ageBvg + 1,
- retirementDate,
- endOfFinancialYear,
- retirementCapitalEndOfYear,
- iBvg,
- retirementAgeBvg,
- retirementCreditSequence));
-
- return retirementAssets.Reverse();
+ GetProjection(
+ ageBvg + 1,
+ retirementDate,
+ endOfFinancialYear,
+ retirementCapitalItem,
+ iBvg,
+ retirementAgeBvg,
+ retirementCreditSequence));
+
+ return retirementAssets.Reverse();
}
///
@@ -112,29 +103,6 @@ public static (decimal ValueD1, decimal ValueD2) InterpolateInterval(
return (beginOfPeriodValue, endOfPeriodValue);
}
- ///
- /// Gets the retirement credit sequence.
- ///
- /// The person details.
- /// The process date.
- ///
- /// Process date {processDate} after date of retirement {dateOfRetirement}
- internal static IReadOnlyCollection GetRetirementCreditSequence(
- BvgPerson personDetails,
- DateTime processDate,
- BvgSalary salaryDetails)
- {
- int xsBvg = BvgCalculator.GetRetirementAge(personDetails.Gender);
- int xBvg = personDetails.DateOfBirth.GetBvgAge(processDate.Year);
-
- BvgRetirementCreditsTable bvgRetirementCreditTable = new BvgRetirementCreditsTable();
-
- return Enumerable.Range(xBvg, xsBvg - xBvg + 1)
- .Select(x =>
- new RetirementCredit(bvgRetirementCreditTable.GetRate(x) * salaryDetails.InsuredSalary, x))
- .ToList();
- }
-
private static List GetProjection(
int age,
DateTime dateOfRetirement,
@@ -182,7 +150,7 @@ private static RetirementCapital CalculateNewProjectedRetirementCapital(
// Difference in without interest from without interest by plan:
// by plan takes the rounded retirement credits (to 0.1CHF) while
// the other calculates with effective credits
- decimal x1Capital = xCapital * (1M + iProjection) + retirementCredit.AmountRounded10;
+ decimal x1Capital = xCapital * (1M + iProjection) + MathUtils.Round10(retirementCredit.AmountRaw);
decimal x1CapitalWoI = xCapitalWoI + retirementCredit.AmountRaw;
return new RetirementCapital(
diff --git a/src/BvgCalculator/BvgRetirementCreditsTable.cs b/src/Application/Bvg/BvgRetirementCreditsTable.cs
similarity index 81%
rename from src/BvgCalculator/BvgRetirementCreditsTable.cs
rename to src/Application/Bvg/BvgRetirementCreditsTable.cs
index 7d5dff90..c221305b 100644
--- a/src/BvgCalculator/BvgRetirementCreditsTable.cs
+++ b/src/Application/Bvg/BvgRetirementCreditsTable.cs
@@ -1,7 +1,6 @@
-using System.Collections.Generic;
-using static PensionCoach.Tools.CommonUtils.LevelValueDictionaryExtensions;
+using static PensionCoach.Tools.CommonUtils.LevelValueDictionaryExtensions;
-namespace PensionCoach.Tools.BvgCalculator
+namespace Application.Bvg
{
public class BvgRetirementCreditsTable : IBvgRetirementCredits
{
diff --git a/src/Application/Bvg/BvgRetirementDateCalculator.cs b/src/Application/Bvg/BvgRetirementDateCalculator.cs
new file mode 100644
index 00000000..f78f72f9
--- /dev/null
+++ b/src/Application/Bvg/BvgRetirementDateCalculator.cs
@@ -0,0 +1,67 @@
+using System.ComponentModel;
+using Application.Extensions;
+using Domain.Enums;
+
+namespace Application.Bvg;
+
+public class BvgRetirementDateCalculator
+{
+ public DateTime DateOfRetirement(Gender gender, DateTime dateOfBirth)
+ {
+ (int Year, int Months) finaleAge = RetirementAge(gender, dateOfBirth);
+
+ return DateOfRetirementByAge(dateOfBirth, finaleAge);
+ }
+
+ public DateTime DateOfRetirementByAge(DateTime dateOfBirth, (int Year, int Months) finaleAge)
+ {
+ return dateOfBirth
+ .GetBirthdateTechnical()
+ .AddYears(finaleAge.Year)
+ .AddMonths(finaleAge.Months);
+ }
+
+ public (int Years, int Months) RetirementAge(Gender gender, DateTime dateOfBirth)
+ {
+ return RetirementAgeInternal(gender, dateOfBirth);
+ }
+
+ private (int Year, int Months) RetirementAgeInternal(Gender gender, DateTime dateOfBirth)
+ {
+ const int lastGenerationBeforeTransition = 1960;
+ const int firstTransitionGeneration = 1961;
+ const int secondTransitionGeneration = 1962;
+ const int thirdTransitionGeneration = 1963;
+ const int additionalMonthsFirstGeneration = 3;
+ const int additionalMonthsSecondGeneration = 6;
+ const int additionalMonthsThirdGeneration = 9;
+ const int retirementAgeFemaleBeforeTransition = 64;
+
+ if (gender == Gender.Undefined)
+ {
+ throw new InvalidEnumArgumentException(nameof(Gender));
+ }
+
+ int yearOfBirth = dateOfBirth.Year;
+
+ (int retirementAgeMen, int retirementAgeWomen) = (65, 65);
+
+ if (gender == Gender.Female)
+ {
+ if (yearOfBirth > lastGenerationBeforeTransition)
+ {
+ return yearOfBirth switch
+ {
+ firstTransitionGeneration => (retirementAgeFemaleBeforeTransition, additionalMonthsFirstGeneration),
+ secondTransitionGeneration => (retirementAgeFemaleBeforeTransition, additionalMonthsSecondGeneration),
+ thirdTransitionGeneration => (retirementAgeFemaleBeforeTransition, additionalMonthsThirdGeneration),
+ _ => (retirementAgeWomen, 0)
+ };
+ }
+
+ return (retirementAgeFemaleBeforeTransition, 0);
+ }
+
+ return (retirementAgeMen, 0);
+ }
+}
diff --git a/src/Application/Bvg/BvgRevisionCalculator.cs b/src/Application/Bvg/BvgRevisionCalculator.cs
new file mode 100644
index 00000000..1fabad17
--- /dev/null
+++ b/src/Application/Bvg/BvgRevisionCalculator.cs
@@ -0,0 +1,325 @@
+using Application.Bvg.Models;
+using Application.Extensions;
+using Domain.Enums;
+using Domain.Models.Bvg;
+using FluentValidation;
+using FluentValidation.Results;
+using LanguageExt;
+using PensionCoach.Tools.CommonUtils;
+
+namespace Application.Bvg;
+
+public class BvgRevisionCalculator(
+ BvgCalculator bvgCalculator,
+ BvgRetirementDateCalculator retirementDateCalculator,
+ IBvgRetirementCredits retirementCredits,
+ ISavingsProcessProjectionCalculator projectionCalculator,
+ IValidator bvgPersonValidator)
+ : IBvgCalculator
+{
+ private const decimal PensionConversionRate = 0.06M;
+ private const decimal SalaryThresholdFactor = 0.675M;
+ private const decimal CoordinationDeductionFactor = 0.2M;
+ private static readonly DateTime StartOfBvgRevision = new(2026, 1, 1);
+
+ public Either Calculate(int calculationYear, decimal retirementCapitalEndOfYear, BvgPerson person)
+ {
+ Option validationResult = bvgPersonValidator.Validate(person);
+
+ return validationResult
+ .Where(r => !r.IsValid)
+ .Map>(r =>
+ {
+ var errorMessageLine = string.Join(";", r.Errors.Select(x => x.ErrorMessage));
+ return $"validation failed: {errorMessageLine}";
+ })
+ .IfNone(true)
+ .Bind(_ => CalculateInternal(calculationYear, retirementCapitalEndOfYear, person));
+ }
+
+ public Either InsuredSalary(int calculationYear, BvgPerson person)
+ {
+ if(calculationYear < StartOfBvgRevision.Year)
+ {
+ return bvgCalculator.InsuredSalary(calculationYear, person);
+ }
+
+ bool isRetired = IsRetired(person, new DateTime(calculationYear, 1, 1));
+
+ if (isRetired && person.DisabilityDegree == decimal.One)
+ {
+ return decimal.Zero;
+ }
+
+ return UnconditionedInsuredSalary(calculationYear, person.ReportedSalary, decimal.One - person.DisabilityDegree);
+ }
+
+ public Either InsuredSalaries(int calculationYear, BvgPerson person)
+ {
+ DateTime technicalBirthdate = person.DateOfBirth.GetBirthdateTechnical();
+ TechnicalAge birthdateAsAge = (technicalBirthdate.Year, technicalBirthdate.Month);
+ DateTime dateOfFinalAge = GetRetirementDate(person.DateOfBirth, person.Gender);
+
+ List salaries = [];
+ for (DateTime currentDate = new DateTime(calculationYear, 1, 1); currentDate <= dateOfFinalAge; currentDate = currentDate.AddMonths(1))
+ {
+ decimal salary = InsuredSalary(currentDate.Year, person).IfLeft(decimal.Zero);
+
+ TechnicalAge age = TechnicalAge.From(currentDate.Year, currentDate.Month) - birthdateAsAge;
+
+ salaries.Add(new BvgTimeSeriesPoint(age, currentDate, salary));
+ }
+
+ return salaries.ToArray();
+ }
+
+ public Either RetirementCreditFactors(int calculationYear, BvgPerson person)
+ {
+ DateTime technicalBirthdate = person.DateOfBirth.GetBirthdateTechnical();
+ TechnicalAge birthdateAsAge = TechnicalAge.From(technicalBirthdate.Year, technicalBirthdate.Month);
+ DateTime dateOfFinalAge = GetRetirementDate(person.DateOfBirth, person.Gender);
+
+ List points = [];
+ for (DateTime currentDate = new DateTime(calculationYear, 1, 1); currentDate <= dateOfFinalAge; currentDate = currentDate.AddMonths(1))
+ {
+ int xBvg = currentDate.Year - person.DateOfBirth.Year;
+ decimal factor = currentDate < StartOfBvgRevision
+ ? retirementCredits.GetRate(xBvg)
+ : RetirementCreditFactor(xBvg);
+
+ TechnicalAge age = TechnicalAge.From(currentDate.Year, currentDate.Month) - birthdateAsAge;
+
+ points.Add(new BvgTimeSeriesPoint(age, currentDate, factor));
+ }
+
+ return points.ToArray();
+ }
+
+ public Either RetirementCredits(int calculationYear, BvgPerson person)
+ {
+ return from factors in RetirementCreditFactors(calculationYear, person)
+ from salaries in InsuredSalaries(calculationYear, person)
+ select Combine(factors, salaries).ToArray();
+
+ IEnumerable Combine(BvgTimeSeriesPoint[] factors, BvgTimeSeriesPoint[] salaries)
+ {
+ return from f in factors
+ from s in salaries
+ where f.Date == s.Date
+ select f with {Value = f.Value * s.Value};
+ }
+ }
+
+ private Either CalculateInternal(int calculationYear, decimal retirementCapitalEndOfYear, BvgPerson person)
+ {
+ DateTime retirementDate = GetRetirementDate(person.DateOfBirth, person.Gender);
+
+ decimal insuredSalary = InsuredSalary(calculationYear, person).IfLeft(() => decimal.Zero);
+
+ decimal retirementCreditFactor = GetRetirementCreditFactor(person, calculationYear);
+
+ BvgTimeSeriesPoint[] retirementCreditSequence = RetirementCredits(calculationYear, person).IfLeft(() => []);
+
+ IReadOnlyCollection retirementCapitalSequence =
+ GetRetirementCapitalSequence(retirementCapitalEndOfYear, calculationYear, person, retirementCreditSequence);
+
+ DateTime processingDate = new(calculationYear, 1, 1);
+
+ decimal retirementCredit = retirementCreditSequence.SingleOrDefault(item => item.Date == processingDate) switch
+ {
+ null => decimal.Zero,
+ { } item => item.Value
+ };
+
+ RetirementCapital finalRetirementCapital = retirementCapitalSequence.SingleOrDefault(item => item.Date == retirementDate);
+
+ decimal actualRetirementCapitalEndOfYear = ActualRetirementCapitalEndOfYear(calculationYear, retirementCapitalSequence, retirementDate);
+
+ decimal finalRetirementCapitalWithInterest = finalRetirementCapital switch
+ {
+ null => decimal.Zero,
+ not null => finalRetirementCapital.Value
+ };
+
+ decimal finalRetirementCapitalWithoutInterest = finalRetirementCapital switch
+ {
+ null => decimal.Zero,
+ not null => finalRetirementCapital.ValueWithoutInterest
+ };
+
+ decimal retirementPension = RetirementPension(finalRetirementCapitalWithInterest, retirementDate, CurrentBvgCalculatorFunc(retirementCredit));
+
+ // reset risk benefits to 0 if below salary threshold
+ decimal disabilityPension = DisabilityPension(finalRetirementCapitalWithoutInterest, retirementDate, CurrentBvgCalculatorFunc(retirementCredit));
+ decimal partnerPension = disabilityPension * Bvg.FactorPartnersPension;
+ decimal childPension = disabilityPension * Bvg.FactorChildPension;
+ decimal orphanPension = childPension;
+
+ Either result = new BvgCalculationResult
+ {
+ DateOfRetirement = GetRetirementDate(person.DateOfBirth, person.Gender),
+ EffectiveSalary = person.ReportedSalary,
+ InsuredSalary = insuredSalary,
+ RetirementCredit = retirementCredit,
+ RetirementCreditFactor = retirementCreditFactor,
+ RetirementPension = retirementPension,
+ RetirementCapitalEndOfYear = actualRetirementCapitalEndOfYear,
+ FinalRetirementCapital = finalRetirementCapitalWithInterest,
+ FinalRetirementCapitalWithoutInterest = finalRetirementCapitalWithoutInterest,
+ DisabilityPension = disabilityPension,
+ PartnerPension = partnerPension,
+ OrphanPension = orphanPension,
+ ChildPensionForDisabled = childPension,
+ RetirementCreditSequence = retirementCreditSequence.Select(item => new RetirementCredit(item.Value, item.Age.Years)).ToList(),
+ RetirementCapitalSequence = retirementCapitalSequence
+ };
+
+ return result;
+
+ Func CurrentBvgCalculatorFunc(decimal currentRetirementCapitalEndOfYear)
+ {
+ return () => bvgCalculator.Calculate(calculationYear, currentRetirementCapitalEndOfYear, person).IfLeft(() => null);
+ }
+ }
+
+ private static decimal ActualRetirementCapitalEndOfYear(int calculationYear,
+ IReadOnlyCollection retirementCapitalSequence, DateTime retirementDate)
+ {
+ DateTime followingYearDate = new DateTime(calculationYear + 1, 1, 1);
+
+ RetirementCapital match = retirementCapitalSequence.SingleOrDefault(item => item.Date == followingYearDate);
+
+ if (match is null)
+ {
+ if (followingYearDate > retirementDate)
+ {
+ return retirementCapitalSequence
+ .Where(item => item.Date.Year == calculationYear)
+ .MaxBy(item => item.Date)
+ .Value;
+ }
+ return decimal.Zero;
+ }
+
+ return match.Value;
+ }
+
+ private static decimal DisabilityPension(decimal finalRetirementCapitalWithoutInterest, DateTime retirementDate, Func currentBvgCalculatorFunc)
+ {
+ if (retirementDate < StartOfBvgRevision)
+ {
+ return currentBvgCalculatorFunc().DisabilityPension;
+ }
+
+ return finalRetirementCapitalWithoutInterest * PensionConversionRate;
+ }
+
+ public bool IsRetired(BvgPerson person, DateTime dateOfProcess)
+ {
+ DateTime retiredAt = GetRetirementDate(person.DateOfBirth, person.Gender);
+
+ return retiredAt.AddDays(-1) < dateOfProcess.Date;
+ }
+
+ public DateTime GetRetirementDate(DateTime dateOfBirth, Gender gender)
+ {
+ return retirementDateCalculator.DateOfRetirement(gender, dateOfBirth);
+ }
+
+ public TechnicalAge GetRetirementAge(Gender typeOfGender, DateTime birthdate)
+ {
+ (int years, int months) = retirementDateCalculator.RetirementAge(typeOfGender, birthdate);
+
+ return TechnicalAge.From(years, months);
+ }
+
+ private decimal RetirementPension(decimal finalRetirementCapital, DateTime retirementDate, Func currentBvgCalculatorFunc)
+ {
+ if(retirementDate < StartOfBvgRevision)
+ {
+ return currentBvgCalculatorFunc().RetirementPension;
+ }
+
+ return finalRetirementCapital * PensionConversionRate;
+ }
+
+ private IReadOnlyCollection GetRetirementCapitalSequence(
+ decimal retirementCapitalEndOfYear,
+ int calculationYear,
+ BvgPerson personDetails,
+ BvgTimeSeriesPoint[] retirementCreditSequence)
+ {
+ // Date of retirement
+ DateTime dateOfRetirement = GetRetirementDate(personDetails.DateOfBirth, personDetails.Gender);
+
+ // Interest rates
+ decimal iBvg = Bvg.GetInterestRate(calculationYear);
+
+ // Retirement assets at end of insurance period Bvg portion
+ TechnicalAge retirementAgeBvg = GetRetirementAge(personDetails.Gender, personDetails.DateOfBirth);
+
+ Func retirementCreditGetter = age =>
+ {
+ return retirementCreditSequence.SingleOrDefault(p => p.Age == age)?.Value ?? decimal.Zero;
+ };
+
+ if (IsRetired(personDetails, new(calculationYear+1, 1, 1)))
+ {
+ return [new RetirementCapital(dateOfRetirement, retirementCapitalEndOfYear, retirementCapitalEndOfYear)];
+ }
+
+ return projectionCalculator.ProjectionTable(
+ iBvg,
+ dateOfRetirement,
+ dateOfRetirement,
+ retirementAgeBvg,
+ retirementAgeBvg,
+ calculationYear+1,
+ retirementCapitalEndOfYear,
+ retirementCreditGetter)
+ .Select(item => new RetirementCapital(item.DateOfCalculation, item.RetirementCapital, item.RetirementCapitalWithoutInterest))
+ .ToArray();
+ }
+
+
+ private decimal UnconditionedInsuredSalary(int calculationYear, decimal reportedSalary, decimal quota)
+ {
+ decimal ahvMax = Bvg.GetPensionMaximum(calculationYear);
+ return Prelude.Some(MathUtils.Round(reportedSalary))
+
+ // scale salary up
+ .Map(salary => salary / quota)
+
+ // check salary entrance level
+ .Where(v => v > ahvMax * SalaryThresholdFactor)
+
+ .Map(v => Math.Min(v, Bvg.GetMaximalInsurableSalary(ahvMax)))
+ .Map(v => Math.Min(v, Bvg.GetMaximumSalary(ahvMax)))
+
+ // reduce by coordination deduction
+ .Map(v => v * (decimal.One - CoordinationDeductionFactor))
+
+ // restrict by BVG salary max
+ .Map(v => v * quota)
+ .Map(MathUtils.Round5)
+ .IfNone(decimal.Zero);
+ }
+
+ private decimal GetRetirementCreditFactor(BvgPerson person, int calculationYear)
+ {
+ int xBvg = calculationYear - person.DateOfBirth.Year;
+
+ return retirementCredits.GetRate(xBvg);
+ }
+
+ private static decimal RetirementCreditFactor(int xBvg)
+ {
+ return xBvg switch
+ {
+ > 24 and <= 44 => 0.09M,
+ > 44 and <= 65 => 0.14M,
+ _ => 0
+ };
+ }
+
+}
diff --git a/src/Application/Bvg/BvgRevisionPensionSupplementCalculator.cs b/src/Application/Bvg/BvgRevisionPensionSupplementCalculator.cs
new file mode 100644
index 00000000..4d15db80
--- /dev/null
+++ b/src/Application/Bvg/BvgRevisionPensionSupplementCalculator.cs
@@ -0,0 +1,33 @@
+namespace Application.Bvg;
+
+public class BvgRevisionPensionSupplementCalculator : IPensionSupplementCalculator
+{
+ public decimal CalculatePensionSupplement(
+ DateTime dateOfBirth,
+ decimal finalRetirementCapital)
+ {
+ const decimal lBound = 220500;
+ const decimal uBound = lBound * 2M;
+
+ const decimal pensionLowerBound = 1200;
+ const decimal pensionMediumBound = pensionLowerBound * 1.5M;
+ const decimal pensionUpperBound = pensionLowerBound * 2M;
+
+ decimal s = decimal.One - (finalRetirementCapital - lBound) / (uBound - lBound);
+
+ decimal pensionIncrease = (dateOfBirth.Year, finalRetirementCapital) switch
+ {
+ (_, > uBound) => decimal.Zero,
+ ( < 1961, _) => decimal.Zero,
+ ( >= 1961 and <= 1965, <= lBound) => pensionUpperBound,
+ ( <= 1970, <= lBound) => pensionMediumBound,
+ ( <= 1975, <= lBound) => pensionLowerBound,
+ ( >= 1961 and <= 1965, < uBound) => pensionUpperBound * s,
+ ( <= 1970, < uBound) => pensionMediumBound * s,
+ ( <= 1975, < uBound) => pensionLowerBound * s,
+ _ => decimal.Zero
+ };
+
+ return pensionIncrease;
+ }
+}
diff --git a/src/Application/Bvg/IBvgCalculator.cs b/src/Application/Bvg/IBvgCalculator.cs
new file mode 100644
index 00000000..f7aeb13d
--- /dev/null
+++ b/src/Application/Bvg/IBvgCalculator.cs
@@ -0,0 +1,18 @@
+using Application.Bvg.Models;
+using Domain.Models.Bvg;
+using LanguageExt;
+
+namespace Application.Bvg;
+
+public interface IBvgCalculator
+{
+ Either Calculate(int calculationYear, decimal retirementCapitalEndOfYear, BvgPerson person);
+
+ Either InsuredSalary(int calculationYear, BvgPerson person);
+
+ Either InsuredSalaries(int calculationYear, BvgPerson person);
+
+ Either RetirementCreditFactors(int calculationYear, BvgPerson person);
+
+ Either RetirementCredits(int calculationYear, BvgPerson person);
+}
diff --git a/src/Application/Bvg/IBvgRetirementCreditsTable.cs b/src/Application/Bvg/IBvgRetirementCreditsTable.cs
new file mode 100644
index 00000000..02445853
--- /dev/null
+++ b/src/Application/Bvg/IBvgRetirementCreditsTable.cs
@@ -0,0 +1,8 @@
+namespace Application.Bvg;
+
+public interface IBvgRetirementCredits
+{
+ decimal GetRateInPercentage(int bvgAge);
+
+ decimal GetRate(int bvgAge);
+}
diff --git a/src/Application/Bvg/IPensionSupplementCalculator.cs b/src/Application/Bvg/IPensionSupplementCalculator.cs
new file mode 100644
index 00000000..11e476b7
--- /dev/null
+++ b/src/Application/Bvg/IPensionSupplementCalculator.cs
@@ -0,0 +1,6 @@
+namespace Application.Bvg;
+
+public interface IPensionSupplementCalculator
+{
+ decimal CalculatePensionSupplement(DateTime dateOfBirth, decimal finalRetirementCapital);
+}
diff --git a/src/Application/Bvg/ISavingsProcessProjectionCalculator.cs b/src/Application/Bvg/ISavingsProcessProjectionCalculator.cs
new file mode 100644
index 00000000..704e5558
--- /dev/null
+++ b/src/Application/Bvg/ISavingsProcessProjectionCalculator.cs
@@ -0,0 +1,17 @@
+using Application.Bvg.Models;
+using Domain.Models.Bvg;
+
+namespace Application.Bvg;
+
+public interface ISavingsProcessProjectionCalculator
+{
+ RetirementSavingsProcessResult[] ProjectionTable(
+ decimal projectionInterestRate,
+ DateTime dateOfRetirement,
+ DateTime dateOfEndOfSavings,
+ TechnicalAge retirementAge,
+ TechnicalAge finalAge,
+ int yearOfBeginProjection,
+ decimal beginOfRetirementCapital,
+ Func retirementCreditGetter);
+}
diff --git a/src/Application/Bvg/Models/BvgDate.cs b/src/Application/Bvg/Models/BvgDate.cs
new file mode 100644
index 00000000..b08b2264
--- /dev/null
+++ b/src/Application/Bvg/Models/BvgDate.cs
@@ -0,0 +1,29 @@
+namespace Application.Bvg.Models;
+
+///
+/// Represents a date in the BVG context able to distinguish begin and end of day date (time 00:00 vs 24:00).
+/// eg. 17.09.2021 00:00 vs 17.09.2021 24:00 but one is the begin and the other the end of the day.
+///
+///
+///
+public readonly record struct BvgDate(DateTime DateTime, bool IsEndOfDay)
+{
+ // construct a BvgDate from a DateTime
+ public BvgDate(DateOnly date, bool isEndOfDay=false) : this(date.ToDateTime(TimeOnly.MinValue), isEndOfDay)
+ {}
+
+ public static bool operator <(BvgDate left, BvgDate right) => left.DateTime < right.DateTime || (left.DateTime == right.DateTime && !left.IsEndOfDay && right.IsEndOfDay);
+
+ public static bool operator >(BvgDate left, BvgDate right) => left.DateTime > right.DateTime || (left.DateTime == right.DateTime && left.IsEndOfDay && !right.IsEndOfDay);
+
+ public static bool operator <=(BvgDate left, BvgDate right) => !(left > right);
+
+ public static bool operator >=(BvgDate left, BvgDate right) => !(left < right);
+
+ public static implicit operator BvgDate(DateTime dateTime)
+ {
+ return new BvgDate(dateTime, false);
+ }
+
+ public DateTime ToDateTime() => IsEndOfDay ? DateTime.AddDays(1) : DateTime;
+}
diff --git a/src/BvgCalculator/Models/BvgSalary.cs b/src/Application/Bvg/Models/BvgSalary.cs
similarity index 80%
rename from src/BvgCalculator/Models/BvgSalary.cs
rename to src/Application/Bvg/Models/BvgSalary.cs
index 467e9184..4331973b 100644
--- a/src/BvgCalculator/Models/BvgSalary.cs
+++ b/src/Application/Bvg/Models/BvgSalary.cs
@@ -1,4 +1,4 @@
-namespace PensionCoach.Tools.BvgCalculator.Models
+namespace Application.Bvg.Models
{
internal class BvgSalary
{
@@ -7,4 +7,4 @@ internal class BvgSalary
public decimal InsuredSalary { get; set; }
public decimal CoordinationDeduction { get; set; }
}
-}
\ No newline at end of file
+}
diff --git a/src/Application/Bvg/Models/BvgTimeSeriesPoint.cs b/src/Application/Bvg/Models/BvgTimeSeriesPoint.cs
new file mode 100644
index 00000000..af01ab31
--- /dev/null
+++ b/src/Application/Bvg/Models/BvgTimeSeriesPoint.cs
@@ -0,0 +1,5 @@
+using Domain.Models.Bvg;
+
+namespace Application.Bvg.Models;
+
+public record BvgTimeSeriesPoint(TechnicalAge Age, DateTime Date, decimal Value);
diff --git a/src/Application/Bvg/Models/RetirementSavingsProcessResult.cs b/src/Application/Bvg/Models/RetirementSavingsProcessResult.cs
new file mode 100644
index 00000000..3bed8c34
--- /dev/null
+++ b/src/Application/Bvg/Models/RetirementSavingsProcessResult.cs
@@ -0,0 +1,17 @@
+using Domain.Models.Bvg;
+
+namespace Application.Bvg.Models;
+
+public record RetirementSavingsProcessResult(
+ DateTime DateOfCalculation,
+ int BvgAge,
+ TechnicalAge TechnicalAge,
+ decimal ProRatedFactor,
+ decimal GrossInterestRate,
+ decimal RetirementCredit,
+ decimal RetirementCapitalWithoutInterest,
+ decimal RetirementCapital,
+ bool IsRetirementDate,
+ bool IsEndOfSavings,
+ bool IsFullYear,
+ bool IsFullAge);
diff --git a/src/Application/Bvg/SingleSavingsProcessProjectionCalculator.cs b/src/Application/Bvg/SingleSavingsProcessProjectionCalculator.cs
new file mode 100644
index 00000000..ef46077b
--- /dev/null
+++ b/src/Application/Bvg/SingleSavingsProcessProjectionCalculator.cs
@@ -0,0 +1,152 @@
+using Domain.Models.Bvg;
+using Application.Bvg.Models;
+
+namespace Application.Bvg;
+
+public class SingleSavingsProcessProjectionCalculator : ISavingsProcessProjectionCalculator
+{
+ public RetirementSavingsProcessResult[] ProjectionTable(
+ decimal projectionInterestRate,
+ DateTime dateOfRetirement,
+ DateTime dateOfEndOfSavings,
+ TechnicalAge retirementAge,
+ TechnicalAge finalAge,
+ int yearOfBeginProjection,
+ decimal beginOfRetirementCapital,
+ Func retirementCreditGetter)
+ {
+ // Values are only projected to final age
+ // Note:
+ // person might retired in between, or even before the
+ // begin of the projection period.
+ DateTime dateOfTechnicalBirth = dateOfRetirement
+ .AddMonths(-retirementAge.Months)
+ .AddYears(-retirementAge.Years);
+
+ DateTime dateOfFinalAge = dateOfTechnicalBirth
+ .AddMonths(finalAge.Months)
+ .AddYears(finalAge.Years);
+
+ DateTime startingDate = new(yearOfBeginProjection, 1, 1);
+
+ if (startingDate > dateOfFinalAge)
+ {
+ return [];
+ }
+
+ List results = [];
+
+ // AGH at begin
+ decimal aghoz = beginOfRetirementCapital;
+ decimal aghmz = beginOfRetirementCapital;
+
+ TechnicalAge birthdateAsAge = TechnicalAge.From(dateOfTechnicalBirth.Year, dateOfTechnicalBirth.Month);
+ TechnicalAge startingAge = TechnicalAge.From(startingDate.Year, startingDate.Month) - birthdateAsAge;
+
+ decimal agsj = retirementCreditGetter(startingAge);
+
+ RetirementSavingsProcessResult lastResult = new(
+ DateOfCalculation: startingDate,
+ BvgAge: startingDate.Year - dateOfTechnicalBirth.Year,
+ TechnicalAge: startingAge,
+ ProRatedFactor: decimal.One,
+ GrossInterestRate: projectionInterestRate,
+ RetirementCredit: agsj,
+ RetirementCapitalWithoutInterest: beginOfRetirementCapital,
+ RetirementCapital: beginOfRetirementCapital,
+ dateOfRetirement == startingDate,
+ false,
+ IsFullYear: true,
+ startingAge.Months == 0);
+
+ results.Add(lastResult);
+
+ int offset = 1;
+ TechnicalAge currentAge = startingAge;
+ for (DateTime currentDate = startingDate.AddMonths(1); currentDate <= dateOfFinalAge; currentDate = currentDate.AddMonths(1))
+ {
+ bool isEndOfYear = offset % 12 == 0;
+
+ currentAge += TechnicalAge.From(0,1);
+
+ // pro-rated factor
+ decimal phi = offset / 12M;
+
+ decimal agsPhi = agsj * phi;
+
+ decimal aghozPhi = aghoz + agsPhi;
+ decimal aghmzPhi = aghmz * (1M + phi * projectionInterestRate) + agsPhi;
+
+ lastResult = new RetirementSavingsProcessResult(
+ DateOfCalculation: currentDate,
+ BvgAge: currentDate.Year - dateOfTechnicalBirth.Year,
+ TechnicalAge: currentAge,
+ ProRatedFactor: phi,
+ GrossInterestRate: projectionInterestRate,
+ RetirementCredit: agsPhi,
+ RetirementCapitalWithoutInterest: aghozPhi,
+ RetirementCapital: aghmzPhi,
+ dateOfRetirement == currentDate,
+ dateOfEndOfSavings == currentDate,
+ IsFullYear: isEndOfYear,
+ currentAge.Months == 0);
+
+ results.Add(lastResult);
+
+ if (isEndOfYear)
+ {
+ aghoz += agsj;
+ aghmz = aghmz * (1M + projectionInterestRate) + agsj;
+ offset = 0;
+ }
+
+ agsj = retirementCreditGetter(currentAge);
+
+ offset++;
+ }
+
+ int releaseOffset = 1;
+ aghoz = lastResult.RetirementCapitalWithoutInterest;
+ aghmz = lastResult.RetirementCapital;
+ for (DateTime currentDate = dateOfRetirement.AddMonths(1); currentDate <= dateOfEndOfSavings; currentDate = currentDate.AddMonths(1))
+ {
+ bool isEndOfYear = offset % 12 == 0;
+
+ currentAge += TechnicalAge.From(0, 1);
+
+ decimal phi = releaseOffset / 12M;
+
+ decimal aghozPhi = aghoz;
+ decimal aghmzPhi = aghmz * (1M + phi * projectionInterestRate);
+
+ lastResult = new RetirementSavingsProcessResult(
+ DateOfCalculation: currentDate,
+ BvgAge: currentDate.Year - dateOfTechnicalBirth.Year,
+ TechnicalAge: currentAge,
+ ProRatedFactor: phi,
+ GrossInterestRate: projectionInterestRate,
+ RetirementCredit: decimal.Zero,
+ RetirementCapitalWithoutInterest: aghozPhi,
+ RetirementCapital: aghmzPhi,
+ dateOfRetirement == currentDate,
+ dateOfEndOfSavings == currentDate,
+ IsFullYear: isEndOfYear,
+ currentAge.Months == 0);
+
+ results.Add(lastResult);
+
+ if (isEndOfYear)
+ {
+ aghmz *= (1M + projectionInterestRate * phi);
+ offset = 0;
+ releaseOffset = 0;
+ }
+
+ offset++;
+ releaseOffset++;
+ }
+
+ return results.ToArray();
+ }
+}
+
diff --git a/src/Application/Extensions/BvgCalculatorsCollectionExtensions.cs b/src/Application/Extensions/BvgCalculatorsCollectionExtensions.cs
new file mode 100644
index 00000000..c54294de
--- /dev/null
+++ b/src/Application/Extensions/BvgCalculatorsCollectionExtensions.cs
@@ -0,0 +1,24 @@
+using Application.Bvg;
+using Application.Validators;
+using Domain.Models.Bvg;
+using FluentValidation;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Application.Extensions
+{
+ public static class BvgCalculatorsCollectionExtensions
+ {
+ public static void AddBvgCalculators(this IServiceCollection serviceCollection)
+ {
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+
+ serviceCollection.AddSingleton, BvgPersonValidator>();
+ }
+ }
+}
diff --git a/src/BvgCalculator/BvgRetirementCapitalCalculationExtensions.cs b/src/Application/Extensions/BvgRetirementCapitalCalculationExtensions.cs
similarity index 89%
rename from src/BvgCalculator/BvgRetirementCapitalCalculationExtensions.cs
rename to src/Application/Extensions/BvgRetirementCapitalCalculationExtensions.cs
index f4cf0146..c3dd757e 100644
--- a/src/BvgCalculator/BvgRetirementCapitalCalculationExtensions.cs
+++ b/src/Application/Extensions/BvgRetirementCapitalCalculationExtensions.cs
@@ -1,8 +1,7 @@
-using System;
-using PensionCoach.Tools.BvgCalculator.Models;
+using Domain.Models.Bvg;
using PensionCoach.Tools.CommonUtils;
-namespace PensionCoach.Tools.BvgCalculator
+namespace Application.Extensions
{
public static class BvgCalculationExtensions
{
diff --git a/src/Application/Extensions/OccupationalBenefitsDateExtensions.cs b/src/Application/Extensions/OccupationalBenefitsDateExtensions.cs
new file mode 100644
index 00000000..d80b5c65
--- /dev/null
+++ b/src/Application/Extensions/OccupationalBenefitsDateExtensions.cs
@@ -0,0 +1,54 @@
+using Domain.Enums;
+using Domain.Models.Bvg;
+
+namespace Application.Extensions;
+
+///
+/// Extension methods related to date calculations in
+/// the context of occupational benefits (Vorsorge)
+///
+public static class OccupationalBenefitsDateExtensions
+{
+ public static DateTime GetRetirementDate(this DateTime birthdate, int retirementAge)
+ {
+
+ // Date of retirement
+ return birthdate
+ .GetBirthdateTechnical()
+ .AddYears(retirementAge);
+ }
+
+ public static DateTime GetRetirementDate(this DateTime birthdate, Gender gender)
+ {
+ // FinalAgeByPlan BVG
+ int xsBvg = Bvg.Bvg.GetRetirementAge(gender);
+
+ // Date of retirement
+ return GetRetirementDate(birthdate, xsBvg);
+ }
+
+ ///
+ /// Gets the birthdate technical: first day of following month.
+ ///
+ /// The birthdate.
+ ///
+ public static DateTime GetBirthdateTechnical(this DateTime birthdate)
+ {
+ return new DateTime(birthdate.Year, birthdate.Month, 1).AddMonths(1);
+ }
+
+ public static int GetBvgAge(this DateTime birthdate, int calculationYear)
+ {
+ return calculationYear - birthdate.Year;
+ }
+
+ public static DateTime BeginOfYear(this DateTime date)
+ {
+ return new DateTime(date.Year, 1, 1);
+ }
+
+ public static DateTime EndOfYear(this DateTime date)
+ {
+ return new DateTime(date.Year, 1, 1).AddYears(1);
+ }
+}
diff --git a/src/Application/Features/CheckSettings/ICheckSettingsConnector.cs b/src/Application/Features/CheckSettings/ICheckSettingsConnector.cs
new file mode 100644
index 00000000..abe8110f
--- /dev/null
+++ b/src/Application/Features/CheckSettings/ICheckSettingsConnector.cs
@@ -0,0 +1,7 @@
+namespace Application.Features.CheckSettings
+{
+ public interface ICheckSettingsConnector
+ {
+ Task> GetAsync();
+ }
+}
diff --git a/src/Application/Features/FullTaxCalculation/ITaxCalculatorConnector.cs b/src/Application/Features/FullTaxCalculation/ITaxCalculatorConnector.cs
new file mode 100644
index 00000000..7bb4850f
--- /dev/null
+++ b/src/Application/Features/FullTaxCalculation/ITaxCalculatorConnector.cs
@@ -0,0 +1,16 @@
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Features.FullTaxCalculation
+{
+ public interface ITaxCalculatorConnector
+ {
+ Task> CalculateAsync(
+ int calculationYear, int bfsMunicipalityId, TaxPerson person, bool withMaxAvailableCalculationYear = false);
+
+ Task> CalculateAsync(
+ int calculationYear, int bfsMunicipalityId, CapitalBenefitTaxPerson person, bool withMaxAvailableCalculationYear = false);
+
+ Task GetSupportedTaxYears();
+ }
+}
diff --git a/src/Application/Features/FullTaxCalculation/ITaxSupportedYearProvider.cs b/src/Application/Features/FullTaxCalculation/ITaxSupportedYearProvider.cs
new file mode 100644
index 00000000..a0914aed
--- /dev/null
+++ b/src/Application/Features/FullTaxCalculation/ITaxSupportedYearProvider.cs
@@ -0,0 +1,9 @@
+namespace Application.Features.FullTaxCalculation
+{
+ public interface ITaxSupportedYearProvider
+ {
+ int[] GetSupportedTaxYears();
+
+ int MapToSupportedYear(int taxYear);
+ }
+}
diff --git a/src/Application/Features/FullTaxCalculation/TaxCalculatorConnector.cs b/src/Application/Features/FullTaxCalculation/TaxCalculatorConnector.cs
new file mode 100644
index 00000000..d0a502ed
--- /dev/null
+++ b/src/Application/Features/FullTaxCalculation/TaxCalculatorConnector.cs
@@ -0,0 +1,56 @@
+using Application.Municipality;
+using Application.Tax.Contracts;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Features.FullTaxCalculation
+{
+ public class TaxCalculatorConnector : ITaxCalculatorConnector
+ {
+ private readonly int[] supportedTaxYears = { 2019 };
+
+ private readonly IFullWealthAndIncomeTaxCalculator fullWealthAndIncomeTaxCalculator;
+ private readonly IFullCapitalBenefitTaxCalculator fullCapitalBenefitTaxCalculator;
+ private readonly IMunicipalityConnector municipalityResolver;
+
+ public TaxCalculatorConnector(
+ IFullWealthAndIncomeTaxCalculator fullWealthAndIncomeTaxCalculator,
+ IFullCapitalBenefitTaxCalculator fullCapitalBenefitTaxCalculator,
+ IMunicipalityConnector municipalityResolver)
+ {
+ this.fullWealthAndIncomeTaxCalculator = fullWealthAndIncomeTaxCalculator;
+ this.fullCapitalBenefitTaxCalculator = fullCapitalBenefitTaxCalculator;
+ this.municipalityResolver = municipalityResolver;
+ }
+
+ public async Task> CalculateAsync(
+ int calculationYear, int bfsMunicipalityId, TaxPerson person, bool withMaxAvailableCalculationYear = false)
+ {
+ Either municipalityData =
+ await municipalityResolver.GetAsync(bfsMunicipalityId, calculationYear);
+
+ return await municipalityData
+ .BindAsync(m => fullWealthAndIncomeTaxCalculator.CalculateAsync(
+ calculationYear, m, person));
+ }
+
+ public async Task> CalculateAsync(
+ int calculationYear, int bfsMunicipalityId, CapitalBenefitTaxPerson person, bool withMaxAvailableCalculationYear = false)
+ {
+ Either municipalityData =
+ await municipalityResolver.GetAsync(bfsMunicipalityId, calculationYear);
+
+ return await municipalityData
+ .BindAsync(m => fullCapitalBenefitTaxCalculator.CalculateAsync(
+ calculationYear,
+ m,
+ person));
+ }
+
+ public Task GetSupportedTaxYears()
+ {
+ return supportedTaxYears.AsTask();
+ }
+ }
+}
diff --git a/src/TaxCalculator/TaxCalculatorServiceCollectionExtensions.cs b/src/Application/Features/FullTaxCalculation/TaxCalculatorServiceCollectionExtensions.cs
similarity index 84%
rename from src/TaxCalculator/TaxCalculatorServiceCollectionExtensions.cs
rename to src/Application/Features/FullTaxCalculation/TaxCalculatorServiceCollectionExtensions.cs
index e9a189e4..99a07256 100644
--- a/src/TaxCalculator/TaxCalculatorServiceCollectionExtensions.cs
+++ b/src/Application/Features/FullTaxCalculation/TaxCalculatorServiceCollectionExtensions.cs
@@ -1,28 +1,28 @@
-using System;
+using Application.Features.MarginalTaxCurve;
+using Application.Mapping;
+using Application.Municipality;
+using Application.Tax.Contracts;
+using Application.Tax.Estv;
+using Application.Tax.Mock;
+using Application.Tax.Proprietary;
+using Application.Tax.Proprietary.Basis.CapitalBenefit;
+using Application.Tax.Proprietary.Basis.Income;
+using Application.Tax.Proprietary.Basis.Wealth;
+using Application.Tax.Proprietary.Contracts;
+using Application.Tax.Proprietary.Models;
+using Application.Validators;
using AutoMapper;
+using Domain.Enums;
+using Domain.Models.Tax;
+using Domain.Models.Tax.Person;
using FluentValidation;
-using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
-using PensionCoach.Tools.CommonTypes;
-using PensionCoach.Tools.CommonTypes.Tax;
-using PensionCoach.Tools.CommonUtils;
-using PensionCoach.Tools.TaxCalculator.Abstractions;
-using PensionCoach.Tools.TaxCalculator.Abstractions.Models;
-using PensionCoach.Tools.TaxCalculator.Abstractions.Models.Person;
-using PensionCoach.Tools.TaxCalculator.Basis.CapitalBenefit;
-using PensionCoach.Tools.TaxCalculator.Basis.Income;
-using PensionCoach.Tools.TaxCalculator.Basis.Wealth;
-using PensionCoach.Tools.TaxCalculator.Estv;
-using PensionCoach.Tools.TaxCalculator.Mapping;
-using PensionCoach.Tools.TaxCalculator.Mock;
-using PensionCoach.Tools.TaxCalculator.Proprietary;
-using PensionCoach.Tools.TaxCalculator.Validators;
-namespace PensionCoach.Tools.TaxCalculator
+namespace Application.Features.FullTaxCalculation
{
public static class TaxCalculatorServiceCollectionExtensions
{
- public static void AddTaxCalculators(this IServiceCollection collection, IConfiguration configuration)
+ public static void AddTaxCalculators(this IServiceCollection collection, ApplicationMode applicationMode)
{
collection.AddTransient();
collection.AddTransient();
@@ -32,10 +32,8 @@ public static void AddTaxCalculators(this IServiceCollection collection, IConfig
collection.AddTransient();
collection.AddTransient();
collection.AddTransient();
- collection.AddTransient();
- collection.AddTransient();
- collection.AddFullTaxCalculators(configuration);
+ collection.AddFullTaxCalculators(applicationMode);
var mappingConfig = new MapperConfiguration(mc =>
{
@@ -51,11 +49,9 @@ public static void AddTaxCalculators(this IServiceCollection collection, IConfig
collection.AddCantonCapitalBenefitTaxCalculatorFactory();
}
- private static void AddFullTaxCalculators(this IServiceCollection collection, IConfiguration configuration)
+ private static void AddFullTaxCalculators(this IServiceCollection collection, ApplicationMode applicationMode)
{
- ApplicationMode typeOfTaxCalculator = configuration.GetApplicationMode();
-
- switch (typeOfTaxCalculator)
+ switch (applicationMode)
{
case ApplicationMode.Proprietary:
collection.AddTransient();
diff --git a/src/TaxCalculator.Abstractions/IMarginalTaxCurveCalculatorConnector.cs b/src/Application/Features/MarginalTaxCurve/IMarginalTaxCurveCalculatorConnector.cs
similarity index 70%
rename from src/TaxCalculator.Abstractions/IMarginalTaxCurveCalculatorConnector.cs
rename to src/Application/Features/MarginalTaxCurve/IMarginalTaxCurveCalculatorConnector.cs
index 2a7fd0d2..9478d925 100644
--- a/src/TaxCalculator.Abstractions/IMarginalTaxCurveCalculatorConnector.cs
+++ b/src/Application/Features/MarginalTaxCurve/IMarginalTaxCurveCalculatorConnector.cs
@@ -1,9 +1,8 @@
-using LanguageExt;
-using PensionCoach.Tools.CommonTypes.Tax;
-using PensionCoach.Tools.TaxCalculator.Abstractions.Models;
-using System.Threading.Tasks;
+using Application.Tax.Proprietary.Models;
+using Domain.Models.Tax;
+using LanguageExt;
-namespace PensionCoach.Tools.TaxCalculator.Abstractions;
+namespace Application.Features.MarginalTaxCurve;
public interface IMarginalTaxCurveCalculatorConnector
{
diff --git a/src/Application/Features/MarginalTaxCurve/MarginalTaxCurveCalculatorConnector.cs b/src/Application/Features/MarginalTaxCurve/MarginalTaxCurveCalculatorConnector.cs
new file mode 100644
index 00000000..d5118be6
--- /dev/null
+++ b/src/Application/Features/MarginalTaxCurve/MarginalTaxCurveCalculatorConnector.cs
@@ -0,0 +1,290 @@
+using Application.Municipality;
+using Application.Tax.Contracts;
+using Application.Tax.Proprietary.Models;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+using static System.Runtime.InteropServices.JavaScript.JSType;
+
+namespace Application.Features.MarginalTaxCurve;
+
+public class MarginalTaxCurveCalculatorConnector : IMarginalTaxCurveCalculatorConnector
+{
+ private readonly IFullWealthAndIncomeTaxCalculator fullWealthAndIncomeTaxCalculator;
+ private readonly IFullCapitalBenefitTaxCalculator fullCapitalBenefitTaxCalculator;
+ private readonly IMunicipalityConnector municipalityResolver;
+
+ public MarginalTaxCurveCalculatorConnector(
+ IFullWealthAndIncomeTaxCalculator fullWealthAndIncomeTaxCalculator,
+ IFullCapitalBenefitTaxCalculator fullCapitalBenefitTaxCalculator,
+ IMunicipalityConnector municipalityResolver)
+ {
+ this.fullWealthAndIncomeTaxCalculator = fullWealthAndIncomeTaxCalculator;
+ this.fullCapitalBenefitTaxCalculator = fullCapitalBenefitTaxCalculator;
+ this.municipalityResolver = municipalityResolver;
+ }
+
+ public async Task> CalculateIncomeTaxCurveAsync(
+ int calculationYear,
+ int bfsMunicipalityId,
+ TaxPerson person,
+ int lowerLimit,
+ int upperLimit,
+ int numberOfSamples)
+ {
+ MarginalTaxCurveResult result = new();
+
+ Either municipalityResult = await municipalityResolver
+ .GetAsync(bfsMunicipalityId, calculationYear);
+
+ (await municipalityResult
+ .MapAsync(model => CalculateInternalAsync(model, person)))
+ .Iter(t => result.MarginalTaxCurve = t);
+
+ (await municipalityResult
+ .MapAsync(municipalityModel => CalculateSingleMarginalTaxRate(municipalityModel, person)))
+ .Iter(taxRate => taxRate.Iter(rate => result.CurrentMarginalTaxRate = rate));
+
+ Merge(result);
+
+ return result;
+
+ void Merge(MarginalTaxCurveResult beforeMergeResult)
+ {
+ if (beforeMergeResult.CurrentMarginalTaxRate is null)
+ {
+ return;
+ }
+
+ if (beforeMergeResult.MarginalTaxCurve.All(p =>
+ p.Salary != beforeMergeResult.CurrentMarginalTaxRate.Salary))
+ {
+ beforeMergeResult.MarginalTaxCurve.Add(beforeMergeResult.CurrentMarginalTaxRate);
+ }
+ }
+
+ async Task> CalculateInternalAsync(
+ MunicipalityModel municipalityModel, TaxPerson taxPerson)
+ {
+ List incomeTaxes = new();
+
+ decimal previousMarginalTaxRate = 0;
+ foreach (decimal currentSalary in LinearSpace(lowerLimit, upperLimit, numberOfSamples))
+ {
+ var currentPerson = taxPerson with { TaxableIncome = currentSalary, TaxableFederalIncome = currentSalary };
+
+ (await CalculateSingleMarginalTaxRate(municipalityModel, currentPerson))
+ .Iter(r =>
+ {
+ if (r.Rate > previousMarginalTaxRate)
+ {
+ incomeTaxes.Add(new MarginalTaxInfo(r.Salary, r.Rate, r.TotalTaxAmount));
+ previousMarginalTaxRate = r.Rate;
+ }
+ });
+ }
+
+ return incomeTaxes;
+ }
+
+ async Task> CalculateSingleMarginalTaxRate(
+ MunicipalityModel municipalityModel, TaxPerson taxPerson)
+ {
+ const decimal delta = 1000M;
+
+ var x0Person = person with
+ {
+ TaxableIncome = taxPerson.TaxableIncome, TaxableFederalIncome = taxPerson.TaxableFederalIncome
+ };
+
+ Either tax0 =
+ await fullWealthAndIncomeTaxCalculator.CalculateAsync(calculationYear, municipalityModel, x0Person);
+
+ var x1Person = taxPerson with
+ {
+ TaxableIncome = taxPerson.TaxableIncome + delta,
+ TaxableFederalIncome = taxPerson.TaxableFederalIncome + delta
+ };
+
+ Either tax1 =
+ await fullWealthAndIncomeTaxCalculator.CalculateAsync(calculationYear, municipalityModel, x1Person);
+
+ var r =
+ from t0 in tax0
+ from t1 in tax1
+ select new MarginalTaxInfo(
+ taxPerson.TaxableIncome,
+ (t1.TotalTaxAmount - t0.TotalTaxAmount) / delta,
+ t0.TotalTaxAmount);
+
+ return r;
+ }
+ }
+
+ public async Task> CalculateCapitalBenefitTaxCurveAsync(
+ int calculationYear,
+ int bfsMunicipalityId,
+ CapitalBenefitTaxPerson person,
+ int lowerLimit,
+ int upperLimit,
+ int numberOfSamples)
+ {
+ MarginalTaxCurveResult result = new();
+
+ Either municipalityResult = await municipalityResolver
+ .GetAsync(bfsMunicipalityId, calculationYear);
+
+ (await municipalityResult
+ .MapAsync(model => CalculateInternalAsync(model, person)))
+ .Iter(t => result.MarginalTaxCurve = t);
+
+ (await municipalityResult
+ .MapAsync(model => CalculateSingleMarginalTaxRate(model, person)))
+ .Iter(taxRate => taxRate.Iter(rate => result.CurrentMarginalTaxRate = rate));
+
+ // Create pairs of tax rates from the curve and check if the current tax rate is between them.
+ // If it is, adjust the current tax rate to the one from the curve.
+ foreach (var pair in result.MarginalTaxCurve.Zip(result.MarginalTaxCurve.Skip(1)))
+ {
+ if (pair.Item1.Salary <= result.CurrentMarginalTaxRate.Salary &&
+ pair.Item2.Salary > result.CurrentMarginalTaxRate.Salary)
+ {
+ result.CurrentMarginalTaxRate = result.CurrentMarginalTaxRate with { Rate = pair.Item1.Rate };
+ }
+ }
+
+
+ return result;
+
+ async Task> CalculateSingleMarginalTaxRate(
+ MunicipalityModel municipalityModel, CapitalBenefitTaxPerson taxPerson)
+ {
+ decimal delta = 1000M;
+
+ Either tax0 =
+ await fullCapitalBenefitTaxCalculator.CalculateAsync(calculationYear, municipalityModel, taxPerson);
+
+ var x1Person = taxPerson with { TaxableCapitalBenefits = taxPerson.TaxableCapitalBenefits + delta };
+
+ Either tax1 =
+ await fullCapitalBenefitTaxCalculator.CalculateAsync(calculationYear, municipalityModel, x1Person);
+
+ Either r =
+ from t0 in tax0
+ from t1 in tax1
+ select new MarginalTaxInfo(
+ taxPerson.TaxableCapitalBenefits,
+ (t1.TotalTaxAmount - t0.TotalTaxAmount) / delta,
+ t0.TotalTaxAmount);
+
+ return r;
+ }
+
+ async Task> CalculateInternalAsync(
+ MunicipalityModel municipalityModel, CapitalBenefitTaxPerson taxPerson)
+ {
+ int currentSalary = (int)taxPerson.TaxableCapitalBenefits;
+ int[] linearSpace = LinearSpace(lowerLimit, upperLimit, numberOfSamples);
+ int currentValueIndex = SortedIndex(linearSpace, currentSalary);
+ linearSpace = InsertAt(linearSpace, (int)taxPerson.TaxableCapitalBenefits, currentValueIndex);
+
+ List taxes = new();
+
+ decimal previousMarginalTaxRate = 0;
+ foreach (decimal salary in linearSpace)
+ {
+ var currentPerson = taxPerson with { TaxableCapitalBenefits = salary };
+
+ (await CalculateSingleMarginalTaxRate(municipalityModel, currentPerson))
+ .Iter(r =>
+ {
+ if (r.Rate <= previousMarginalTaxRate)
+ {
+ if (taxes.Count == 0)
+ {
+ taxes.Add(new MarginalTaxInfo(r.Salary, r.Rate, r.TotalTaxAmount));
+ previousMarginalTaxRate = r.Rate;
+ }
+ else
+ {
+ taxes.Add(taxes[^1] with { Salary = r.Salary, TotalTaxAmount = r.TotalTaxAmount });
+ }
+ }
+ else if (r.Rate > previousMarginalTaxRate)
+ {
+ taxes.Add(new MarginalTaxInfo(r.Salary, r.Rate, r.TotalTaxAmount));
+ previousMarginalTaxRate = r.Rate;
+ }
+ });
+ }
+
+ return taxes;
+ }
+ }
+
+ protected static int[] LinearSpace(int start, int end, int size)
+ {
+ int[] result = new int[size];
+ decimal step = (end - start) / (size - 1M);
+
+ for (int i = 0; i < size; i++)
+ {
+ result[i] = (int)(start + (i * step));
+ }
+
+ // Ensure the end value is exactly as specified, avoiding floating-point arithmetic errors
+ if (size > 1)
+ {
+ result[size - 1] = end;
+ }
+
+ return result;
+ }
+
+ private static int SortedIndex(int[] array, int value)
+ {
+ int low = 0;
+ int high = array.Length;
+
+ while (low < high)
+ {
+ var mid = (low + high) >>> 1;
+ if (array[mid] < value) low = mid + 1;
+ else high = mid;
+ }
+
+ return low;
+ }
+
+ private static int[] InsertAt(int[] originalArray, int value, int index)
+ {
+ // Handle the case where index is out of bounds
+ if (index < 0 || index > originalArray.Length)
+ {
+ index = originalArray.Length; // Append to the end if index is out of bounds
+ }
+ else if (originalArray[index] == value)
+ {
+ return originalArray;
+ }
+
+ // Create a new array with one extra space
+ int[] newArray = new int[originalArray.Length + 1];
+
+ for (int i = 0, j = 0; i < newArray.Length; i++)
+ {
+ if (i == index)
+ {
+ // Insert the new value at the specified index
+ newArray[i] = value;
+ }
+ else
+ {
+ // Copy the value from the original array
+ newArray[i] = originalArray[j++];
+ }
+ }
+
+ return newArray;
+ }
+}
diff --git a/src/Application/Features/PensionVersusCapital/IPensionVersusCapitalCalculator.cs b/src/Application/Features/PensionVersusCapital/IPensionVersusCapitalCalculator.cs
new file mode 100644
index 00000000..f5244b6d
--- /dev/null
+++ b/src/Application/Features/PensionVersusCapital/IPensionVersusCapitalCalculator.cs
@@ -0,0 +1,16 @@
+using Domain.Enums;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Features.PensionVersusCapital;
+
+public interface IPensionVersusCapitalCalculator
+{
+ Task> CalculateAsync(
+ int calculationYear,
+ int municipalityId,
+ Canton canton,
+ decimal retirementPension,
+ decimal retirementCapital,
+ TaxPerson taxPerson);
+}
diff --git a/src/Application/Features/PensionVersusCapital/PensionVersusCapitalCalculator.cs b/src/Application/Features/PensionVersusCapital/PensionVersusCapitalCalculator.cs
new file mode 100644
index 00000000..d7a782e5
--- /dev/null
+++ b/src/Application/Features/PensionVersusCapital/PensionVersusCapitalCalculator.cs
@@ -0,0 +1,74 @@
+using Application.Features.FullTaxCalculation;
+using Domain.Enums;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Features.PensionVersusCapital;
+
+public class PensionVersusCapitalCalculator : IPensionVersusCapitalCalculator
+{
+ private readonly ITaxCalculatorConnector taxCalculatorConnector;
+
+ public PensionVersusCapitalCalculator(ITaxCalculatorConnector taxCalculatorConnector)
+ {
+ this.taxCalculatorConnector = taxCalculatorConnector;
+ }
+
+ public async Task> CalculateAsync(
+ int calculationYear,
+ int municipalityId,
+ Canton canton,
+ decimal retirementPension,
+ decimal retirementCapital,
+ TaxPerson taxPerson)
+ {
+ CapitalBenefitTaxPerson capitalBenefitTaxPerson = new()
+ {
+ Name = "Benefit Person",
+ CivilStatus = taxPerson.CivilStatus,
+ TaxableCapitalBenefits = retirementCapital,
+ ReligiousGroupType = taxPerson.ReligiousGroupType,
+ NumberOfChildren = taxPerson.NumberOfChildren,
+ PartnerReligiousGroupType = taxPerson.PartnerReligiousGroupType
+ };
+
+ Either justOtherIncomeTaxCalculationResult =
+ await taxCalculatorConnector.CalculateAsync(calculationYear, municipalityId, taxPerson, true);
+
+ Either capitalBenefitTaxCalculationResult =
+ await taxCalculatorConnector.CalculateAsync(calculationYear, municipalityId, capitalBenefitTaxPerson, true);
+
+ var taxPersonWithPensionIncome = taxPerson with
+ {
+ TaxableIncome = taxPerson.TaxableIncome + retirementPension,
+ TaxableFederalIncome = taxPerson.TaxableFederalIncome + retirementPension,
+ };
+
+ Either withPensionIncomeTaxCalculationResult =
+ await taxCalculatorConnector.CalculateAsync(calculationYear, municipalityId, taxPersonWithPensionIncome, true);
+
+ Either r = from benefitsTax in capitalBenefitTaxCalculationResult
+ from otherIncomeTax in justOtherIncomeTaxCalculationResult
+ from withPensionIncomeTax in withPensionIncomeTaxCalculationResult
+ select CalculateBreakEvenFactor(benefitsTax, otherIncomeTax, withPensionIncomeTax);
+
+ return r;
+
+ decimal CalculateBreakEvenFactor(
+ FullCapitalBenefitTaxResult benefitTaxResult,
+ FullTaxResult otherIncomeTaxResult,
+ FullTaxResult withPensionIncomeTaxResult)
+ {
+ decimal capitalBenefitNet = capitalBenefitTaxPerson.TaxableCapitalBenefits - benefitTaxResult.TotalTaxAmount;
+ decimal incomeNet = taxPersonWithPensionIncome.TaxableIncome - taxPerson.TaxableIncome;
+ decimal totalTaxNet = withPensionIncomeTaxResult.TotalTaxAmount - otherIncomeTaxResult.TotalTaxAmount;
+
+ if (incomeNet == decimal.Zero)
+ {
+ return 0m;
+ }
+
+ return capitalBenefitNet / (incomeNet - totalTaxNet);
+ }
+ }
+}
diff --git a/src/Application/Features/PensionVersusCapital/ToolsCollectionExtensions.cs b/src/Application/Features/PensionVersusCapital/ToolsCollectionExtensions.cs
new file mode 100644
index 00000000..260e7c15
--- /dev/null
+++ b/src/Application/Features/PensionVersusCapital/ToolsCollectionExtensions.cs
@@ -0,0 +1,11 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Application.Features.PensionVersusCapital;
+
+public static class ToolsCollectionExtensions
+{
+ public static void AddToolsCalculators(this IServiceCollection serviceCollection)
+ {
+ serviceCollection.AddSingleton();
+ }
+}
diff --git a/src/Tax.Tools.Comparison/Tax.Tools.Comparison.Abstractions/ITaxComparer.cs b/src/Application/Features/TaxComparison/ITaxComparer.cs
similarity index 68%
rename from src/Tax.Tools.Comparison/Tax.Tools.Comparison.Abstractions/ITaxComparer.cs
rename to src/Application/Features/TaxComparison/ITaxComparer.cs
index bdc319f0..ad673319 100644
--- a/src/Tax.Tools.Comparison/Tax.Tools.Comparison.Abstractions/ITaxComparer.cs
+++ b/src/Application/Features/TaxComparison/ITaxComparer.cs
@@ -1,9 +1,8 @@
-using System.Collections.Generic;
+using Domain.Models.Tax;
+using Domain.Models.TaxComparison;
using LanguageExt;
-using PensionCoach.Tools.CommonTypes.Tax;
-using PensionCoach.Tools.TaxComparison;
-namespace Tax.Tools.Comparison.Abstractions
+namespace Application.Features.TaxComparison
{
public interface ITaxComparer
{
diff --git a/src/TaxCalculator.WebApi/Models/CapitalBenefitTaxRequest.cs b/src/Application/Features/TaxComparison/Models/CapitalBenefitTaxComparerRequest.cs
similarity index 52%
rename from src/TaxCalculator.WebApi/Models/CapitalBenefitTaxRequest.cs
rename to src/Application/Features/TaxComparison/Models/CapitalBenefitTaxComparerRequest.cs
index 20926103..7d66f161 100644
--- a/src/TaxCalculator.WebApi/Models/CapitalBenefitTaxRequest.cs
+++ b/src/Application/Features/TaxComparison/Models/CapitalBenefitTaxComparerRequest.cs
@@ -1,25 +1,23 @@
using System.ComponentModel.DataAnnotations;
-using PensionCoach.Tools.CommonTypes;
+using Domain.Enums;
-namespace TaxCalculator.WebApi.Models
+namespace Application.Features.TaxComparison.Models
{
- public class CapitalBenefitTaxRequest
+ public class CapitalBenefitTaxComparerRequest
{
[MaxLength(50)]
public string Name { get; set; }
- [Range(2018, 2099, ErrorMessage = "Valid tax years start from 2018")]
- public int CalculationYear { get; set; }
-
public CivilStatus CivilStatus { get; set; }
public ReligiousGroupType ReligiousGroup { get; set; }
public ReligiousGroupType? PartnerReligiousGroup { get; set; }
- [Required]
- [Range(typeof(int), "0", "10000", ErrorMessage = "BFS number not valid")]
- public int BfsMunicipalityId { get; set; }
+ ///
+ /// List of BFS number defines for which municipalites a comparison is calculated.
+ ///
+ public int[] BfsNumberList { get; set; }
[Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
public decimal TaxableBenefits { get; set; }
diff --git a/src/Application/Features/TaxComparison/Models/IncomeAndWealthComparerRequest.cs b/src/Application/Features/TaxComparison/Models/IncomeAndWealthComparerRequest.cs
new file mode 100644
index 00000000..97633d8a
--- /dev/null
+++ b/src/Application/Features/TaxComparison/Models/IncomeAndWealthComparerRequest.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel.DataAnnotations;
+using Domain.Enums;
+
+namespace Application.Features.TaxComparison.Models
+{
+ public class IncomeAndWealthComparerRequest
+ {
+ [MaxLength(50)]
+ public string Name { get; set; }
+
+ public CivilStatus CivilStatus { get; set; }
+
+ public ReligiousGroupType ReligiousGroup { get; set; }
+
+ public ReligiousGroupType? PartnerReligiousGroup { get; set; }
+
+ ///
+ /// List of BFS number defines for which municipalites a comparison is calculated.
+ ///
+ public int[] BfsNumberList { get; set; }
+
+ [Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
+ public decimal TaxableIncome { get; set; }
+
+ [Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
+ public decimal TaxableFederalIncome { get; set; }
+
+ [Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
+ public decimal TaxableWealth { get; set; }
+ }
+}
diff --git a/src/Application/Features/TaxComparison/Models/TaxComparerResponse.cs b/src/Application/Features/TaxComparison/Models/TaxComparerResponse.cs
new file mode 100644
index 00000000..de925992
--- /dev/null
+++ b/src/Application/Features/TaxComparison/Models/TaxComparerResponse.cs
@@ -0,0 +1,25 @@
+using Domain.Enums;
+using Domain.Models.Tax;
+
+namespace Application.Features.TaxComparison.Models;
+
+public class TaxComparerResponse
+{
+ public string Name { get; set; }
+
+ public int MunicipalityId { get; set; }
+
+ public string MunicipalityName { get; set; }
+
+ public Canton Canton { get; set; }
+
+ public int MaxSupportedTaxYear { get; set; }
+
+ public decimal TotalTaxAmount { get; set; }
+
+ public TaxAmountDetail TaxDetails { get; set; }
+
+ public int TotalCount { get; set; }
+
+ public int CountComputed { get; set; }
+}
diff --git a/src/Tax.Tools.Comparison.Domain/TaxComparerResultReportModel.cs b/src/Application/Features/TaxComparison/Models/TaxComparerResultReportModel.cs
similarity index 81%
rename from src/Tax.Tools.Comparison.Domain/TaxComparerResultReportModel.cs
rename to src/Application/Features/TaxComparison/Models/TaxComparerResultReportModel.cs
index 07d44cf1..87a00c1d 100644
--- a/src/Tax.Tools.Comparison.Domain/TaxComparerResultReportModel.cs
+++ b/src/Application/Features/TaxComparison/Models/TaxComparerResultReportModel.cs
@@ -1,6 +1,6 @@
-using PensionCoach.Tools.CommonTypes;
+using Domain.Enums;
-namespace PensionCoach.Tools.TaxComparison
+namespace Application.Features.TaxComparison.Models
{
public class TaxComparerResultReportModel
{
diff --git a/src/Tax.Tools.Comparison/Tax.Tools.Comparison/TaxComparer.cs b/src/Application/Features/TaxComparison/TaxComparer.cs
similarity index 93%
rename from src/Tax.Tools.Comparison/Tax.Tools.Comparison/TaxComparer.cs
rename to src/Application/Features/TaxComparison/TaxComparer.cs
index cc3894ac..46a7f0ae 100644
--- a/src/Tax.Tools.Comparison/Tax.Tools.Comparison/TaxComparer.cs
+++ b/src/Application/Features/TaxComparison/TaxComparer.cs
@@ -1,17 +1,13 @@
-using LanguageExt;
-using System.Linq;
-using System.Collections.Generic;
-using System.Threading.Tasks;
+using System.Threading.Channels;
+using Application.Municipality;
+using Application.Tax.Contracts;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using Domain.Models.TaxComparison;
using FluentValidation;
-using PensionCoach.Tools.CommonTypes.Municipality;
-using PensionCoach.Tools.CommonTypes.Tax;
-using PensionCoach.Tools.TaxCalculator.Abstractions;
-using Tax.Tools.Comparison.Abstractions;
-using System.Threading.Channels;
-using System.Threading;
-using PensionCoach.Tools.TaxComparison;
-
-namespace Tax.Tools.Comparison
+using LanguageExt;
+
+namespace Application.Features.TaxComparison
{
public class TaxComparer : ITaxComparer
{
diff --git a/src/Application/Features/TaxComparison/TaxComparerServiceCollectionExtensions.cs b/src/Application/Features/TaxComparison/TaxComparerServiceCollectionExtensions.cs
new file mode 100644
index 00000000..a9eed1bb
--- /dev/null
+++ b/src/Application/Features/TaxComparison/TaxComparerServiceCollectionExtensions.cs
@@ -0,0 +1,12 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Application.Features.TaxComparison
+{
+ public static class TaxComparerServiceCollectionExtensions
+ {
+ public static void AddTaxComparers(this IServiceCollection collection)
+ {
+ collection.AddTransient();
+ }
+ }
+}
diff --git a/src/Application/Features/TaxScenarios/ITaxScenarioCalculator.cs b/src/Application/Features/TaxScenarios/ITaxScenarioCalculator.cs
new file mode 100644
index 00000000..1d42d891
--- /dev/null
+++ b/src/Application/Features/TaxScenarios/ITaxScenarioCalculator.cs
@@ -0,0 +1,34 @@
+using Domain.Models.Scenarios;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Features.TaxScenarios;
+
+public interface ITaxScenarioCalculator
+{
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> CapitalBenefitTransferInsAsync(
+ int startingYear,
+ int bfsMunicipalityId,
+ TaxPerson person,
+ CapitalBenefitTransferInsScenarioModel scenarioModel);
+
+ Task> ThirdPillarVersusSelfInvestmentAsync(
+ int startingYear,
+ int bfsMunicipalityId,
+ TaxPerson person,
+ ThirdPillarVersusSelfInvestmentScenarioModel scenarioModel);
+
+ Task> PensionVersusCapitalComparisonAsync(
+ int calculationYear,
+ int municipalityId,
+ decimal yearConsumptionAmount,
+ decimal retirementPension,
+ decimal retirementCapital,
+ decimal netWealthReturn,
+ TaxPerson taxPerson);
+}
diff --git a/src/Tax.Tools.Comparison.Domain/CapitalBenefitTransferInComparerRequest.cs b/src/Application/Features/TaxScenarios/Models/CapitalBenefitTransferInComparerRequest.cs
similarity index 87%
rename from src/Tax.Tools.Comparison.Domain/CapitalBenefitTransferInComparerRequest.cs
rename to src/Application/Features/TaxScenarios/Models/CapitalBenefitTransferInComparerRequest.cs
index 6ae3acbb..192f8d52 100644
--- a/src/Tax.Tools.Comparison.Domain/CapitalBenefitTransferInComparerRequest.cs
+++ b/src/Application/Features/TaxScenarios/Models/CapitalBenefitTransferInComparerRequest.cs
@@ -1,8 +1,8 @@
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using PensionCoach.Tools.CommonTypes;
+using System.ComponentModel.DataAnnotations;
+using Domain.Enums;
+using Domain.Models.Tax;
-namespace PensionCoach.Tools.TaxComparison;
+namespace Application.Features.TaxScenarios.Models;
public class CapitalBenefitTransferInComparerRequest
{
@@ -30,7 +30,7 @@ public class CapitalBenefitTransferInComparerRequest
[Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
public decimal TaxableWealth { get; set; }
- public IReadOnlyCollection TransferIns { get; set;}
+ public IReadOnlyCollection TransferIns { get; set; }
///
/// Gets or sets yearly net return on transfer-ins.
@@ -38,7 +38,7 @@ public class CapitalBenefitTransferInComparerRequest
public decimal NetWealthReturn { get; set; }
public decimal NetPensionCapitalReturn { get; set; }
-
+
public bool WithCapitalBenefitTaxation { get; set; }
///
@@ -46,7 +46,7 @@ public class CapitalBenefitTransferInComparerRequest
/// The amount when starting withdrawals does not include the previously added transfer-ins.
///
public decimal CapitalBenefitsBeforeWithdrawal { get; set; }
-
+
public IReadOnlyCollection Withdrawals { get; set; }
}
diff --git a/src/Application/Features/TaxScenarios/Models/ThirdPillarVersusSelfInvestmentComparerRequest.cs b/src/Application/Features/TaxScenarios/Models/ThirdPillarVersusSelfInvestmentComparerRequest.cs
new file mode 100644
index 00000000..072803bb
--- /dev/null
+++ b/src/Application/Features/TaxScenarios/Models/ThirdPillarVersusSelfInvestmentComparerRequest.cs
@@ -0,0 +1,57 @@
+using System.ComponentModel.DataAnnotations;
+using Domain.Enums;
+
+namespace Application.Features.TaxScenarios.Models;
+
+public class ThirdPillarVersusSelfInvestmentComparerRequest
+{
+ [MaxLength(50)]
+ public string Name { get; set; }
+
+ [Range(2018, 2099, ErrorMessage = "Valid tax years start from 2018")]
+ public int CalculationYear { get; set; }
+
+ ///
+ /// Final transfer-in year. At this year the third-pillar account is closed and the money is transferred to the wealth.
+ ///
+ public int FinalYear { get; set; }
+
+ public CivilStatus CivilStatus { get; set; }
+
+ public ReligiousGroupType ReligiousGroup { get; set; }
+
+ public ReligiousGroupType? PartnerReligiousGroup { get; set; }
+
+ [Range(typeof(int), "0", "100000", ErrorMessage = "BFS Number not valid")]
+ public int BfsMunicipalityId { get; set; }
+
+ [Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
+ public decimal TaxableIncome { get; set; }
+
+ [Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
+ public decimal TaxableFederalIncome { get; set; }
+
+ [Range(typeof(decimal), "0", "1000000000", ErrorMessage = "No negative values allowed")]
+ public decimal TaxableWealth { get; set; }
+
+ ///
+ /// The amount invested in the third pillar or self-investment account.
+ /// First investment is done in the calculation year up to final year.
+ ///
+ public decimal InvestmentAmount { get; set; }
+
+ ///
+ /// Gets or sets the net growth rate of the investment account which is taxed by the wealth tax scheme.
+ ///
+ public decimal InvestmentNetGrowthRate { get; set; }
+
+ ///
+ /// Gets or sets the net income rate of the investment account which is taxed by the income tax scheme.
+ ///
+ public decimal InvestmentNetIncomeRate { get; set; }
+
+ ///
+ /// Gets or sets the net growth rate of the third-pillar account.
+ ///
+ public decimal ThirdPillarNetGrowthRate { get; set; }
+}
diff --git a/src/Application/Features/TaxScenarios/TaxScenarioCalculator.cs b/src/Application/Features/TaxScenarios/TaxScenarioCalculator.cs
new file mode 100644
index 00000000..fe19b682
--- /dev/null
+++ b/src/Application/Features/TaxScenarios/TaxScenarioCalculator.cs
@@ -0,0 +1,589 @@
+using Application.MultiPeriodCalculator;
+using Application.Municipality;
+using Domain.Contracts;
+using Domain.Enums;
+using Domain.Models.Cashflows;
+using Domain.Models.MultiPeriod;
+using Domain.Models.MultiPeriod.Actions;
+using Domain.Models.MultiPeriod.Definitions;
+using Domain.Models.Municipality;
+using Domain.Models.Scenarios;
+using Domain.Models.Tax;
+using LanguageExt;
+using PensionCoach.Tools.CommonTypes.MultiPeriod;
+using PensionCoach.Tools.CommonTypes.Tax;
+
+namespace Application.Features.TaxScenarios;
+
+public class TaxScenarioCalculator : ITaxScenarioCalculator
+{
+ private readonly IMultiPeriodCashFlowCalculator multiPeriodCashFlowCalculator;
+ private readonly IMunicipalityConnector municipalityResolver;
+
+ public TaxScenarioCalculator(
+ IMultiPeriodCashFlowCalculator multiPeriodCashFlowCalculator,
+ IMunicipalityConnector municipalityResolver)
+ {
+ this.multiPeriodCashFlowCalculator = multiPeriodCashFlowCalculator;
+ this.municipalityResolver = municipalityResolver;
+ }
+
+ public async Task> CapitalBenefitTransferInsAsync(
+ int startingYear, int bfsMunicipalityId, TaxPerson person, CapitalBenefitTransferInsScenarioModel scenarioModel)
+ {
+ var birthdate = new DateTime(1969, 3, 17);
+
+ MultiPeriodOptions options = new();
+ options.CapitalBenefitsNetGrowthRate = scenarioModel.NetReturnCapitalBenefits;
+ options.WealthNetGrowthRate = scenarioModel.NetReturnWealth;
+ options.SavingsQuota = decimal.Zero;
+
+ CashFlowDefinitionHolder cashFlowDefinitionHolder = CreateScenarioDefinitions();
+
+ Either municipalityData =
+ await municipalityResolver.GetAsync(bfsMunicipalityId, startingYear);
+
+ Either scenarioResult = await municipalityData
+ .BindAsync(m =>
+ multiPeriodCashFlowCalculator.CalculateAsync(startingYear, 0, GetPerson(m, birthdate, person),
+ cashFlowDefinitionHolder, options));
+
+ CashFlowDefinitionHolder benchmarkDefinitions = CreateBenchmarkDefinitions();
+
+ Either benchmarkResult = await municipalityData
+ .BindAsync(m =>
+ multiPeriodCashFlowCalculator.CalculateAsync(startingYear, 0, GetPerson(m, birthdate, person),
+ benchmarkDefinitions, options));
+
+ var benchmarkSeriesResult = benchmarkResult
+ .Map(r => r.Accounts
+ .Where(a => a.AccountType is AccountType.Wealth or AccountType.OccupationalPension));
+
+ var scenarioSeriesResult = scenarioResult
+ .Map(r => r.Accounts
+ .Where(a => a.AccountType is AccountType.Wealth or AccountType.OccupationalPension));
+
+ return from b in benchmarkSeriesResult
+ from s in scenarioSeriesResult
+ select CalculateDelta(b.ToList(), s.ToList());
+
+ CashFlowDefinitionHolder CreateBenchmarkDefinitions()
+ {
+ CashFlowDefinitionHolder holder = new CashFlowDefinitionHolder();
+ holder.Composites = CreateDefaultComposites(person, scenarioModel).ToList();
+ holder.CashFlowActions = GetClearAccountAction(scenarioModel).ToList();
+
+ return holder;
+ }
+
+ CashFlowDefinitionHolder CreateScenarioDefinitions()
+ {
+ CashFlowDefinitionHolder holder = new CashFlowDefinitionHolder();
+ holder.Composites = CreateTransferInDefinitions(scenarioModel)
+ .Concat(CreateDefaultComposites(person, scenarioModel))
+ .ToList();
+ holder.CashFlowActions = GetClearAccountAction(scenarioModel).ToList();
+
+ return holder;
+ }
+ }
+
+ public async Task> ThirdPillarVersusSelfInvestmentAsync(
+ int startingYear,
+ int bfsMunicipalityId,
+ TaxPerson person,
+ ThirdPillarVersusSelfInvestmentScenarioModel scenarioModel)
+ {
+ var birthdate = new DateTime(1969, 3, 17);
+
+ MultiPeriodOptions options = new();
+ options.CapitalBenefitsNetGrowthRate = decimal.Zero;
+ options.WealthNetGrowthRate = decimal.Zero;
+ options.InvestmentNetGrowthRate = scenarioModel.InvestmentNetGrowthRate;
+ options.SavingsQuota = decimal.Zero;
+
+ CashFlowDefinitionHolder cashFlowDefinitionHolder = CreateScenarioDefinitions();
+
+ Either municipalityData =
+ await municipalityResolver.GetAsync(bfsMunicipalityId, startingYear);
+
+ Either scenarioResult = await municipalityData
+ .BindAsync(m =>
+ multiPeriodCashFlowCalculator.CalculateAsync(startingYear, 0, GetPerson(m, birthdate, person),
+ cashFlowDefinitionHolder, options));
+
+ var scenarioSeriesResult = scenarioResult
+ .Map(r => r.Accounts
+ .Where(a => a.AccountType is AccountType.Wealth or AccountType.ThirdPillar));
+
+ CashFlowDefinitionHolder benchmarkDefinitions = CreateBenchmarkDefinitions();
+
+ Either benchmarkResult = await municipalityData
+ .BindAsync(m =>
+ multiPeriodCashFlowCalculator.CalculateAsync(
+ startingYear,
+ scenarioModel.FinalYear - startingYear,
+ GetPerson(m, birthdate, person), benchmarkDefinitions, options));
+
+ var benchmarkSeriesResult = benchmarkResult
+ .Map(r => r.Accounts
+ .Where(a => a.AccountType is AccountType.Wealth or AccountType.ThirdPillar or AccountType.Investment));
+
+ return from b in benchmarkSeriesResult
+ from s in scenarioSeriesResult
+ select CalculateDeltaForThirdPillarComparison(b.ToList(), s.ToList());
+
+ CashFlowDefinitionHolder CreateScenarioDefinitions()
+ {
+ CashFlowDefinitionHolder holder = new CashFlowDefinitionHolder();
+
+ var thirdPillarInvestmentDefinitions = new List
+ {
+ new ThirdPillarPaymentsDefinition
+ {
+ DateOfStart = new DateTime(startingYear, 1, 1),
+ NetGrowthRate = decimal.Zero,
+ NumberOfInvestments = scenarioModel.FinalYear - startingYear + 1,
+ YearlyAmount = scenarioModel.InvestmentAmount,
+ }
+ };
+
+ var thirdPillarWithdrawalDefinitions = new List
+ {
+ new DynamicTransferAccountAction
+ {
+ Header =
+ new CashFlowHeader {Id = Guid.NewGuid().ToString(), Name = "Third Pillar Withdrawal"},
+ DateOfProcess = new DateTime(scenarioModel.FinalYear, 12, 31),
+ TransferRatio = decimal.One,
+ Flow = new FlowPair(AccountType.ThirdPillar, AccountType.Wealth),
+ IsTaxable = true,
+ TaxType = TaxType.CapitalBenefits
+ }
+ };
+
+ holder.Composites = thirdPillarInvestmentDefinitions
+ .Concat(CreateSalaryPaymentDefinition(person, scenarioModel.FinalYear))
+ .ToList();
+ holder.CashFlowActions = thirdPillarWithdrawalDefinitions.ToList();
+
+ return holder;
+ }
+
+ CashFlowDefinitionHolder CreateBenchmarkDefinitions()
+ {
+ CashFlowDefinitionHolder holder = new CashFlowDefinitionHolder();
+
+ holder.InvestmentDefinitions = new List
+ {
+ new()
+ {
+ Header = new CashFlowHeader {Id = Guid.NewGuid().ToString(), Name = "Self Investment"},
+ DateOfProcess = new DateTime(startingYear, 1, 1),
+ NetCapitalGrowthRate = scenarioModel.InvestmentNetGrowthRate,
+ NetIncomeRate = scenarioModel.InvestmentNetIncomeYield,
+ InitialInvestment = 0,
+ RecurringInvestment = new RecurringInvestment
+ {
+ Amount = scenarioModel.InvestmentAmount,
+ Frequency = FrequencyType.Yearly,
+ },
+ InvestmentPeriod = new InvestmentPeriod
+ {
+ Year = startingYear,
+ NumberOfPeriods = scenarioModel.FinalYear - startingYear + 1,
+ }
+ }
+ };
+
+ holder.Composites = CreateSalaryPaymentDefinition(person, scenarioModel.FinalYear).ToList();
+
+ return holder;
+ }
+ }
+
+ ///
+ /// Compares the pension income versus capital consumption scenario. Pension income is the benchmark scenario.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public async Task> PensionVersusCapitalComparisonAsync(
+ int calculationYear,
+ int municipalityId,
+ decimal yearConsumptionAmount,
+ decimal retirementPension,
+ decimal retirementCapital,
+ decimal netWealthReturn,
+ TaxPerson taxPerson)
+ {
+ const int numberOfYears = 25;
+
+ var birthdate = new DateTime(1969, 3, 17);
+
+ MultiPeriodOptions options = new()
+ {
+ CapitalBenefitsNetGrowthRate = decimal.Zero,
+ WealthNetGrowthRate = netWealthReturn,
+ SavingsQuota = decimal.Zero
+ };
+
+ CashFlowDefinitionHolder benchmarkDefinitionHolder = CreateBenchmarkCashFlowDefinitions();
+
+ Either municipalityData =
+ await municipalityResolver.GetAsync(municipalityId, calculationYear);
+
+ Either benchmarkScenarioResult = await municipalityData
+ .BindAsync(m => multiPeriodCashFlowCalculator.CalculateAsync(
+ calculationYear, 0, GetPerson(m, birthdate, taxPerson), benchmarkDefinitionHolder, options));
+
+ Either> benchmarkSeriesResult = benchmarkScenarioResult
+ .Map(r => r.Accounts
+ .Where(a => a.AccountType is AccountType.Wealth));
+
+ CashFlowDefinitionHolder scenarioDefinitionHolder = CreateScenarioCashFlowDefinitions();
+
+ Either scenarioResult = await municipalityData
+ .BindAsync(m => multiPeriodCashFlowCalculator.CalculateAsync(
+ calculationYear, 0, GetPerson(m, birthdate, taxPerson), scenarioDefinitionHolder, options));
+
+ Either> scenarioSeriesResult = scenarioResult
+ .Map(r => r.Accounts
+ .Where(a => a.AccountType is AccountType.Wealth));
+
+ var result = from b in benchmarkSeriesResult
+ from s in scenarioSeriesResult
+ select CalculateDeltaForPensionVersusCapitalComparison(b.ToList(), s.ToList());
+
+ return from r in result
+ from br in benchmarkScenarioResult
+ from sr in scenarioResult
+ select SetTransactions(r, br, sr);
+
+ ScenarioCalculationResult SetTransactions(
+ ScenarioCalculationResult finalResult, MultiPeriodCalculationResult multiPeriodBenchmarkResult, MultiPeriodCalculationResult multiPeriodScenarioResult)
+ {
+ finalResult.BenchmarkTransactions = new AccountTransactionResultHolder()
+ {
+ WealthAccount = multiPeriodBenchmarkResult.Transactions.WealthAccount
+ };
+
+ // set scenario transactions
+ finalResult.ScenarioTransactions = new AccountTransactionResultHolder()
+ {
+ WealthAccount = multiPeriodScenarioResult.Transactions.WealthAccount
+ };
+
+ return finalResult;
+ }
+
+ CashFlowDefinitionHolder CreateBenchmarkCashFlowDefinitions()
+ {
+ CashFlowDefinitionHolder holder = new CashFlowDefinitionHolder();
+
+ holder.Composites = CreateBenchmarkDefinitions(calculationYear + numberOfYears).ToList();
+
+ return holder;
+ }
+
+ CashFlowDefinitionHolder CreateScenarioCashFlowDefinitions()
+ {
+ CashFlowDefinitionHolder holder = new CashFlowDefinitionHolder();
+
+ holder.Composites = CreateScenarioDefinitions(calculationYear + numberOfYears).ToList();
+ holder.CashFlowActions = new List
+ {
+ new DynamicTransferAccountAction()
+ {
+ Header =
+ new CashFlowHeader
+ {
+ Id = Guid.NewGuid().ToString(), Name = "Capital Benefit Withdrawal"
+ },
+ DateOfProcess = new DateTime(calculationYear, 1, 2),
+ TransferRatio = decimal.One,
+ Flow = new FlowPair(AccountType.OccupationalPension, AccountType.Wealth),
+ IsTaxable = true,
+ TaxType = TaxType.CapitalBenefits
+ }
+ };
+
+ return holder;
+ }
+
+ IEnumerable CreateBenchmarkDefinitions(int finalYear)
+ {
+ yield return new SalaryPaymentsDefinition
+ {
+ YearlyAmount = taxPerson.TaxableIncome,
+ DateOfEndOfPeriod = new DateTime(finalYear, 1, 1),
+ };
+
+ yield return new SalaryPaymentsDefinition
+ {
+ YearlyAmount = retirementPension,
+ DateOfEndOfPeriod = new DateTime(finalYear, 1, 1),
+ };
+
+ yield return new SetupAccountDefinition
+ {
+ InitialWealth = taxPerson.TaxableWealth
+ };
+
+ decimal deltaWealthConsumption = (taxPerson.TaxableIncome + retirementPension) - yearConsumptionAmount;
+
+ foreach (var fixedTransferDefinition in CreateYearlyWealthConsumption(deltaWealthConsumption))
+ {
+ yield return fixedTransferDefinition;
+ }
+ }
+
+ IEnumerable CreateScenarioDefinitions(int finalYear)
+ {
+ yield return new SalaryPaymentsDefinition
+ {
+ YearlyAmount = taxPerson.TaxableIncome,
+ DateOfEndOfPeriod = new DateTime(finalYear, 1, 1),
+ };
+
+ yield return new SetupAccountDefinition
+ {
+ InitialOccupationalPensionAssets = retirementCapital,
+ InitialWealth = taxPerson.TaxableWealth
+ };
+
+ decimal deltaWealthConsumption = taxPerson.TaxableIncome - yearConsumptionAmount;
+
+ foreach (var fixedTransferDefinition in CreateYearlyWealthConsumption(deltaWealthConsumption))
+ {
+ yield return fixedTransferDefinition;
+ }
+ }
+
+ IEnumerable CreateYearlyWealthConsumption(decimal deltaWealthConsumption)
+ {
+ foreach (var year in Enumerable.Range(calculationYear, numberOfYears))
+ {
+ yield return new FixedTransferAmountDefinition
+ {
+ Header = new CashFlowHeader { Id = Guid.NewGuid().ToString(), Name = $"Wealth Consumption in {year}" },
+ DateOfProcess = new DateTime(year, 12, 31),
+ TransferAmount = Math.Abs(deltaWealthConsumption),
+ Flow = new FlowPair(AccountType.Wealth, AccountType.Exogenous),
+ IsTaxable = false,
+ TaxType = TaxType.Undefined
+ };
+ }
+ }
+ }
+
+ private static IEnumerable CreateSalaryPaymentDefinition(
+ TaxPerson person, int finalYear)
+ {
+ ICompositeCashFlowDefinition salaryPaymentDefinition = new SalaryPaymentsDefinition
+ {
+ YearlyAmount = person.TaxableIncome,
+ DateOfEndOfPeriod = new DateTime(finalYear, 1, 1),
+ };
+
+ return new List { salaryPaymentDefinition };
+ }
+
+ private IEnumerable GetClearAccountAction(CapitalBenefitTransferInsScenarioModel scenarioModel)
+ {
+ if (scenarioModel is { WithCapitalBenefitWithdrawal: false })
+ {
+ yield break;
+ }
+
+ foreach (var withdrawal in scenarioModel.Withdrawals)
+ {
+ DateTime withdrawalDate = new DateTime(withdrawal.DateOfTransferIn.Year, 12, 31);
+
+ yield return new DynamicTransferAccountAction
+ {
+ Header = new CashFlowHeader { Id = Guid.NewGuid().ToString(), Name = "Capital Benefit Withdrawal" },
+ DateOfProcess = withdrawalDate,
+ TransferRatio = withdrawal.Amount,
+ Flow = new FlowPair(AccountType.OccupationalPension, AccountType.Wealth),
+ IsTaxable = true,
+ TaxType = TaxType.CapitalBenefits
+ };
+ }
+ }
+
+ private static IEnumerable CreateTransferInDefinitions(CapitalBenefitTransferInsScenarioModel scenarioModel)
+ {
+ // one purchase transfer-in for each single transfer-in
+ // as they might not be continuously
+ foreach (var singleTransferIn in scenarioModel.TransferIns)
+ {
+ yield return new PurchaseInsuranceYearsPaymentsDefinition
+ {
+ DateOfStart = singleTransferIn.DateOfTransferIn,
+ NetGrowthRate = scenarioModel.NetReturnCapitalBenefits,
+ NumberOfInvestments = 1,
+ YearlyAmount = singleTransferIn.Amount,
+ };
+ }
+ }
+
+ private static IEnumerable CreateDefaultComposites(
+ TaxPerson person, CapitalBenefitTransferInsScenarioModel scenarioModel)
+ {
+ DateTime finalSalaryPaymentDate = scenarioModel.TransferIns.Max(t => t.DateOfTransferIn).AddYears(1);
+
+ DateTime finalDate = scenarioModel.WithCapitalBenefitWithdrawal
+ ? scenarioModel.Withdrawals.Min(w => w.DateOfTransferIn)
+ : finalSalaryPaymentDate;
+
+ yield return new SalaryPaymentsDefinition
+ {
+ YearlyAmount = person.TaxableIncome,
+ DateOfEndOfPeriod = finalDate,
+ NetGrowthRate = decimal.Zero,
+ };
+
+ yield return new SetupAccountDefinition
+ {
+ InitialOccupationalPensionAssets = decimal.Zero,
+ InitialWealth = person.TaxableWealth
+ };
+
+ yield return new FixedTransferAmountDefinition
+ {
+ DateOfProcess = new DateTime(finalDate.Year, 1, 1),
+ Flow = new FlowPair(AccountType.Exogenous, AccountType.OccupationalPension),
+ TransferAmount = scenarioModel.CapitalBenefitsBeforeWithdrawal,
+ TaxType = TaxType.Undefined,
+ IsTaxable = false,
+ };
+ }
+
+ private static ScenarioCalculationResult CalculateDeltaForPensionVersusCapitalComparison(
+ IReadOnlyCollection benchmark,
+ IReadOnlyCollection scenario)
+ {
+ IEnumerable deltaResults =
+ from b in benchmark.Where(item => item.AccountType == AccountType.Wealth)
+ from s in scenario.Where(item => item.AccountType == AccountType.Wealth)
+ where b.Year == s.Year
+ select new SinglePeriodCalculationResult
+ {
+ Amount = s.Amount - b.Amount,
+ Year = b.Year,
+ AccountType = AccountType.Exogenous
+ };
+
+ List deltaSeries = deltaResults.ToList();
+
+ return new ScenarioCalculationResult
+ {
+ StartingYear = Math.Min(benchmark.Min(a => a.Year), scenario.Min(a => a.Year)),
+ NumberOfPeriods = deltaSeries.Count,
+ BenchmarkSeries = benchmark.ToList(),
+ ScenarioSeries = scenario.ToList(),
+ DeltaSeries = deltaSeries
+ };
+ }
+
+ private static ScenarioCalculationResult CalculateDeltaForThirdPillarComparison(
+ IReadOnlyCollection benchmark,
+ IReadOnlyCollection scenario)
+ {
+ var sumBenchmarkSeries =
+ from w in benchmark.Where(item => item.AccountType == AccountType.Wealth)
+ from p in benchmark.Where(item => item.AccountType == AccountType.Investment)
+ where w.Year == p.Year
+ select new { Sum = w.Amount + p.Amount, w.Year };
+
+ var sumScenarioSeries =
+ from w in scenario.Where(item => item.AccountType == AccountType.Wealth)
+ from p in scenario.Where(item => item.AccountType == AccountType.ThirdPillar)
+ where w.Year == p.Year
+ select new { Sum = w.Amount + p.Amount, w.Year };
+
+ IEnumerable deltaResults = from bSum in sumBenchmarkSeries
+ from sSum in sumScenarioSeries
+ where bSum.Year == sSum.Year
+ select new SinglePeriodCalculationResult
+ {
+ Amount = sSum.Sum - bSum.Sum,
+ Year = bSum.Year,
+ AccountType = AccountType.Exogenous
+ };
+
+ List deltaSeries = deltaResults.ToList();
+
+ return new ScenarioCalculationResult
+ {
+ StartingYear = Math.Min(benchmark.Min(a => a.Year), scenario.Min(a => a.Year)),
+ NumberOfPeriods = deltaSeries.Count,
+ BenchmarkSeries = benchmark.ToList(),
+ ScenarioSeries = scenario.ToList(),
+ DeltaSeries = deltaSeries
+ };
+ }
+
+ private static ScenarioCalculationResult CalculateDelta(
+ IReadOnlyCollection benchmark,
+ IReadOnlyCollection scenario)
+ {
+ var sumBenchmarkSeries =
+ from w in benchmark.Where(item => item.AccountType == AccountType.Wealth)
+ from p in benchmark.Where(item => item.AccountType == AccountType.OccupationalPension)
+ where w.Year == p.Year
+ select new { Sum = w.Amount + p.Amount, w.Year };
+
+ var sumScenarioSeries =
+ from w in scenario.Where(item => item.AccountType == AccountType.Wealth)
+ from p in scenario.Where(item => item.AccountType == AccountType.OccupationalPension)
+ where w.Year == p.Year
+ select new { Sum = w.Amount + p.Amount, w.Year };
+
+ IEnumerable deltaResults = from bSum in sumBenchmarkSeries
+ from sSum in sumScenarioSeries
+ where bSum.Year == sSum.Year
+ select new SinglePeriodCalculationResult
+ {
+ Amount = sSum.Sum - bSum.Sum,
+ Year = bSum.Year,
+ AccountType = AccountType.Exogenous
+ };
+
+ List deltaSeries = deltaResults.ToList();
+
+ return new ScenarioCalculationResult
+ {
+ StartingYear = Math.Min(benchmark.Min(a => a.Year), scenario.Min(a => a.Year)),
+ NumberOfPeriods = deltaSeries.Count,
+ BenchmarkSeries = benchmark.ToList(),
+ ScenarioSeries = scenario.ToList(),
+ DeltaSeries = deltaSeries
+ };
+ }
+
+ private static MultiPeriodCalculatorPerson GetPerson(MunicipalityModel municipality, DateTime birthday, TaxPerson person)
+ {
+ return new MultiPeriodCalculatorPerson
+ {
+ CivilStatus = person.CivilStatus,
+ DateOfBirth = birthday,
+ Gender = Gender.Male,
+ Name = "Purchase Scenario",
+ Canton = municipality.Canton,
+ MunicipalityId = municipality.BfsNumber,
+ Income = person.TaxableIncome,
+ Wealth = person.TaxableWealth,
+ CapitalBenefits = (0, 0),
+ NumberOfChildren = 0,
+ PartnerReligiousGroupType = person.PartnerReligiousGroupType,
+ ReligiousGroupType = person.ReligiousGroupType,
+ };
+ }
+}
diff --git a/src/TaxCalculator/Mapping/MappingProfile.cs b/src/Application/Mapping/MappingProfile.cs
similarity index 84%
rename from src/TaxCalculator/Mapping/MappingProfile.cs
rename to src/Application/Mapping/MappingProfile.cs
index d37e58f5..eac4311f 100644
--- a/src/TaxCalculator/Mapping/MappingProfile.cs
+++ b/src/Application/Mapping/MappingProfile.cs
@@ -1,12 +1,10 @@
-using System;
-using System.Globalization;
+using System.Globalization;
using AutoMapper;
-using PensionCoach.Tools.CommonTypes.Municipality;
-using PensionCoach.Tools.CommonTypes.Tax;
-using PensionCoach.Tools.TaxCalculator.Abstractions.Models.Person;
-using Tax.Data.Abstractions.Models;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using Domain.Models.Tax.Person;
-namespace PensionCoach.Tools.TaxCalculator.Mapping
+namespace Application.Mapping
{
public class MappingProfile : Profile
{
diff --git a/src/Application/MultiPeriodCalculator/CashFlowCalculatorsServiceCollectionExtensions.cs b/src/Application/MultiPeriodCalculator/CashFlowCalculatorsServiceCollectionExtensions.cs
new file mode 100644
index 00000000..0d83255d
--- /dev/null
+++ b/src/Application/MultiPeriodCalculator/CashFlowCalculatorsServiceCollectionExtensions.cs
@@ -0,0 +1,13 @@
+using Application.Features.TaxScenarios;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Application.MultiPeriodCalculator;
+
+public static class CashFlowCalculatorsServiceCollectionExtensions
+{
+ public static void AddCashFlowCalculators(this IServiceCollection collection)
+ {
+ collection.AddTransient();
+ collection.AddTransient();
+ }
+}
diff --git a/src/Calculators.CashFlow/CashFlowHelperExtensions.cs b/src/Application/MultiPeriodCalculator/CashFlowHelperExtensions.cs
similarity index 86%
rename from src/Calculators.CashFlow/CashFlowHelperExtensions.cs
rename to src/Application/MultiPeriodCalculator/CashFlowHelperExtensions.cs
index b15f75de..02383eba 100644
--- a/src/Calculators.CashFlow/CashFlowHelperExtensions.cs
+++ b/src/Application/MultiPeriodCalculator/CashFlowHelperExtensions.cs
@@ -1,17 +1,15 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Calculators.CashFlow.Accounts;
-using Calculators.CashFlow.Models;
-using PensionCoach.Tools.BvgCalculator;
-using PensionCoach.Tools.CommonTypes;
-using PensionCoach.Tools.CommonTypes.MultiPeriod;
-using PensionCoach.Tools.CommonTypes.MultiPeriod.Actions;
-using PensionCoach.Tools.CommonTypes.MultiPeriod.Definitions;
+using Application.Extensions;
+using Domain.Contracts;
+using Domain.Enums;
+using Domain.Models.Cashflows;
+using Domain.Models.Cashflows.Accounts;
+using Domain.Models.MultiPeriod;
+using Domain.Models.MultiPeriod.Actions;
+using Domain.Models.MultiPeriod.Definitions;
using PensionCoach.Tools.CommonTypes.Tax;
using PensionCoach.Tools.CommonUtils;
-namespace Calculators.CashFlow;
+namespace Application.MultiPeriodCalculator;
public static class CashFlowHelperExtensions
{
@@ -180,6 +178,28 @@ public static IEnumerable CreateGenericDefinition(
IsTaxable = false,
TaxType = TaxType.CapitalBenefits
};
+
+ yield return new StaticGenericCashFlowDefinition
+ {
+ Header = new CashFlowHeader
+ {
+ Id = "setupInvestmentAccount",
+ Name = "Investment Account"
+ },
+ DateOfProcess = dateOfStart,
+ InitialAmount = accountDefinition.InitialInvestmentAssets,
+ Flow = new FlowPair(AccountType.Exogenous, AccountType.Investment),
+ RecurringInvestment = new RecurringInvestment
+ {
+ Amount = 0,
+ Frequency = FrequencyType.Yearly,
+ },
+ InvestmentPeriod = new InvestmentPeriod
+ {
+ Year = dateOfStart.Year,
+ NumberOfPeriods = 0
+ },
+ };
}
public static IEnumerable CreateGenericDefinition(
@@ -352,12 +372,35 @@ public static IEnumerable CreateGenericDefinition(thi
};
}
+ public static IEnumerable CreateGenericDefinition(this InvestmentPortfolioDefinition investmentPortfolioDefinition)
+ {
+ yield return new StaticGenericCashFlowDefinition
+ {
+ Header = investmentPortfolioDefinition.Header,
+ DateOfProcess = investmentPortfolioDefinition.DateOfProcess,
+ InitialAmount = decimal.Zero,
+ NetGrowthRate = investmentPortfolioDefinition.NetCapitalGrowthRate,
+ RecurringInvestment = investmentPortfolioDefinition.RecurringInvestment,
+ InvestmentPeriod = investmentPortfolioDefinition.InvestmentPeriod,
+ Flow = new FlowPair(AccountType.Wealth, AccountType.Investment),
+ };
+ }
+
public static IEnumerable CreateGenericDefinition(
- this ICompositeCashFlowDefinition cashFlowAction, MultiPeriodCalculatorPerson person, DateTime dateOfStart)
+ this ICompositeCashFlowDefinition cashFlowDefinition, MultiPeriodCalculatorPerson person, DateTime dateOfStart)
{
- return cashFlowAction switch
+ return cashFlowDefinition switch
{
OrdinaryRetirementAction a => a.CreateCashFlows(person),
+ _ => CreateGenericDefinition(cashFlowDefinition, dateOfStart)
+ };
+ }
+
+ public static IEnumerable CreateGenericDefinition(
+ this ICompositeCashFlowDefinition cashFlowDefinition, DateTime dateOfStart)
+ {
+ return cashFlowDefinition switch
+ {
SetupAccountDefinition s => s.CreateGenericDefinition(dateOfStart),
SalaryPaymentsDefinition p => p.CreateGenericDefinition(dateOfStart),
FixedTransferAmountDefinition t => t.CreateGenericDefinition(),
diff --git a/src/Application/MultiPeriodCalculator/IMultiPeriodCashFlowCalculator.cs b/src/Application/MultiPeriodCalculator/IMultiPeriodCashFlowCalculator.cs
new file mode 100644
index 00000000..8da9c005
--- /dev/null
+++ b/src/Application/MultiPeriodCalculator/IMultiPeriodCashFlowCalculator.cs
@@ -0,0 +1,33 @@
+using Domain.Models.MultiPeriod;
+using LanguageExt;
+using PensionCoach.Tools.CommonTypes.MultiPeriod;
+
+namespace Application.MultiPeriodCalculator
+{
+ public interface IMultiPeriodCashFlowCalculator
+ {
+ ///
+ /// Calculates how taxable assets evolves over time.
+ /// Takes a list of cash-flow definitions sums them up by groups of target/source pairs along the timeline.
+ /// Then, iterates along the timeline and calculates for a given year:
+ /// 1. adds cash-flow amount for the given year to its associated asset type
+ /// 2. calculates tax amount for each target asset type
+ /// 3. deduct tax amount from asset values
+ /// 4. move flow asset types to its stock asset type (ie. salary does not stay after paying tax for it but
+ /// is moved to taxable wealth).
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> CalculateAsync(
+ int startingYear,
+ int minimumNumberOfPeriods,
+ MultiPeriodCalculatorPerson person,
+ CashFlowDefinitionHolder cashFlowDefinitionHolder,
+ MultiPeriodOptions options);
+
+ }
+}
diff --git a/src/Application/MultiPeriodCalculator/MultiPeriodCashFlowCalculator.cs b/src/Application/MultiPeriodCalculator/MultiPeriodCashFlowCalculator.cs
new file mode 100644
index 00000000..73fb5d64
--- /dev/null
+++ b/src/Application/MultiPeriodCalculator/MultiPeriodCashFlowCalculator.cs
@@ -0,0 +1,481 @@
+using Application.Extensions;
+using Application.Municipality;
+using Application.Tax.Contracts;
+using Domain.Contracts;
+using Domain.Enums;
+using Domain.Models.Cashflows;
+using Domain.Models.Cashflows.Accounts;
+using Domain.Models.MultiPeriod;
+using Domain.Models.MultiPeriod.Actions;
+using Domain.Models.MultiPeriod.Definitions;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+using Microsoft.Extensions.Logging;
+using PensionCoach.Tools.CommonTypes.MultiPeriod;
+using PensionCoach.Tools.CommonTypes.Tax;
+
+namespace Application.MultiPeriodCalculator
+{
+ public class MultiPeriodCashFlowCalculator : IMultiPeriodCashFlowCalculator
+ {
+ private readonly IFullWealthAndIncomeTaxCalculator _fullTaxCalculator;
+ private readonly IFullCapitalBenefitTaxCalculator _capitalBenefitCalculator;
+ private readonly IMunicipalityConnector municipalityConnector;
+ private readonly ILogger _logger;
+
+ public MultiPeriodCashFlowCalculator(
+ IFullWealthAndIncomeTaxCalculator fullTaxCalculator,
+ IFullCapitalBenefitTaxCalculator capitalBenefitCalculator,
+ IMunicipalityConnector municipalityConnector,
+ ILogger logger)
+ {
+ _fullTaxCalculator = fullTaxCalculator;
+ _capitalBenefitCalculator = capitalBenefitCalculator;
+ this.municipalityConnector = municipalityConnector;
+ _logger = logger;
+ }
+
+ ///
+ public async Task> CalculateAsync(
+ int startingYear,
+ int minimumNumberOfPeriods,
+ MultiPeriodCalculatorPerson person,
+ CashFlowDefinitionHolder cashFlowDefinitionHolder,
+ MultiPeriodOptions options)
+ {
+ DateTime dateOfStart = new DateTime(startingYear, 1, 1);
+
+ ExogenousAccount exogenousAccount = new() { Id = Guid.NewGuid(), Name = "Exogenous Account", };
+
+ IncomeAccount incomeAccount = new() { Id = Guid.NewGuid(), Name = "Income Account", };
+
+ WealthAccount wealthAccount = new() { Id = Guid.NewGuid(), Name = "Wealth Account", };
+
+ InvestmentAccount investmentAccount = SetupInvestmentAccount(cashFlowDefinitionHolder);
+
+ OccupationalPensionAccount occupationalPensionAccount = new()
+ {
+ Id = Guid.NewGuid(), Name = "Occupational Pension Account",
+ };
+
+ ThirdPillarAccount thirdPillarAccount = new()
+ {
+ Id = Guid.NewGuid(),
+ Name = "Third Pillar Account",
+ };
+
+ TaxAccount taxAccount = new() { Id = Guid.NewGuid(), Name = "Tax Account", };
+
+ IEnumerable definitionFromInvestments = cashFlowDefinitionHolder.InvestmentDefinitions
+ .SelectMany(investment => investment.CreateGenericDefinition());
+
+ IEnumerable definitionFromComposites = cashFlowDefinitionHolder.Composites
+ .SelectMany(composite => composite.CreateGenericDefinition(person, dateOfStart));
+
+ IEnumerable staticCashFlowsFromComposites = definitionFromComposites
+ .Concat(definitionFromInvestments)
+ .OfType()
+ .SelectMany(d => d.GenerateCashFlow())
+ .AggregateCashFlows();
+
+ IEnumerable staticCashFlowsFromGenerics = cashFlowDefinitionHolder.StaticGenericCashFlowDefinitions
+ .SelectMany(d => d.GenerateCashFlow())
+ .AggregateCashFlows();
+
+ IEnumerable staticCashFlows = staticCashFlowsFromGenerics
+ .Concat(staticCashFlowsFromComposites)
+ .ToList();
+
+ var combinedList = definitionFromComposites
+ .Concat(cashFlowDefinitionHolder.CashFlowActions).ToList();
+
+ int finalYear = combinedList.Count > 0
+ ? combinedList.Max(d => d.DateOfProcess.Year)
+ : startingYear + minimumNumberOfPeriods;
+
+ List singlePeriodCalculationResults =
+ Enumerable.Empty().ToList();
+
+ Dictionary currentAccounts = new Dictionary
+ {
+ { AccountType.Exogenous, exogenousAccount },
+ { AccountType.Income, incomeAccount },
+ { AccountType.Wealth, wealthAccount },
+ { AccountType.Investment, investmentAccount },
+ { AccountType.OccupationalPension, occupationalPensionAccount },
+ { AccountType.ThirdPillar, thirdPillarAccount },
+ { AccountType.Tax, taxAccount }
+ };
+
+ MultiPeriodCalculatorPerson currentPerson = person;
+ for (int currentYear = startingYear; currentYear <= finalYear; currentYear++)
+ {
+ DateOnly startingDate = new DateOnly(currentYear, 1, 1);
+ DateOnly finalDate = new DateOnly(currentYear, 1, 1).AddYears(1);
+
+ // all days in the current year
+ for (DateOnly currentDate = startingDate; currentDate < finalDate; currentDate = currentDate.AddDays(1))
+ {
+ var currentDateAsDateTime = currentDate.ToDateTime(TimeOnly.MinValue);
+
+ List currentDateStaticCashFlows = staticCashFlows
+ .Where(item => item.DateOfProcess == currentDate)
+ .ToList();
+
+ List currentDateChangeResidenceActions = cashFlowDefinitionHolder
+ .ChangeResidenceActions
+ .Where(item => item.DateOfProcess == currentDateAsDateTime)
+ .ToList();
+
+ List currentDateDynamicCashFlows = definitionFromComposites
+ .Concat(cashFlowDefinitionHolder.CashFlowActions)
+ .OfType()
+ .Where(item => item.DateOfProcess == currentDateAsDateTime)
+ .SelectMany(item => item.CreateGenericDefinition(currentAccounts))
+ .SelectMany(item => item.GenerateCashFlow())
+ .ToList();
+
+ // 1. change residence
+ foreach (var changeAction in currentDateChangeResidenceActions.OfType())
+ {
+ currentPerson = currentPerson with
+ {
+ MunicipalityId = changeAction.DestinationMunicipalityId,
+ Canton = changeAction.DestinationCanton,
+ };
+
+ currentAccounts = ProcessResidenceChangeAction(currentAccounts, changeAction);
+ }
+
+ // 2. process simple cash-flow: move amount from source to target account
+ foreach (CashFlowModel cashFlow in currentDateStaticCashFlows.Concat(currentDateDynamicCashFlows))
+ {
+ currentAccounts = await ProcessSimpleCashFlowAsync(currentAccounts, cashFlow, person);
+ }
+ }
+
+ currentAccounts = await ProcessEndOfYearSettlementAsync(currentAccounts, currentPerson, finalDate, options);
+
+ // collect calculation results
+ int year = currentYear;
+ currentAccounts
+ .Select(pair => new SinglePeriodCalculationResult
+ {
+ Year = year, Amount = pair.Value.Balance, AccountType = pair.Key
+ })
+ .Iter(item => singlePeriodCalculationResults.Add(item));
+ }
+
+ var accountTransactionResult = new AccountTransactionResult
+ {
+ Id = exogenousAccount.Id,
+ Name = exogenousAccount.Name,
+ Transactions = exogenousAccount.Transactions,
+ };
+
+ var incomeTransactionResult = new AccountTransactionResult
+ {
+ Id = incomeAccount.Id,
+ Name = incomeAccount.Name,
+ Transactions = incomeAccount.Transactions,
+ };
+
+ var wealthTransactionResult = new AccountTransactionResult
+ {
+ Id = wealthAccount.Id,
+ Name = wealthAccount.Name,
+ Transactions = wealthAccount.Transactions,
+ };
+
+ var investmentTransactionResult = new AccountTransactionResult
+ {
+ Id = investmentAccount.Id,
+ Name = investmentAccount.Name,
+ Transactions = investmentAccount.Transactions,
+ };
+
+ var occupationalTransactionResult = new AccountTransactionResult
+ {
+ Id = occupationalPensionAccount.Id,
+ Name = occupationalPensionAccount.Name,
+ Transactions = occupationalPensionAccount.Transactions,
+ };
+
+ var thirdPillarTransactionResult = new AccountTransactionResult
+ {
+ Id = thirdPillarAccount.Id,
+ Name = thirdPillarAccount.Name,
+ Transactions = thirdPillarAccount.Transactions,
+ };
+
+ var taxTransactionResult = new AccountTransactionResult
+ {
+ Id = taxAccount.Id,
+ Name = taxAccount.Name,
+ Transactions = taxAccount.Transactions,
+ };
+
+ return new MultiPeriodCalculationResult
+ {
+ StartingYear = startingYear,
+ NumberOfPeriods = finalYear - startingYear + 1,
+ Accounts = singlePeriodCalculationResults,
+ Transactions = new AccountTransactionResultHolder
+ {
+ ExogenousAccount = accountTransactionResult,
+ IncomeAccount = incomeTransactionResult,
+ WealthAccount = wealthTransactionResult,
+ InvestmentAccount = investmentTransactionResult,
+ OccupationalPensionAccount = occupationalTransactionResult,
+ ThirdPillarAccount = thirdPillarTransactionResult,
+ TaxAccount = taxTransactionResult
+ }
+ };
+ }
+
+ private static InvestmentAccount SetupInvestmentAccount(CashFlowDefinitionHolder cashFlowDefinitionHolder)
+ {
+ InvestmentPortfolioDefinition firstInvestmentAccount = cashFlowDefinitionHolder.InvestmentDefinitions.FirstOrDefault();
+
+ if (firstInvestmentAccount == null)
+ {
+ return new InvestmentAccount
+ {
+ Id = Guid.NewGuid(),
+ Name = "Investment Account",
+ NetGrowthRate = 0.0M,
+ NetIncomeYield = 0.0M,
+ };
+ }
+
+ return new()
+ {
+ Id = Guid.NewGuid(),
+ Name = firstInvestmentAccount.Header.Name,
+ NetGrowthRate = firstInvestmentAccount.NetCapitalGrowthRate,
+ NetIncomeYield = firstInvestmentAccount.NetIncomeRate,
+ };
+ }
+
+ public Task> CalculateWithSetupsAsync(
+ int startingYear,
+ int minimumNumberOfPeriods,
+ MultiPeriodCalculatorPerson person,
+ CashFlowDefinitionHolder cashFlowDefinitionHolder,
+ MultiPeriodOptions options)
+ {
+ ICompositeCashFlowDefinition accountSetupDefinition = new SetupAccountDefinition
+ {
+ InitialOccupationalPensionAssets = person.CapitalBenefits.PensionPlan + person.CapitalBenefits.Pillar3a,
+ InitialWealth = person.Wealth
+ };
+
+ CashFlowDefinitionHolder extendedDefinitionHolder = cashFlowDefinitionHolder with
+ {
+ Composites = cashFlowDefinitionHolder.Composites
+ .Concat(new[] { accountSetupDefinition })
+ .ToList()
+ };
+
+ return CalculateAsync(startingYear, minimumNumberOfPeriods, person, extendedDefinitionHolder, options);
+ }
+
+ private async Task> ProcessSimpleCashFlowAsync(
+ Dictionary currentAccounts, CashFlowModel cashFlow, MultiPeriodCalculatorPerson person)
+ {
+ ICashFlowAccount creditAccount = currentAccounts[cashFlow.Target];
+ ICashFlowAccount debitAccount = currentAccounts[cashFlow.Source];
+
+ ExecuteTransaction(debitAccount, creditAccount, "Simple cash-flow", cashFlow.DateOfProcess.ToDateTime(TimeOnly.MinValue), cashFlow.Amount);
+
+ if (cashFlow.IsTaxable)
+ {
+ // Tax reduces wealth as transaction is taxable.
+ if (cashFlow.TaxType == TaxType.CapitalBenefits)
+ {
+ var taxPaymentAmount = await CalculateCapitalBenefitsTaxAsync(cashFlow.DateOfProcess.Year, person, cashFlow.Amount);
+
+ ICashFlowAccount wealthAccount = currentAccounts[AccountType.Wealth];
+ ICashFlowAccount taxAccount = currentAccounts[AccountType.Tax];
+
+ ExecuteTransaction(wealthAccount, taxAccount, "Tax payment", cashFlow.DateOfProcess.ToDateTime(TimeOnly.MinValue), taxPaymentAmount);
+ }
+ }
+
+ return currentAccounts;
+ }
+
+ private Dictionary ProcessResidenceChangeAction(
+ Dictionary currentAccounts, ChangeResidenceAction action)
+ {
+ ICashFlowAccount wealthAccount = currentAccounts[AccountType.Wealth];
+ ICashFlowAccount exogenousAccount = currentAccounts[AccountType.Exogenous];
+
+ ExecuteTransaction(wealthAccount, exogenousAccount, "Residence change costs", action.DateOfProcess, action.ChangeCost);
+
+ return currentAccounts;
+ }
+
+ private async Task> ProcessEndOfYearSettlementAsync(
+ Dictionary currentAccounts,
+ MultiPeriodCalculatorPerson person,
+ DateOnly finalDate,
+ MultiPeriodOptions options)
+ {
+ ICashFlowAccount incomeAccount = currentAccounts[AccountType.Income];
+ ICashFlowAccount wealthAccount = currentAccounts[AccountType.Wealth];
+ ICashFlowAccount investmentAccount = currentAccounts[AccountType.Investment];
+ ICashFlowAccount exogenousAccount = currentAccounts[AccountType.Exogenous];
+ ICashFlowAccount occupationalPensionAccount = currentAccounts[AccountType.OccupationalPension];
+ ICashFlowAccount thirdPillarAccount = currentAccounts[AccountType.ThirdPillar];
+ ICashFlowAccount taxAccount = currentAccounts[AccountType.Tax];
+
+ DateTime finalDateAsDateTime = finalDate.ToDateTime(TimeOnly.MinValue);
+
+ // compound wealth and capital benefits accounts
+ // todo: instead of assuming account balance is compounded a full year a time-weighted calculation should be used (TWR).
+ // This can be achieved by the ordered list of transaction on the accounts.
+ decimal wealthCompoundedReturn = wealthAccount.Balance * options.WealthNetGrowthRate;
+ ExecuteTransaction(exogenousAccount, wealthAccount, "Compound Return Wealth", finalDateAsDateTime, wealthCompoundedReturn);
+
+ InvestmentTransactions(investmentAccount);
+
+ decimal occupationalPensionCompoundedReturn = occupationalPensionAccount.Balance * options.CapitalBenefitsNetGrowthRate;
+ ExecuteTransaction(exogenousAccount, occupationalPensionAccount, "Compound Return Occupational Pension", finalDateAsDateTime, occupationalPensionCompoundedReturn);
+
+ decimal thirdPillarCompoundedReturn = thirdPillarAccount.Balance * options.CapitalBenefitsNetGrowthRate;
+ ExecuteTransaction(exogenousAccount, thirdPillarAccount, "Compound Return Third Pillar", finalDateAsDateTime, thirdPillarCompoundedReturn);
+
+ // savings quota: take share from current income account and move it to wealth
+ decimal newSavings = incomeAccount.Balance * SavingsQuota(finalDate, options, person);
+
+ // savings are subject to wealth tax but are not deducted from the income/salary account to keep taxable salary amount clean
+ ExecuteTransaction(exogenousAccount, wealthAccount, "Savings Quota", finalDateAsDateTime, newSavings);
+
+ // take each account amount, calculate tax, and deduct it from wealth
+ var totalTaxAmount =
+ await CalculateIncomeAndWealthTaxAsync(finalDate.Year, person, incomeAccount.Balance, wealthAccount.Balance + investmentAccount.Balance);
+
+ ExecuteTransaction(wealthAccount, taxAccount, "Yearly Income and Wealth Tax", finalDateAsDateTime, totalTaxAmount);
+
+ // Clear income account as it begin at 0 in the new year
+ ExecuteTransaction(incomeAccount, exogenousAccount, "Clear income account", finalDateAsDateTime, incomeAccount.Balance);
+
+ return currentAccounts;
+
+ void InvestmentTransactions(ICashFlowAccount cashFlowAccount)
+ {
+ var account = cashFlowAccount as InvestmentAccount;
+
+ if (account == null)
+ {
+ throw new ArgumentException($"Account {cashFlowAccount.Name} is not a investment account", nameof(cashFlowAccount));
+ }
+
+ decimal investmentCapitalGain = account.Balance * account.NetGrowthRate;
+ decimal investmentIncome = account.Balance * account.NetIncomeYield;
+
+ ExecuteTransaction(exogenousAccount, investmentAccount, "Capital Gains", finalDateAsDateTime, investmentCapitalGain);
+ ExecuteTransaction(exogenousAccount, investmentAccount, "Income", finalDateAsDateTime, investmentIncome);
+
+ // income is taxable
+ ExecuteTransaction(exogenousAccount, incomeAccount, "Investement Income", finalDateAsDateTime, investmentCapitalGain);
+ }
+ }
+
+ private async Task CalculateIncomeAndWealthTaxAsync(
+ int currentYear, MultiPeriodCalculatorPerson calculatorPerson, decimal income, decimal wealth)
+ {
+ TaxPerson taxPerson = new()
+ {
+ Name = calculatorPerson.Name,
+ CivilStatus = calculatorPerson.CivilStatus,
+ NumberOfChildren = calculatorPerson.NumberOfChildren,
+ ReligiousGroupType = calculatorPerson.ReligiousGroupType,
+ PartnerReligiousGroupType = calculatorPerson.PartnerReligiousGroupType,
+ TaxableWealth = Math.Max(0, wealth),
+ TaxableFederalIncome = income,
+ TaxableIncome = income
+ };
+
+ Either municipality =
+ await municipalityConnector.GetAsync(calculatorPerson.MunicipalityId, currentYear);
+
+ Either result = await municipality
+ .BindAsync(m => _fullTaxCalculator.CalculateAsync(currentYear, m, taxPerson, true));
+
+ return result.Match(
+ Right: r => r.TotalTaxAmount,
+ Left: error =>
+ {
+ _logger.LogError(error);
+ return decimal.Zero;
+ });
+ }
+
+ private async Task CalculateCapitalBenefitsTaxAsync(
+ int currentYear, MultiPeriodCalculatorPerson person, decimal amount)
+ {
+ CapitalBenefitTaxPerson taxPerson = new()
+ {
+ Name = person.Name,
+ CivilStatus = person.CivilStatus,
+ NumberOfChildren = person.NumberOfChildren,
+ ReligiousGroupType = person.ReligiousGroupType,
+ PartnerReligiousGroupType = person.PartnerReligiousGroupType,
+ TaxableCapitalBenefits = amount
+ };
+
+ Either municipality =
+ await municipalityConnector.GetAsync(person.MunicipalityId, currentYear);
+
+ Either result = await municipality
+ .BindAsync(m => _capitalBenefitCalculator.CalculateAsync(
+ currentYear, m, taxPerson, true));
+
+ return result.Match(
+ Right: r => r.TotalTaxAmount,
+ Left: error =>
+ {
+ _logger.LogError(error);
+ return decimal.Zero;
+
+ });
+ }
+
+ private static void ExecuteTransaction(
+ ICashFlowAccount debitAccount, ICashFlowAccount creditAccount, string description, DateTime transactionDate, decimal amount)
+ {
+ if (amount == decimal.Zero)
+ {
+ return;
+ }
+
+ AccountTransaction trxCreditAccount =
+ new($"{description}: inflow from {debitAccount.Name}", transactionDate, amount);
+
+ creditAccount.Balance += amount;
+ creditAccount.Transactions.Add(trxCreditAccount);
+
+
+ AccountTransaction trxDebitAccount =
+ new($"{description}: outflow to {creditAccount.Name}", transactionDate, -amount);
+
+ debitAccount.Balance -= amount;
+ debitAccount.Transactions.Add(trxDebitAccount);
+ }
+
+ private decimal SavingsQuota(DateOnly dateOfValidity, MultiPeriodOptions options, MultiPeriodCalculatorPerson person)
+ {
+ DateOnly retirementDate = DateOnly.FromDateTime(person.DateOfBirth.GetRetirementDate(person.Gender));
+
+ if (dateOfValidity < retirementDate)
+ {
+ return options.SavingsQuota;
+ }
+
+ return decimal.Zero;
+ }
+ }
+}
diff --git a/src/TaxCalculator.Abstractions/IMunicipalityConnector.cs b/src/Application/Municipality/IMunicipalityConnector.cs
similarity index 70%
rename from src/TaxCalculator.Abstractions/IMunicipalityConnector.cs
rename to src/Application/Municipality/IMunicipalityConnector.cs
index eca0a337..b28bce84 100644
--- a/src/TaxCalculator.Abstractions/IMunicipalityConnector.cs
+++ b/src/Application/Municipality/IMunicipalityConnector.cs
@@ -1,10 +1,8 @@
-using System.Collections.Generic;
-using System.Threading.Tasks;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
using LanguageExt;
-using PensionCoach.Tools.CommonTypes.Municipality;
-using PensionCoach.Tools.CommonTypes.Tax;
-namespace PensionCoach.Tools.TaxCalculator.Abstractions
+namespace Application.Municipality
{
public interface IMunicipalityConnector
{
diff --git a/src/Application/Municipality/IMunicipalityRepository.cs b/src/Application/Municipality/IMunicipalityRepository.cs
new file mode 100644
index 00000000..0090c2be
--- /dev/null
+++ b/src/Application/Municipality/IMunicipalityRepository.cs
@@ -0,0 +1,15 @@
+using Domain.Models.Municipality;
+
+namespace Application.Municipality
+{
+ public interface IMunicipalityRepository
+ {
+ IEnumerable GetAll();
+
+ MunicipalityEntity Get(int bfsNumber, int year);
+
+ IEnumerable GetAllSupportTaxCalculation();
+
+ IEnumerable Search(MunicipalitySearchFilter searchFilter);
+ }
+}
diff --git a/src/Application/Municipality/ProprietaryMunicipalityConnector.cs b/src/Application/Municipality/ProprietaryMunicipalityConnector.cs
new file mode 100644
index 00000000..38624f25
--- /dev/null
+++ b/src/Application/Municipality/ProprietaryMunicipalityConnector.cs
@@ -0,0 +1,98 @@
+using Application.Tax.Proprietary.Repositories;
+using AutoMapper;
+using Domain.Enums;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Municipality
+{
+ public class ProprietaryMunicipalityConnector : IMunicipalityConnector
+ {
+ private readonly IMapper mapper;
+ private readonly IMunicipalityRepository municipalityRepository;
+ private readonly IStateTaxRateRepository stateTaxRateRepository;
+
+ public ProprietaryMunicipalityConnector(
+ IMapper mapper,
+ IMunicipalityRepository municipalityRepository,
+ IStateTaxRateRepository stateTaxRateRepository)
+ {
+ this.mapper = mapper;
+ this.municipalityRepository = municipalityRepository;
+ this.stateTaxRateRepository = stateTaxRateRepository;
+ }
+
+ public Task> GetAllAsync()
+ {
+ return Task.FromResult(mapper.Map>(municipalityRepository.GetAll()));
+ }
+
+ ///
+ /// Searches the specified search filter.
+ ///
+ /// The search filter.
+ /// List of municipalities.
+ public IEnumerable Search(MunicipalitySearchFilter searchFilter)
+ {
+ foreach (var entity in municipalityRepository.Search(searchFilter))
+ {
+ var model = mapper.Map(entity);
+
+ if (searchFilter.YearOfValidity.HasValue)
+ {
+ if (!model.DateOfMutation.HasValue)
+ {
+ yield return model;
+ }
+ else if (model.DateOfMutation.Value.Year > searchFilter.YearOfValidity)
+ {
+ yield return model;
+ }
+ }
+ else
+ {
+ yield return model;
+ }
+ }
+ }
+
+ public Task> GetAsync(int bfsNumber, int year)
+ {
+ Option entity = municipalityRepository.GetAll()
+ .FirstOrDefault(item => item.BfsNumber == bfsNumber
+ && string.IsNullOrEmpty(item.DateOfMutation));
+
+ return entity
+ .Match>(
+ Some: item => mapper.Map(item),
+ None: () => $"Municipality not found by BFS number {bfsNumber}")
+ .AsTask();
+ }
+
+ ///
+ public Task> GetAllSupportTaxCalculationAsync()
+ {
+ IReadOnlyCollection municipalities =
+ stateTaxRateRepository.TaxRates()
+ .AsEnumerable()
+ .GroupBy(keySelector => new
+ {
+ Id = keySelector.BfsId,
+ Name = keySelector.MunicipalityName,
+ keySelector.Canton,
+ })
+ .Select(item => new TaxSupportedMunicipalityModel
+ {
+ BfsMunicipalityNumber = item.Key.Id,
+ Name = item.Key.Name,
+ Canton = Enum.Parse(item.Key.Canton),
+ MaxSupportedYear = item.Max(entity => entity.Year),
+ })
+ .OrderBy(item => item.Name)
+ .ToList();
+
+ return Task.FromResult(municipalities);
+ }
+ }
+}
diff --git a/src/Application/Tax.Contracts/IFullCapitalBenefitTaxCalculator.cs b/src/Application/Tax.Contracts/IFullCapitalBenefitTaxCalculator.cs
new file mode 100644
index 00000000..ce8bd4df
--- /dev/null
+++ b/src/Application/Tax.Contracts/IFullCapitalBenefitTaxCalculator.cs
@@ -0,0 +1,14 @@
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Tax.Contracts;
+
+public interface IFullCapitalBenefitTaxCalculator
+{
+ Task> CalculateAsync(
+ int calculationYear,
+ MunicipalityModel municipality,
+ CapitalBenefitTaxPerson person,
+ bool withMaxAvailableCalculationYear = false);
+}
diff --git a/src/Application/Tax.Contracts/IFullWealthAndIncomeTaxCalculator.cs b/src/Application/Tax.Contracts/IFullWealthAndIncomeTaxCalculator.cs
new file mode 100644
index 00000000..7f85edc4
--- /dev/null
+++ b/src/Application/Tax.Contracts/IFullWealthAndIncomeTaxCalculator.cs
@@ -0,0 +1,11 @@
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Tax.Contracts;
+
+public interface IFullWealthAndIncomeTaxCalculator
+{
+ Task> CalculateAsync(
+ int calculationYear, MunicipalityModel municipality, TaxPerson person, bool withMaxAvailableCalculationYear = false);
+}
diff --git a/src/Application/Tax.Estv/Client/IEstvCalculatorClient.cs b/src/Application/Tax.Estv/Client/IEstvCalculatorClient.cs
new file mode 100644
index 00000000..8c52957c
--- /dev/null
+++ b/src/Application/Tax.Estv/Client/IEstvCalculatorClient.cs
@@ -0,0 +1,14 @@
+using Application.Tax.Estv.Client.Models;
+using Domain.Models.Tax;
+
+namespace Application.Tax.Estv.Client
+{
+ public interface IEstvTaxCalculatorClient
+ {
+ Task GetTaxLocationsAsync(string zip, string city);
+
+ Task CalculateIncomeAndWealthTaxAsync(int taxLocationId, int taxYear, TaxPerson person);
+
+ Task CalculateCapitalBenefitTaxAsync(int taxLocationId, int taxYear, CapitalBenefitTaxPerson person);
+ }
+}
diff --git a/src/Application/Tax.Estv/Client/Models/ChildModel.cs b/src/Application/Tax.Estv/Client/Models/ChildModel.cs
new file mode 100644
index 00000000..7e8f3b09
--- /dev/null
+++ b/src/Application/Tax.Estv/Client/Models/ChildModel.cs
@@ -0,0 +1,7 @@
+namespace Application.Tax.Estv.Client.Models
+{
+ public class ChildModel
+ {
+ public int Age { get; set; }
+ }
+}
diff --git a/src/Application/Tax.Estv/Client/Models/SimpleCapitalTaxResult.cs b/src/Application/Tax.Estv/Client/Models/SimpleCapitalTaxResult.cs
new file mode 100644
index 00000000..fe1f2d09
--- /dev/null
+++ b/src/Application/Tax.Estv/Client/Models/SimpleCapitalTaxResult.cs
@@ -0,0 +1,12 @@
+namespace Application.Tax.Estv.Client.Models
+{
+ public class
+ SimpleCapitalTaxResult
+ {
+ public int TaxCanton { get; set; }
+ public int TaxChurch { get; set; }
+ public int TaxCity { get; set; }
+ public int TaxFed { get; set; }
+ public TaxLocation Location { get; set; }
+ }
+}
diff --git a/src/Application/Tax.Estv/Client/Models/SimpleTaxResult.cs b/src/Application/Tax.Estv/Client/Models/SimpleTaxResult.cs
new file mode 100644
index 00000000..480c2cf5
--- /dev/null
+++ b/src/Application/Tax.Estv/Client/Models/SimpleTaxResult.cs
@@ -0,0 +1,20 @@
+namespace Application.Tax.Estv.Client.Models
+{
+ public class SimpleTaxResult
+ {
+ public int IncomeSimpleTaxCanton { get; set; }
+ public int FortuneTaxCanton { get; set; }
+ public int IncomeSimpleTaxCity { get; set; }
+ public int IncomeTaxChurch { get; set; }
+ public int IncomeTaxCity { get; set; }
+ public int IncomeSimpleTaxFed { get; set; }
+ public int PersonalTax { get; set; }
+ public int FortuneTaxCity { get; set; }
+ public int FortuneSimpleTaxCanton { get; set; }
+ public int IncomeTaxFed { get; set; }
+ public int FortuneSimpleTaxCity { get; set; }
+ public int IncomeTaxCanton { get; set; }
+ public TaxLocation Location { get; set; }
+ public int FortuneTaxChurch { get; set; }
+ }
+}
diff --git a/src/Application/Tax.Estv/Client/Models/TaxLocation.cs b/src/Application/Tax.Estv/Client/Models/TaxLocation.cs
new file mode 100644
index 00000000..5138ff30
--- /dev/null
+++ b/src/Application/Tax.Estv/Client/Models/TaxLocation.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Serialization;
+
+namespace Application.Tax.Estv.Client.Models
+{
+ public class TaxLocation
+ {
+ [JsonPropertyName("TaxLocationID")]
+ public int Id { get; set; }
+
+ public string ZipCode { get; set; }
+
+ [JsonPropertyName("BfsID")]
+ public int BfsId { get; set; }
+
+ [JsonPropertyName("CantonID")]
+ public int CantonId { get; set; }
+
+ public string BfsName { get; set; }
+
+ public string City { get; set; }
+
+ public string Canton { get; set; }
+ }
+}
diff --git a/src/Application/Tax.Estv/Client/Models/TaxLocationFilter.cs b/src/Application/Tax.Estv/Client/Models/TaxLocationFilter.cs
new file mode 100644
index 00000000..3e949bfb
--- /dev/null
+++ b/src/Application/Tax.Estv/Client/Models/TaxLocationFilter.cs
@@ -0,0 +1,9 @@
+namespace Application.Tax.Estv.Client.Models
+{
+ public class TaxLocationRequest
+ {
+ public string Search { get; set; }
+
+ public int Language { get; set; } = 1;
+ }
+}
diff --git a/src/Application/Tax.Estv/EstvFullCapitalBenefitTaxCalculator.cs b/src/Application/Tax.Estv/EstvFullCapitalBenefitTaxCalculator.cs
new file mode 100644
index 00000000..625d0376
--- /dev/null
+++ b/src/Application/Tax.Estv/EstvFullCapitalBenefitTaxCalculator.cs
@@ -0,0 +1,68 @@
+using Application.Features.FullTaxCalculation;
+using Application.Tax.Contracts;
+using Application.Tax.Estv.Client;
+using Application.Tax.Estv.Client.Models;
+using Application.Tax.Proprietary.Abstractions.Models;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Tax.Estv
+{
+ ///
+ /// Facade for the ESTV tax calculation service.
+ ///
+ public class EstvFullCapitalBenefitTaxCalculator : IFullCapitalBenefitTaxCalculator
+ {
+ private readonly IEstvTaxCalculatorClient estvTaxCalculatorClient;
+ private readonly ITaxSupportedYearProvider taxSupportedYearProvider;
+
+ public EstvFullCapitalBenefitTaxCalculator(
+ IEstvTaxCalculatorClient estvTaxCalculatorClient,
+ ITaxSupportedYearProvider taxSupportedYearProvider)
+ {
+ this.estvTaxCalculatorClient = estvTaxCalculatorClient;
+ this.taxSupportedYearProvider = taxSupportedYearProvider;
+ }
+
+ public async Task> CalculateAsync(
+ int calculationYear,
+ MunicipalityModel municipality,
+ CapitalBenefitTaxPerson person,
+ bool withMaxAvailableCalculationYear = false)
+ {
+ if (!municipality.EstvTaxLocationId.HasValue)
+ {
+ return "ESTV tax location id is null";
+ }
+
+ int supportedTaxYear = taxSupportedYearProvider.MapToSupportedYear(calculationYear);
+
+ SimpleCapitalTaxResult calculationResult = await estvTaxCalculatorClient.CalculateCapitalBenefitTaxAsync(
+ municipality.EstvTaxLocationId.Value, supportedTaxYear, person);
+
+ decimal municipalityRate = calculationResult.TaxCanton == 0
+ ? decimal.Zero
+ : calculationResult.TaxCity / (decimal)calculationResult.TaxCanton * 100M;
+
+ return new FullCapitalBenefitTaxResult
+ {
+ FederalResult = new BasisTaxResult { TaxAmount = calculationResult.TaxFed },
+ StateResult = new CapitalBenefitTaxResult
+ {
+ MunicipalityRate = municipalityRate,
+ CantonRate = 100,
+ ChurchTax = new ChurchTaxResult
+ {
+ TaxAmount = calculationResult.TaxChurch,
+ },
+ BasisTax = new BasisTaxResult
+ {
+ TaxAmount = calculationResult.TaxCanton,
+ DeterminingFactorTaxableAmount = municipalityRate
+ }
+ }
+ };
+ }
+ }
+}
diff --git a/src/Application/Tax.Estv/EstvFullTaxCalculator.cs b/src/Application/Tax.Estv/EstvFullTaxCalculator.cs
new file mode 100644
index 00000000..0b779a89
--- /dev/null
+++ b/src/Application/Tax.Estv/EstvFullTaxCalculator.cs
@@ -0,0 +1,79 @@
+using Application.Features.FullTaxCalculation;
+using Application.Tax.Contracts;
+using Application.Tax.Estv.Client;
+using Application.Tax.Estv.Client.Models;
+using Application.Tax.Proprietary.Abstractions.Models;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Tax.Estv
+{
+ public class EstvFullTaxCalculator : IFullWealthAndIncomeTaxCalculator
+ {
+ private readonly IEstvTaxCalculatorClient estvTaxCalculatorClient;
+ private readonly ITaxSupportedYearProvider taxSupportedYearProvider;
+
+ public EstvFullTaxCalculator(IEstvTaxCalculatorClient estvTaxCalculatorClient, ITaxSupportedYearProvider taxSupportedYearProvider)
+ {
+ this.estvTaxCalculatorClient = estvTaxCalculatorClient;
+ this.taxSupportedYearProvider = taxSupportedYearProvider;
+ }
+
+ public async Task> CalculateAsync(
+ int calculationYear, MunicipalityModel municipality, TaxPerson person, bool withMaxAvailableCalculationYear = false)
+ {
+ if (!municipality.EstvTaxLocationId.HasValue)
+ {
+ return "ESTV tax location id is null";
+ }
+
+ int supportedTaxYear = taxSupportedYearProvider.MapToSupportedYear(calculationYear);
+
+ SimpleTaxResult estvResult = await estvTaxCalculatorClient.CalculateIncomeAndWealthTaxAsync(
+ municipality.EstvTaxLocationId.Value, supportedTaxYear, person);
+
+ decimal simpleTaxRate = decimal.Zero;
+ if (estvResult.IncomeTaxCanton > decimal.Zero)
+ {
+ simpleTaxRate = estvResult.IncomeTaxCity / (decimal)estvResult.IncomeTaxCanton * 100M;
+ }
+
+ decimal wealthTaxRate = decimal.Zero;
+ if (estvResult.FortuneTaxCanton > decimal.Zero)
+ {
+ simpleTaxRate = estvResult.FortuneTaxCity / (decimal)estvResult.FortuneTaxCanton * 100M;
+ }
+
+ return new FullTaxResult
+ {
+ FederalTaxResult = new BasisTaxResult
+ {
+ TaxAmount = estvResult.IncomeTaxFed,
+ DeterminingFactorTaxableAmount = decimal.Zero
+ },
+ StateTaxResult = new StateTaxResult
+ {
+ BasisIncomeTax = new BasisTaxResult
+ {
+ TaxAmount = estvResult.IncomeTaxCanton,
+ DeterminingFactorTaxableAmount = simpleTaxRate
+ },
+ BasisWealthTax = new BasisTaxResult
+ {
+ TaxAmount = estvResult.FortuneTaxCanton,
+ DeterminingFactorTaxableAmount = wealthTaxRate
+ },
+ ChurchTax = new ChurchTaxResult
+ {
+ TaxAmount = estvResult.IncomeTaxChurch + estvResult.FortuneTaxChurch,
+ TaxAmountPartner = null,
+ },
+ PollTaxAmount = estvResult.PersonalTax,
+ CantonRate = 100M,
+ MunicipalityRate = simpleTaxRate
+ }
+ };
+ }
+ }
+}
diff --git a/src/Application/Tax.Estv/EstvMunicipalityConnector.cs b/src/Application/Tax.Estv/EstvMunicipalityConnector.cs
new file mode 100644
index 00000000..15fbce63
--- /dev/null
+++ b/src/Application/Tax.Estv/EstvMunicipalityConnector.cs
@@ -0,0 +1,90 @@
+using Application.Municipality;
+using AutoMapper;
+using Domain.Enums;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Tax.Estv
+{
+ public class EstvMunicipalityConnector : IMunicipalityConnector
+ {
+ private readonly IMapper mapper;
+ private readonly IMunicipalityRepository municipalityRepository;
+
+ public EstvMunicipalityConnector(
+ IMapper mapper,
+ IMunicipalityRepository municipalityRepository)
+ {
+ this.mapper = mapper;
+ this.municipalityRepository = municipalityRepository;
+ }
+
+ public Task> GetAllAsync()
+ {
+ return Task.FromResult(
+ mapper.Map>(municipalityRepository.GetAll()));
+ }
+
+ ///
+ /// Searches the specified search filter.
+ ///
+ /// The search filter.
+ /// List of municipalities.
+ public IEnumerable Search(MunicipalitySearchFilter searchFilter)
+ {
+ foreach (MunicipalityEntity entity in municipalityRepository.Search(searchFilter))
+ {
+ var model = mapper.Map(entity);
+
+ if (searchFilter.YearOfValidity.HasValue)
+ {
+ if (!model.DateOfMutation.HasValue)
+ {
+ yield return model;
+ }
+ else if (model.DateOfMutation.Value.Year > searchFilter.YearOfValidity)
+ {
+ yield return model;
+ }
+ }
+ else
+ {
+ yield return model;
+ }
+ }
+ }
+
+ public Task> GetAsync(int bfsNumber, int year)
+ {
+ Option entity =
+ municipalityRepository.Get(bfsNumber, year);
+
+ return entity
+ .Match>(
+ Some: item => mapper.Map(item),
+ None: () => $"Municipality not found by BFS number {bfsNumber}")
+ .AsTask();
+ }
+
+ ///
+ public Task> GetAllSupportTaxCalculationAsync()
+ {
+ const int maxEstvSupportedYear = 2022;
+
+ IReadOnlyCollection list = municipalityRepository
+ .GetAllSupportTaxCalculation()
+ .Select(item => new TaxSupportedMunicipalityModel
+ {
+ BfsMunicipalityNumber = item.BfsNumber,
+ Name = item.Name,
+ Canton = Enum.Parse(item.Canton),
+ MaxSupportedYear = maxEstvSupportedYear,
+ EstvTaxLocationId = item.TaxLocationId
+ })
+ .ToList();
+
+ return Task.FromResult(list);
+ }
+ }
+}
diff --git a/src/Application/Tax.Estv/EstvTaxSupportedYearProvider.cs b/src/Application/Tax.Estv/EstvTaxSupportedYearProvider.cs
new file mode 100644
index 00000000..30a87136
--- /dev/null
+++ b/src/Application/Tax.Estv/EstvTaxSupportedYearProvider.cs
@@ -0,0 +1,19 @@
+using Application.Features.FullTaxCalculation;
+
+namespace Application.Tax.Estv
+{
+ public class EstvTaxSupportedYearProvider : ITaxSupportedYearProvider
+ {
+ private readonly int[] supportedTaxYears = { 2019, 2020, 2021, 2022, 2023 };
+
+ public int[] GetSupportedTaxYears()
+ {
+ return supportedTaxYears;
+ }
+
+ public int MapToSupportedYear(int taxYear)
+ {
+ return GetSupportedTaxYears().Max();
+ }
+ }
+}
diff --git a/src/Application/Tax.Mock/MockedFullTaxCalculator.cs b/src/Application/Tax.Mock/MockedFullTaxCalculator.cs
new file mode 100644
index 00000000..df71384b
--- /dev/null
+++ b/src/Application/Tax.Mock/MockedFullTaxCalculator.cs
@@ -0,0 +1,91 @@
+using Application.Features.FullTaxCalculation;
+using Application.Municipality;
+using Application.Tax.Contracts;
+using Application.Tax.Proprietary;
+using Domain.Enums;
+using Domain.Models.Municipality;
+using Domain.Models.Tax;
+using LanguageExt;
+
+namespace Application.Tax.Mock
+{
+ public class MockedFullTaxCalculator
+ : IFullWealthAndIncomeTaxCalculator, IFullCapitalBenefitTaxCalculator, IMunicipalityConnector, ITaxSupportedYearProvider
+ {
+ const int DefaultBfsMunicipalityId = 261;
+ const Canton DefaultCanton = Canton.ZH;
+
+ private readonly ProprietaryFullTaxCalculator fullTaxCalculator;
+ private readonly ProprietaryFullCapitalBenefitTaxCalculator fullCapitalBenefitTaxCalculator;
+
+ public MockedFullTaxCalculator(
+ ProprietaryFullTaxCalculator fullTaxCalculator,
+ ProprietaryFullCapitalBenefitTaxCalculator fullCapitalBenefitTaxCalculator)
+ {
+ this.fullTaxCalculator = fullTaxCalculator;
+ this.fullCapitalBenefitTaxCalculator = fullCapitalBenefitTaxCalculator;
+ }
+
+ public Task> CalculateAsync(
+ int calculationYear, MunicipalityModel municipality, TaxPerson person, bool withMaxAvailableCalculationYear = false)
+ {
+ MunicipalityModel adaptedModel = GetAdaptedModel();
+
+ return fullTaxCalculator.CalculateAsync(calculationYear, adaptedModel, person, withMaxAvailableCalculationYear);
+ }
+
+ public Task> CalculateAsync(
+ int calculationYear, MunicipalityModel municipality, CapitalBenefitTaxPerson person, bool withMaxAvailableCalculationYear = false)
+ {
+ MunicipalityModel adaptedModel = GetAdaptedModel();
+
+ return fullCapitalBenefitTaxCalculator.CalculateAsync(calculationYear, adaptedModel, person, withMaxAvailableCalculationYear);
+ }
+
+ public Task> GetAllAsync()
+ {
+ return Search(null).AsTask();
+ }
+
+ public IEnumerable Search(MunicipalitySearchFilter searchFilter)
+ {
+ yield return GetAdaptedModel();
+ }
+
+ public Task> GetAsync(int bfsNumber, int year)
+ {
+ Either municipality = GetAdaptedModel();
+
+ return municipality.AsTask();
+ }
+
+ public Task> GetAllSupportTaxCalculationAsync()
+ {
+ MunicipalityModel adaptedModel = GetAdaptedModel();
+
+ IReadOnlyCollection municipalities = new List
+ {
+ new() { MaxSupportedYear = 2022, BfsMunicipalityNumber = adaptedModel.BfsNumber, Canton = adaptedModel.Canton }
+ };
+
+ return municipalities.AsTask();
+ }
+
+ public int[] GetSupportedTaxYears()
+ {
+ int[] years = { 2022, 2023 };
+
+ return years;
+ }
+
+ public int MapToSupportedYear(int taxYear)
+ {
+ return GetSupportedTaxYears().Max();
+ }
+
+ private MunicipalityModel GetAdaptedModel()
+ {
+ return new MunicipalityModel { BfsNumber = DefaultBfsMunicipalityId, Canton = DefaultCanton };
+ }
+ }
+}
diff --git a/src/Application/Tax.Proprietary/Basis/CapitalBenefit/MissingCapitalBenefitTaxCalculator.cs b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/MissingCapitalBenefitTaxCalculator.cs
new file mode 100644
index 00000000..6188a909
--- /dev/null
+++ b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/MissingCapitalBenefitTaxCalculator.cs
@@ -0,0 +1,32 @@
+using Application.Tax.Proprietary.Contracts;
+using Domain.Enums;
+using Domain.Models.Tax;
+using LanguageExt;
+using Microsoft.Extensions.Logging;
+
+namespace Application.Tax.Proprietary.Basis.CapitalBenefit;
+
+///
+/// Null calculator for missing capital benefit calculator.
+///
+public class MissingCapitalBenefitTaxCalculator : ICapitalBenefitTaxCalculator
+{
+ private readonly ILogger logger;
+
+ public MissingCapitalBenefitTaxCalculator(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public Task> CalculateAsync(
+ int calculationYear, int municipalityId, Canton canton, CapitalBenefitTaxPerson person)
+ {
+ string msg = $"No capital benefit tax calculator for canton {canton} available";
+
+ Either result = msg;
+
+ logger.LogWarning(msg);
+
+ return Task.FromResult(result);
+ }
+}
diff --git a/src/Application/Tax.Proprietary/Basis/CapitalBenefit/SGCapitalBenefitTaxCalculator.cs b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/SGCapitalBenefitTaxCalculator.cs
new file mode 100644
index 00000000..ddfd4017
--- /dev/null
+++ b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/SGCapitalBenefitTaxCalculator.cs
@@ -0,0 +1,107 @@
+using Application.Tax.Proprietary.Abstractions.Models;
+using Application.Tax.Proprietary.Contracts;
+using Application.Tax.Proprietary.Models;
+using Application.Tax.Proprietary.Repositories;
+using AutoMapper;
+using Domain.Enums;
+using Domain.Models.Tax;
+using Domain.Models.Tax.Person;
+using FluentValidation;
+using LanguageExt;
+using static LanguageExt.Prelude;
+
+namespace Application.Tax.Proprietary.Basis.CapitalBenefit;
+
+public class SGCapitalBenefitTaxCalculator : ICapitalBenefitTaxCalculator
+{
+ private readonly IStateTaxRateRepository stateTaxRateRepository;
+ private readonly IMapper mapper;
+ private readonly IValidator validator;
+ private readonly IChurchTaxCalculator churchTaxCalculator;
+
+ public SGCapitalBenefitTaxCalculator(
+ IMapper mapper,
+ IValidator validator,
+ IChurchTaxCalculator churchTaxCalculator,
+ IStateTaxRateRepository stateTaxRateRepository)
+ {
+ this.stateTaxRateRepository = stateTaxRateRepository;
+ this.mapper = mapper;
+ this.validator = validator;
+ this.churchTaxCalculator = churchTaxCalculator;
+ }
+
+ ///
+ public async Task> CalculateAsync(
+ int calculationYear,
+ int municipalityId,
+ Canton canton,
+ CapitalBenefitTaxPerson capitalBenefitTaxPerson)
+ {
+ const decimal taxRateForSingle = 2.2M / 100M;
+ const decimal taxRateForMarried = 2.0M / 100M;
+
+ var validationResult = validator.Validate(capitalBenefitTaxPerson);
+ if (!validationResult.IsValid)
+ {
+ return
+ string.Join(";", validationResult.Errors.Select(x => x.ErrorMessage));
+ }
+
+ var taxRateEntity = stateTaxRateRepository.TaxRates(calculationYear, municipalityId);
+
+ if (taxRateEntity == null)
+ {
+ return
+ $"No tax rate available for municipality {municipalityId} and year {calculationYear}";
+ }
+
+ BasisTaxResult basisTaxResult = GetBasisCapitalBenefitTaxAmount(capitalBenefitTaxPerson);
+
+ ChurchTaxPerson churchTaxPerson = mapper.Map(capitalBenefitTaxPerson);
+
+ Either churchTaxResult =
+ await churchTaxCalculator.CalculateAsync(
+ churchTaxPerson,
+ taxRateEntity,
+ new AggregatedBasisTaxResult
+ {
+ IncomeTax = basisTaxResult,
+ WealthTax = new BasisTaxResult(),
+ });
+
+ return churchTaxResult.Map(Update);
+
+ CapitalBenefitTaxResult Update(ChurchTaxResult churchResult)
+ {
+ return new CapitalBenefitTaxResult
+ {
+ BasisTax = basisTaxResult,
+ ChurchTax = churchResult,
+ CantonRate = taxRateEntity.TaxRateCanton,
+ MunicipalityRate = taxRateEntity.TaxRateMunicipality,
+ };
+ }
+
+ BasisTaxResult GetBasisCapitalBenefitTaxAmount(CapitalBenefitTaxPerson person)
+ {
+ var amount = Some(person.CivilStatus)
+ .Match(
+ Some: status => status switch
+ {
+ CivilStatus.Single =>
+ capitalBenefitTaxPerson.TaxableCapitalBenefits * taxRateForSingle,
+ CivilStatus.Married =>
+ capitalBenefitTaxPerson.TaxableCapitalBenefits * taxRateForMarried,
+ _ => 0M,
+ },
+ None: () => 0);
+
+ return new BasisTaxResult
+ {
+ DeterminingFactorTaxableAmount = amount,
+ TaxAmount = amount,
+ };
+ }
+ }
+}
diff --git a/src/Application/Tax.Proprietary/Basis/CapitalBenefit/SOCapitalBenefitTaxCalculator.cs b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/SOCapitalBenefitTaxCalculator.cs
new file mode 100644
index 00000000..e18e6a15
--- /dev/null
+++ b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/SOCapitalBenefitTaxCalculator.cs
@@ -0,0 +1,72 @@
+using Application.Tax.Proprietary.Abstractions.Models;
+using Application.Tax.Proprietary.Contracts;
+using AutoMapper;
+using Domain.Enums;
+using Domain.Models.Tax;
+using FluentValidation;
+using LanguageExt;
+
+namespace Application.Tax.Proprietary.Basis.CapitalBenefit;
+
+public class SOCapitalBenefitTaxCalculator : ICapitalBenefitTaxCalculator
+{
+ private const decimal ScaleFactor = 0.25M;
+
+ private readonly IMapper mapper;
+ private readonly IValidator validator;
+ private readonly IStateTaxCalculator stateTaxCalculator;
+
+ public SOCapitalBenefitTaxCalculator(
+ IMapper mapper,
+ IValidator validator,
+ IStateTaxCalculator stateTaxCalculator)
+ {
+ this.mapper = mapper;
+ this.validator = validator;
+ this.stateTaxCalculator = stateTaxCalculator;
+ }
+
+ ///
+ public async Task> CalculateAsync(
+ int calculationYear,
+ int municipalityId,
+ Canton canton,
+ CapitalBenefitTaxPerson capitalBenefitTaxPerson)
+ {
+ var validationResult = validator.Validate(capitalBenefitTaxPerson);
+ if (!validationResult.IsValid)
+ {
+ return
+ string.Join(";", validationResult.Errors.Select(x => x.ErrorMessage));
+ }
+
+ var stateTaxPerson = mapper.Map(capitalBenefitTaxPerson);
+ stateTaxPerson.TaxableIncome = capitalBenefitTaxPerson.TaxableCapitalBenefits;
+
+ var stateTaxResult = await stateTaxCalculator
+ .CalculateAsync(calculationYear, municipalityId, Canton.SO, stateTaxPerson);
+
+ return stateTaxResult.Map(Scale);
+
+ static CapitalBenefitTaxResult Scale(StateTaxResult intermediateResult)
+ {
+ return new CapitalBenefitTaxResult
+ {
+ BasisTax = new BasisTaxResult
+ {
+ DeterminingFactorTaxableAmount =
+ intermediateResult.BasisIncomeTax.DeterminingFactorTaxableAmount * ScaleFactor,
+ TaxAmount =
+ intermediateResult.BasisIncomeTax.TaxAmount * ScaleFactor,
+ },
+ ChurchTax = new ChurchTaxResult
+ {
+ TaxAmount = intermediateResult.ChurchTax.TaxAmount * ScaleFactor,
+ TaxAmountPartner = intermediateResult.ChurchTax.TaxAmountPartner * ScaleFactor
+ },
+ CantonRate = intermediateResult.CantonRate,
+ MunicipalityRate = intermediateResult.MunicipalityRate,
+ };
+ }
+ }
+}
diff --git a/src/Application/Tax.Proprietary/Basis/CapitalBenefit/ZHCapitalBenefitTaxCalculator.cs b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/ZHCapitalBenefitTaxCalculator.cs
new file mode 100644
index 00000000..9072aa4a
--- /dev/null
+++ b/src/Application/Tax.Proprietary/Basis/CapitalBenefit/ZHCapitalBenefitTaxCalculator.cs
@@ -0,0 +1,80 @@
+using Application.Tax.Proprietary.Abstractions.Models;
+using Application.Tax.Proprietary.Contracts;
+using AutoMapper;
+using Domain.Enums;
+using Domain.Models.Tax;
+using FluentValidation;
+using LanguageExt;
+
+namespace Application.Tax.Proprietary.Basis.CapitalBenefit;
+
+public class ZHCapitalBenefitTaxCalculator : ICapitalBenefitTaxCalculator
+{
+ private readonly IStateTaxCalculator stateTaxCalculator;
+ private readonly IValidator validator;
+ private readonly IMapper mapper;
+
+ public ZHCapitalBenefitTaxCalculator(
+ IStateTaxCalculator stateTaxCalculator,
+ IValidator