@novha/calc-engines 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/corporate/germany/GermanyCorporateTaxService.js +3 -0
- package/dist/corporate/germany/GermanyCorporateTaxServiceImpl.js +56 -0
- package/dist/corporate/germany/domain/types.js +3 -0
- package/dist/corporate/usa/USACorporateTaxService.js +3 -0
- package/dist/corporate/usa/USACorporateTaxServiceImpl.js +29 -0
- package/dist/corporate/usa/domain/types.js +3 -0
- package/dist/income-tax/germany/GermanyIncomeTaxService.js +3 -0
- package/dist/income-tax/germany/GermanyIncomeTaxServiceImpl.js +86 -0
- package/dist/income-tax/germany/domain/types.js +3 -0
- package/dist/income-tax/usa/USAIncomeTaxService.js +3 -0
- package/dist/income-tax/usa/USAIncomeTaxServiceImpl.js +87 -0
- package/dist/income-tax/usa/domain/types.js +3 -0
- package/dist/index.js +16 -2
- package/dist/mortgage/germany/GermanyMortgageService.js +3 -0
- package/dist/mortgage/germany/GermanyMortgageServiceImpl.js +80 -0
- package/dist/mortgage/germany/domain/types.js +3 -0
- package/dist/mortgage/usa/USAMortgageService.js +3 -0
- package/dist/mortgage/usa/USAMortgageServiceImpl.js +92 -0
- package/dist/mortgage/usa/domain/types.js +3 -0
- package/dist/types/corporate/germany/GermanyCorporateTaxService.d.ts +4 -0
- package/dist/types/corporate/germany/GermanyCorporateTaxServiceImpl.d.ts +8 -0
- package/dist/types/corporate/germany/domain/types.d.ts +24 -0
- package/dist/types/corporate/usa/USACorporateTaxService.d.ts +4 -0
- package/dist/types/corporate/usa/USACorporateTaxServiceImpl.d.ts +8 -0
- package/dist/types/corporate/usa/domain/types.d.ts +15 -0
- package/dist/types/income-tax/germany/GermanyIncomeTaxService.d.ts +4 -0
- package/dist/types/income-tax/germany/GermanyIncomeTaxServiceImpl.d.ts +12 -0
- package/dist/types/income-tax/germany/domain/types.d.ts +23 -0
- package/dist/types/income-tax/usa/USAIncomeTaxService.d.ts +4 -0
- package/dist/types/income-tax/usa/USAIncomeTaxServiceImpl.d.ts +11 -0
- package/dist/types/income-tax/usa/domain/types.d.ts +30 -0
- package/dist/types/index.d.ts +12 -0
- package/dist/types/mortgage/germany/GermanyMortgageService.d.ts +4 -0
- package/dist/types/mortgage/germany/GermanyMortgageServiceImpl.d.ts +7 -0
- package/dist/types/mortgage/germany/domain/types.d.ts +42 -0
- package/dist/types/mortgage/usa/USAMortgageService.d.ts +4 -0
- package/dist/types/mortgage/usa/USAMortgageServiceImpl.d.ts +8 -0
- package/dist/types/mortgage/usa/domain/types.d.ts +44 -0
- package/package.json +1 -1
- package/src/corporate/germany/GermanyCorporateTaxService.ts +5 -0
- package/src/corporate/germany/GermanyCorporateTaxServiceImpl.ts +64 -0
- package/src/corporate/germany/domain/types.ts +20 -0
- package/src/corporate/usa/USACorporateTaxService.ts +5 -0
- package/src/corporate/usa/USACorporateTaxServiceImpl.ts +35 -0
- package/src/corporate/usa/domain/types.ts +15 -0
- package/src/income-tax/germany/GermanyIncomeTaxService.ts +5 -0
- package/src/income-tax/germany/GermanyIncomeTaxServiceImpl.ts +121 -0
- package/src/income-tax/germany/domain/types.ts +27 -0
- package/src/income-tax/usa/USAIncomeTaxService.ts +5 -0
- package/src/income-tax/usa/USAIncomeTaxServiceImpl.ts +113 -0
- package/src/income-tax/usa/domain/types.ts +24 -0
- package/src/index.ts +42 -0
- package/src/mortgage/germany/GermanyMortgageService.ts +5 -0
- package/src/mortgage/germany/GermanyMortgageServiceImpl.ts +111 -0
- package/src/mortgage/germany/domain/types.ts +49 -0
- package/src/mortgage/usa/USAMortgageService.ts +5 -0
- package/src/mortgage/usa/USAMortgageServiceImpl.ts +134 -0
- package/src/mortgage/usa/domain/types.ts +52 -0
- package/test/germany-corporate-tax.test.ts +77 -0
- package/test/germany-income-tax.test.ts +114 -0
- package/test/germany-mortgage.test.ts +105 -0
- package/test/usa-corporate-tax.test.ts +59 -0
- package/test/usa-income-tax.test.ts +138 -0
- package/test/usa-mortgage.test.ts +97 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { OtherFees } from "../../domain/types";
|
|
2
|
+
|
|
3
|
+
export interface MortgageRules {
|
|
4
|
+
loanConstraints: LoanConstraints;
|
|
5
|
+
interest: InterestRules;
|
|
6
|
+
transferTax: TransferTaxRules;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LoanConstraints {
|
|
10
|
+
maxLtvPercent: number;
|
|
11
|
+
maxAmortizationYears: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InterestRules {
|
|
15
|
+
compounding: 'MONTHLY';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TransferTaxBracket {
|
|
19
|
+
upTo?: number;
|
|
20
|
+
above?: number;
|
|
21
|
+
rate: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TransferTaxRules {
|
|
25
|
+
brackets: TransferTaxBracket[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AmortizationScheduleItem {
|
|
29
|
+
year: number;
|
|
30
|
+
principal: number;
|
|
31
|
+
interest: number;
|
|
32
|
+
balance: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MortgageInput {
|
|
36
|
+
propertyPrice: number;
|
|
37
|
+
downPayment: number;
|
|
38
|
+
annualInterestRate: number;
|
|
39
|
+
amortizationYears: number;
|
|
40
|
+
isFirstTimeBuyer: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MortgageOutput {
|
|
44
|
+
loanAmount: number;
|
|
45
|
+
totalMortgage: number;
|
|
46
|
+
monthlyPayment: number;
|
|
47
|
+
totalInterestPaid: number;
|
|
48
|
+
totalPaid: number;
|
|
49
|
+
transferTax: number;
|
|
50
|
+
amortizationSchedule: AmortizationScheduleItem[];
|
|
51
|
+
otherFees: OtherFees;
|
|
52
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { GermanyCorporateTaxServiceImpl } from '../src/corporate/germany/GermanyCorporateTaxServiceImpl';
|
|
2
|
+
import { Input, Rules } from '../src/corporate/germany/domain/types';
|
|
3
|
+
|
|
4
|
+
// Germany corporate tax rules
|
|
5
|
+
const germanyCorporateRules: Rules = {
|
|
6
|
+
corporateIncomeTax: { rate: 0.15 },
|
|
7
|
+
solidaritySurcharge: { rate: 0.055 },
|
|
8
|
+
tradeTax: { multiplier: 0.035, assessmentRate: 4.0 },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('GermanyCorporateTaxServiceImpl', () => {
|
|
12
|
+
it('correctly calculates all components for €100,000 taxable income', () => {
|
|
13
|
+
const input: Input = { taxableIncome: 100000 };
|
|
14
|
+
const service = new GermanyCorporateTaxServiceImpl(input, germanyCorporateRules);
|
|
15
|
+
const result = service.calculate();
|
|
16
|
+
|
|
17
|
+
// Corp tax = 100000 * 15% = 15000
|
|
18
|
+
// Solidarity = 15000 * 5.5% = 825
|
|
19
|
+
// Trade tax = 100000 * 3.5% * 4.0 = 14000
|
|
20
|
+
// Total = 29825
|
|
21
|
+
expect(result.corporateTax).toBe(15000);
|
|
22
|
+
expect(result.solidaritySurcharge).toBe(825);
|
|
23
|
+
expect(result.tradeTax).toBeCloseTo(14000, 2);
|
|
24
|
+
expect(result.totalTax).toBeCloseTo(29825, 2);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('calculates effective tax rate correctly', () => {
|
|
28
|
+
const input: Input = { taxableIncome: 100000 };
|
|
29
|
+
const service = new GermanyCorporateTaxServiceImpl(input, germanyCorporateRules);
|
|
30
|
+
const result = service.calculate();
|
|
31
|
+
|
|
32
|
+
// Effective rate = 29825 / 100000 * 100 = 29.825%
|
|
33
|
+
expect(result.effectiveTaxRate).toBeCloseTo(29.825, 3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns zero for all components when taxable income is zero', () => {
|
|
37
|
+
const input: Input = { taxableIncome: 0 };
|
|
38
|
+
const service = new GermanyCorporateTaxServiceImpl(input, germanyCorporateRules);
|
|
39
|
+
const result = service.calculate();
|
|
40
|
+
|
|
41
|
+
expect(result.corporateTax).toBe(0);
|
|
42
|
+
expect(result.solidaritySurcharge).toBe(0);
|
|
43
|
+
expect(result.tradeTax).toBe(0);
|
|
44
|
+
expect(result.totalTax).toBe(0);
|
|
45
|
+
expect(result.effectiveTaxRate).toBe(0);
|
|
46
|
+
expect(result.breakdowns).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns three breakdown entries for positive income', () => {
|
|
50
|
+
const input: Input = { taxableIncome: 100000 };
|
|
51
|
+
const service = new GermanyCorporateTaxServiceImpl(input, germanyCorporateRules);
|
|
52
|
+
const result = service.calculate();
|
|
53
|
+
|
|
54
|
+
expect(result.breakdowns).toHaveLength(3);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('breakdown amounts match computed values', () => {
|
|
58
|
+
const input: Input = { taxableIncome: 100000 };
|
|
59
|
+
const service = new GermanyCorporateTaxServiceImpl(input, germanyCorporateRules);
|
|
60
|
+
const result = service.calculate();
|
|
61
|
+
|
|
62
|
+
expect(result.breakdowns[0].amount).toBe(15000);
|
|
63
|
+
expect(result.breakdowns[1].amount).toBe(825);
|
|
64
|
+
expect(result.breakdowns[2].amount).toBeCloseTo(14000, 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('scales proportionally for different income levels', () => {
|
|
68
|
+
const input: Input = { taxableIncome: 200000 };
|
|
69
|
+
const service = new GermanyCorporateTaxServiceImpl(input, germanyCorporateRules);
|
|
70
|
+
const result = service.calculate();
|
|
71
|
+
|
|
72
|
+
expect(result.corporateTax).toBe(30000);
|
|
73
|
+
expect(result.solidaritySurcharge).toBe(1650);
|
|
74
|
+
expect(result.tradeTax).toBeCloseTo(28000, 2);
|
|
75
|
+
expect(result.totalTax).toBeCloseTo(59650, 2);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { GermanyIncomeTaxServiceImpl } from '../src/income-tax/germany/GermanyIncomeTaxServiceImpl';
|
|
2
|
+
import { IncomeTaxRules } from '../src/income-tax/germany/domain/types';
|
|
3
|
+
|
|
4
|
+
// Germany 2024 income tax rules (simplified bracket approach)
|
|
5
|
+
const germanyRules: IncomeTaxRules = {
|
|
6
|
+
taxBrackets: [
|
|
7
|
+
{ from: 0, to: 11604, rate: 0 },
|
|
8
|
+
{ from: 11604, to: 17006, rate: 0.14 },
|
|
9
|
+
{ from: 17006, to: 62810, rate: 0.24 },
|
|
10
|
+
{ from: 62810, to: 277826, rate: 0.42 },
|
|
11
|
+
{ from: 277826, to: null, rate: 0.45 },
|
|
12
|
+
],
|
|
13
|
+
solidaritySurcharge: {
|
|
14
|
+
rate: 0.055,
|
|
15
|
+
exemptionThreshold: 18130,
|
|
16
|
+
},
|
|
17
|
+
socialContributions: {
|
|
18
|
+
rate: 0.197,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('GermanyIncomeTaxServiceImpl', () => {
|
|
23
|
+
it('returns zero income tax for income at or below the Grundfreibetrag', () => {
|
|
24
|
+
const service = new GermanyIncomeTaxServiceImpl(11604, germanyRules);
|
|
25
|
+
const result = service.calculateNetIncome();
|
|
26
|
+
|
|
27
|
+
expect(result.incomeTax).toBe(0);
|
|
28
|
+
expect(result.grossIncome).toBe(11604);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('correctly calculates income tax for €30,000 income', () => {
|
|
32
|
+
const service = new GermanyIncomeTaxServiceImpl(30000, germanyRules);
|
|
33
|
+
const result = service.calculateNetIncome();
|
|
34
|
+
|
|
35
|
+
// 0%: 11604 * 0 = 0
|
|
36
|
+
// 14%: (17006 - 11604) * 0.14 = 5402 * 0.14 = 756.28
|
|
37
|
+
// 24%: (30000 - 17006) * 0.24 = 12994 * 0.24 = 3118.56
|
|
38
|
+
// Total = 3874.84
|
|
39
|
+
expect(result.incomeTax).toBeCloseTo(3874.84, 2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('correctly calculates income tax for €80,000 income', () => {
|
|
43
|
+
const service = new GermanyIncomeTaxServiceImpl(80000, germanyRules);
|
|
44
|
+
const result = service.calculateNetIncome();
|
|
45
|
+
|
|
46
|
+
// 0%: 11604 * 0 = 0
|
|
47
|
+
// 14%: (17006 - 11604) * 0.14 = 5402 * 0.14 = 756.28
|
|
48
|
+
// 24%: (62810 - 17006) * 0.24 = 45804 * 0.24 = 10992.96
|
|
49
|
+
// 42%: (80000 - 62810) * 0.42 = 17190 * 0.42 = 7219.80
|
|
50
|
+
// Total = 18969.04
|
|
51
|
+
expect(result.incomeTax).toBeCloseTo(18969.04, 2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does not apply solidarity surcharge when income tax is below exemption threshold', () => {
|
|
55
|
+
// €30,000: income tax ≈ 3874.84 < 18130
|
|
56
|
+
const service = new GermanyIncomeTaxServiceImpl(30000, germanyRules);
|
|
57
|
+
const result = service.calculateNetIncome();
|
|
58
|
+
|
|
59
|
+
expect(result.solidaritySurcharge).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('applies solidarity surcharge when income tax exceeds exemption threshold', () => {
|
|
63
|
+
// €80,000: income tax ≈ 18969.04 > 18130 → soli = 18969.04 * 5.5%
|
|
64
|
+
const service = new GermanyIncomeTaxServiceImpl(80000, germanyRules);
|
|
65
|
+
const result = service.calculateNetIncome();
|
|
66
|
+
|
|
67
|
+
expect(result.solidaritySurcharge).toBeCloseTo(18969.04 * 0.055, 2);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('calculates social contributions as combined rate on gross income', () => {
|
|
71
|
+
const service = new GermanyIncomeTaxServiceImpl(30000, germanyRules);
|
|
72
|
+
const result = service.calculateNetIncome();
|
|
73
|
+
|
|
74
|
+
// 30000 * 19.7% = 5910
|
|
75
|
+
expect(result.socialContributions).toBeCloseTo(5910, 2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('net income equals gross income minus total deductions', () => {
|
|
79
|
+
const service = new GermanyIncomeTaxServiceImpl(50000, germanyRules);
|
|
80
|
+
const result = service.calculateNetIncome();
|
|
81
|
+
|
|
82
|
+
expect(result.netIncome).toBeCloseTo(
|
|
83
|
+
result.grossIncome - result.totalDeductions,
|
|
84
|
+
2,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('total deductions equals income tax plus solidarity surcharge plus social contributions', () => {
|
|
89
|
+
const service = new GermanyIncomeTaxServiceImpl(80000, germanyRules);
|
|
90
|
+
const result = service.calculateNetIncome();
|
|
91
|
+
|
|
92
|
+
expect(result.totalDeductions).toBeCloseTo(
|
|
93
|
+
result.incomeTax + result.solidaritySurcharge + result.socialContributions,
|
|
94
|
+
2,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('effective tax rate is income tax divided by gross income', () => {
|
|
99
|
+
const service = new GermanyIncomeTaxServiceImpl(80000, germanyRules);
|
|
100
|
+
const result = service.calculateNetIncome();
|
|
101
|
+
|
|
102
|
+
expect(result.effectiveTaxRate).toBeCloseTo(result.incomeTax / result.grossIncome, 4);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns zero tax for zero income', () => {
|
|
106
|
+
const service = new GermanyIncomeTaxServiceImpl(0, germanyRules);
|
|
107
|
+
const result = service.calculateNetIncome();
|
|
108
|
+
|
|
109
|
+
expect(result.incomeTax).toBe(0);
|
|
110
|
+
expect(result.solidaritySurcharge).toBe(0);
|
|
111
|
+
expect(result.socialContributions).toBe(0);
|
|
112
|
+
expect(result.effectiveTaxRate).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { GermanyMortgageServiceImpl } from '../src/mortgage/germany/GermanyMortgageServiceImpl';
|
|
2
|
+
import { MortgageInput, MortgageRules } from '../src/mortgage/germany/domain/types';
|
|
3
|
+
|
|
4
|
+
// Germany mortgage rules
|
|
5
|
+
const germanyMortgageRules: MortgageRules = {
|
|
6
|
+
loanConstraints: {
|
|
7
|
+
maxLtvPercent: 80,
|
|
8
|
+
maxAmortizationYears: 30,
|
|
9
|
+
},
|
|
10
|
+
interest: {
|
|
11
|
+
compounding: 'MONTHLY',
|
|
12
|
+
},
|
|
13
|
+
landTransferTax: {
|
|
14
|
+
rate: 0.035,
|
|
15
|
+
},
|
|
16
|
+
notaryFeeRate: 0.015,
|
|
17
|
+
registrationFeeRate: 0.005,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const defaultInput: MortgageInput = {
|
|
21
|
+
propertyPrice: 300000,
|
|
22
|
+
downPayment: 60000, // 20% down
|
|
23
|
+
annualInterestRate: 3.5,
|
|
24
|
+
amortizationYears: 25,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('GermanyMortgageServiceImpl', () => {
|
|
28
|
+
const service = new GermanyMortgageServiceImpl();
|
|
29
|
+
|
|
30
|
+
it('calculates loan amount correctly', () => {
|
|
31
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
32
|
+
|
|
33
|
+
expect(result.loanAmount).toBe(240000);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('calculates monthly payment using standard amortization formula', () => {
|
|
37
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
38
|
+
|
|
39
|
+
const r = 0.035 / 12;
|
|
40
|
+
const n = 25 * 12;
|
|
41
|
+
const expected = 240000 * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
|
|
42
|
+
expect(result.monthlyPayment).toBeCloseTo(expected, 2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('calculates Grunderwerbsteuer (land transfer tax) as flat rate on property price', () => {
|
|
46
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
47
|
+
|
|
48
|
+
// 300000 * 3.5% = 10500
|
|
49
|
+
expect(result.landTransferTax).toBeCloseTo(10500, 2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('calculates notary fees as flat rate on property price', () => {
|
|
53
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
54
|
+
|
|
55
|
+
// 300000 * 1.5% = 4500
|
|
56
|
+
expect(result.notaryFees).toBe(4500);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('calculates registration fees as flat rate on property price', () => {
|
|
60
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
61
|
+
|
|
62
|
+
// 300000 * 0.5% = 1500
|
|
63
|
+
expect(result.registrationFees).toBe(1500);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('generates amortization schedule with correct length', () => {
|
|
67
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
68
|
+
|
|
69
|
+
expect(result.amortizationSchedule.length).toBe(25);
|
|
70
|
+
expect(result.amortizationSchedule[0].year).toBe(1);
|
|
71
|
+
expect(result.amortizationSchedule[24].balance).toBeCloseTo(0, 0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('amortization schedule balance decreases over time', () => {
|
|
75
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
76
|
+
const schedule = result.amortizationSchedule;
|
|
77
|
+
|
|
78
|
+
for (let i = 1; i < schedule.length; i++) {
|
|
79
|
+
expect(schedule[i].balance).toBeLessThan(schedule[i - 1].balance);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('totalPaid equals monthlyPayment * totalPayments', () => {
|
|
84
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
85
|
+
const totalPayments = 25 * 12;
|
|
86
|
+
|
|
87
|
+
expect(result.totalPaid).toBeCloseTo(result.monthlyPayment * totalPayments, 0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('throws error when loan amount is zero or negative', () => {
|
|
91
|
+
const input: MortgageInput = {
|
|
92
|
+
...defaultInput,
|
|
93
|
+
downPayment: 300000,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
expect(() => service.calculate(input, germanyMortgageRules)).toThrow('Invalid loan amount');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('otherFees includes notary fees label', () => {
|
|
100
|
+
const result = service.calculate(defaultInput, germanyMortgageRules);
|
|
101
|
+
|
|
102
|
+
expect(result.otherFees.notaryFees.label).toBe('NOTARY_FEES');
|
|
103
|
+
expect(result.otherFees.notaryFees.value).toBe(result.notaryFees);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { USACorporateTaxServiceImpl } from '../src/corporate/usa/USACorporateTaxServiceImpl';
|
|
2
|
+
import { Input, Rules } from '../src/corporate/usa/domain/types';
|
|
3
|
+
|
|
4
|
+
// USA federal corporate tax rules (TCJA 2017, flat 21%)
|
|
5
|
+
const usaCorporateRules: Rules = {
|
|
6
|
+
regime: {
|
|
7
|
+
type: 'flat',
|
|
8
|
+
rate: 0.21,
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('USACorporateTaxServiceImpl', () => {
|
|
13
|
+
it('applies flat 21% rate for $100,000 taxable income', () => {
|
|
14
|
+
const input: Input = { taxableIncome: 100000 };
|
|
15
|
+
const service = new USACorporateTaxServiceImpl(input, usaCorporateRules);
|
|
16
|
+
const result = service.calculate();
|
|
17
|
+
|
|
18
|
+
expect(result.corporateTax).toBe(21000);
|
|
19
|
+
expect(result.effectiveTaxRate).toBe(21);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns zero tax for zero taxable income', () => {
|
|
23
|
+
const input: Input = { taxableIncome: 0 };
|
|
24
|
+
const service = new USACorporateTaxServiceImpl(input, usaCorporateRules);
|
|
25
|
+
const result = service.calculate();
|
|
26
|
+
|
|
27
|
+
expect(result.corporateTax).toBe(0);
|
|
28
|
+
expect(result.effectiveTaxRate).toBe(0);
|
|
29
|
+
expect(result.breakdowns).toHaveLength(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('effective tax rate is always 21% for positive income', () => {
|
|
33
|
+
const input: Input = { taxableIncome: 500000 };
|
|
34
|
+
const service = new USACorporateTaxServiceImpl(input, usaCorporateRules);
|
|
35
|
+
const result = service.calculate();
|
|
36
|
+
|
|
37
|
+
expect(result.effectiveTaxRate).toBe(21);
|
|
38
|
+
expect(result.corporateTax).toBe(105000);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns one breakdown entry for flat rate', () => {
|
|
42
|
+
const input: Input = { taxableIncome: 100000 };
|
|
43
|
+
const service = new USACorporateTaxServiceImpl(input, usaCorporateRules);
|
|
44
|
+
const result = service.calculate();
|
|
45
|
+
|
|
46
|
+
expect(result.breakdowns).toHaveLength(1);
|
|
47
|
+
expect(result.breakdowns[0].rate).toBe(0.21);
|
|
48
|
+
expect(result.breakdowns[0].amount).toBe(21000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('scales linearly for large income', () => {
|
|
52
|
+
const input: Input = { taxableIncome: 1000000 };
|
|
53
|
+
const service = new USACorporateTaxServiceImpl(input, usaCorporateRules);
|
|
54
|
+
const result = service.calculate();
|
|
55
|
+
|
|
56
|
+
expect(result.corporateTax).toBe(210000);
|
|
57
|
+
expect(result.effectiveTaxRate).toBe(21);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { USAIncomeTaxServiceImpl } from '../src/income-tax/usa/USAIncomeTaxServiceImpl';
|
|
2
|
+
import { IncomeTaxRules } from '../src/income-tax/usa/domain/types';
|
|
3
|
+
|
|
4
|
+
// USA 2024 federal income tax rules (single filer)
|
|
5
|
+
const usaRules: IncomeTaxRules = {
|
|
6
|
+
taxBrackets: [
|
|
7
|
+
{ from: 0, to: 11600, rate: 0.10 },
|
|
8
|
+
{ from: 11600, to: 47150, rate: 0.12 },
|
|
9
|
+
{ from: 47150, to: 100525, rate: 0.22 },
|
|
10
|
+
{ from: 100525, to: 191950, rate: 0.24 },
|
|
11
|
+
{ from: 191950, to: 243725, rate: 0.32 },
|
|
12
|
+
{ from: 243725, to: 609350, rate: 0.35 },
|
|
13
|
+
{ from: 609350, to: null, rate: 0.37 },
|
|
14
|
+
],
|
|
15
|
+
standardDeduction: { amount: 14600 },
|
|
16
|
+
fica: {
|
|
17
|
+
socialSecurity: { rate: 0.062, wageBase: 168600 },
|
|
18
|
+
medicare: { rate: 0.0145, additionalRate: 0.009, additionalThreshold: 200000 },
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('USAIncomeTaxServiceImpl', () => {
|
|
23
|
+
it('returns zero income tax for income at or below the standard deduction', () => {
|
|
24
|
+
const service = new USAIncomeTaxServiceImpl(14600, usaRules);
|
|
25
|
+
const result = service.calculateNetIncome();
|
|
26
|
+
|
|
27
|
+
expect(result.incomeTax).toBe(0);
|
|
28
|
+
expect(result.grossIncome).toBe(14600);
|
|
29
|
+
expect(result.standardDeduction).toBe(14600);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('correctly calculates income tax for $50,000 income', () => {
|
|
33
|
+
const service = new USAIncomeTaxServiceImpl(50000, usaRules);
|
|
34
|
+
const result = service.calculateNetIncome();
|
|
35
|
+
|
|
36
|
+
// Standard deduction = $14,600
|
|
37
|
+
// Taxable income = 50000 - 14600 = 35400
|
|
38
|
+
// 10%: 11600 * 0.10 = 1160
|
|
39
|
+
// 12%: (35400 - 11600) * 0.12 = 23800 * 0.12 = 2856
|
|
40
|
+
// Total = 4016
|
|
41
|
+
expect(result.incomeTax).toBe(4016);
|
|
42
|
+
expect(result.standardDeduction).toBe(14600);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('correctly calculates income tax for $100,000 income', () => {
|
|
46
|
+
const service = new USAIncomeTaxServiceImpl(100000, usaRules);
|
|
47
|
+
const result = service.calculateNetIncome();
|
|
48
|
+
|
|
49
|
+
// Taxable income = 100000 - 14600 = 85400
|
|
50
|
+
// 10%: 11600 * 0.10 = 1160
|
|
51
|
+
// 12%: (47150 - 11600) * 0.12 = 35550 * 0.12 = 4266
|
|
52
|
+
// 22%: (85400 - 47150) * 0.22 = 38250 * 0.22 = 8415
|
|
53
|
+
// Total = 13841
|
|
54
|
+
expect(result.incomeTax).toBe(13841);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('correctly calculates income tax for $200,000 income', () => {
|
|
58
|
+
const service = new USAIncomeTaxServiceImpl(200000, usaRules);
|
|
59
|
+
const result = service.calculateNetIncome();
|
|
60
|
+
|
|
61
|
+
// Taxable income = 200000 - 14600 = 185400
|
|
62
|
+
// 10%: 11600 * 0.10 = 1160
|
|
63
|
+
// 12%: (47150 - 11600) * 0.12 = 4266
|
|
64
|
+
// 22%: (100525 - 47150) * 0.22 = 11742.50
|
|
65
|
+
// 24%: (185400 - 100525) * 0.24 = 84875 * 0.24 = 20370
|
|
66
|
+
// Total = 37538.50
|
|
67
|
+
expect(result.incomeTax).toBe(37538.50);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('calculates Social Security correctly up to wage base', () => {
|
|
71
|
+
const service = new USAIncomeTaxServiceImpl(50000, usaRules);
|
|
72
|
+
const result = service.calculateNetIncome();
|
|
73
|
+
|
|
74
|
+
// Social Security: 50000 * 6.2% = 3100
|
|
75
|
+
expect(result.socialSecurity).toBeCloseTo(3100, 2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('caps Social Security at the wage base', () => {
|
|
79
|
+
const service = new USAIncomeTaxServiceImpl(200000, usaRules);
|
|
80
|
+
const result = service.calculateNetIncome();
|
|
81
|
+
|
|
82
|
+
// Social Security: 168600 * 6.2% = 10453.20
|
|
83
|
+
expect(result.socialSecurity).toBeCloseTo(10453.20, 2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('calculates Medicare without additional tax below $200,000 threshold', () => {
|
|
87
|
+
const service = new USAIncomeTaxServiceImpl(100000, usaRules);
|
|
88
|
+
const result = service.calculateNetIncome();
|
|
89
|
+
|
|
90
|
+
// Medicare: 100000 * 1.45% = 1450
|
|
91
|
+
expect(result.medicare).toBeCloseTo(1450, 2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('applies additional Medicare tax above $200,000 threshold', () => {
|
|
95
|
+
const service = new USAIncomeTaxServiceImpl(250000, usaRules);
|
|
96
|
+
const result = service.calculateNetIncome();
|
|
97
|
+
|
|
98
|
+
// Medicare: 250000 * 1.45% + (250000 - 200000) * 0.9% = 3625 + 450 = 4075
|
|
99
|
+
expect(result.medicare).toBeCloseTo(4075, 2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('net income equals gross income minus total deductions', () => {
|
|
103
|
+
const service = new USAIncomeTaxServiceImpl(100000, usaRules);
|
|
104
|
+
const result = service.calculateNetIncome();
|
|
105
|
+
|
|
106
|
+
expect(result.netIncome).toBeCloseTo(
|
|
107
|
+
result.grossIncome - result.totalDeductions,
|
|
108
|
+
2,
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('total deductions equals income tax plus FICA', () => {
|
|
113
|
+
const service = new USAIncomeTaxServiceImpl(100000, usaRules);
|
|
114
|
+
const result = service.calculateNetIncome();
|
|
115
|
+
|
|
116
|
+
expect(result.totalDeductions).toBeCloseTo(
|
|
117
|
+
result.incomeTax + result.socialSecurity + result.medicare,
|
|
118
|
+
2,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('effective tax rate is income tax divided by gross income', () => {
|
|
123
|
+
const service = new USAIncomeTaxServiceImpl(100000, usaRules);
|
|
124
|
+
const result = service.calculateNetIncome();
|
|
125
|
+
|
|
126
|
+
expect(result.effectiveTaxRate).toBeCloseTo(result.incomeTax / result.grossIncome, 4);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns zero tax for zero income', () => {
|
|
130
|
+
const service = new USAIncomeTaxServiceImpl(0, usaRules);
|
|
131
|
+
const result = service.calculateNetIncome();
|
|
132
|
+
|
|
133
|
+
expect(result.incomeTax).toBe(0);
|
|
134
|
+
expect(result.socialSecurity).toBe(0);
|
|
135
|
+
expect(result.medicare).toBe(0);
|
|
136
|
+
expect(result.effectiveTaxRate).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { USAMortgageServiceImpl } from '../src/mortgage/usa/USAMortgageServiceImpl';
|
|
2
|
+
import { MortgageInput, MortgageRules } from '../src/mortgage/usa/domain/types';
|
|
3
|
+
|
|
4
|
+
// USA federal mortgage rules (no federal transfer tax)
|
|
5
|
+
const usaMortgageRules: MortgageRules = {
|
|
6
|
+
loanConstraints: {
|
|
7
|
+
maxLtvPercent: 97,
|
|
8
|
+
maxAmortizationYears: 30,
|
|
9
|
+
},
|
|
10
|
+
interest: {
|
|
11
|
+
compounding: 'MONTHLY',
|
|
12
|
+
},
|
|
13
|
+
transferTax: {
|
|
14
|
+
brackets: [
|
|
15
|
+
{ above: 0, rate: 0 },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const defaultInput: MortgageInput = {
|
|
21
|
+
propertyPrice: 400000,
|
|
22
|
+
downPayment: 80000, // 20% down
|
|
23
|
+
annualInterestRate: 5.0,
|
|
24
|
+
amortizationYears: 30,
|
|
25
|
+
isFirstTimeBuyer: false,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('USAMortgageServiceImpl', () => {
|
|
29
|
+
const service = new USAMortgageServiceImpl();
|
|
30
|
+
|
|
31
|
+
it('calculates loan amount correctly', () => {
|
|
32
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
33
|
+
|
|
34
|
+
expect(result.loanAmount).toBe(320000);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('calculates monthly payment using standard amortization formula', () => {
|
|
38
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
39
|
+
|
|
40
|
+
const r = 0.05 / 12;
|
|
41
|
+
const n = 30 * 12;
|
|
42
|
+
const expected = 320000 * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
|
|
43
|
+
expect(result.monthlyPayment).toBeCloseTo(expected, 2);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('totalPaid equals monthlyPayment * totalPayments', () => {
|
|
47
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
48
|
+
const totalPayments = 30 * 12;
|
|
49
|
+
|
|
50
|
+
expect(result.totalPaid).toBeCloseTo(result.monthlyPayment * totalPayments, 0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('calculates zero federal transfer tax', () => {
|
|
54
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
55
|
+
|
|
56
|
+
expect(result.transferTax).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('generates amortization schedule with correct length', () => {
|
|
60
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
61
|
+
|
|
62
|
+
expect(result.amortizationSchedule.length).toBe(30);
|
|
63
|
+
expect(result.amortizationSchedule[0].year).toBe(1);
|
|
64
|
+
expect(result.amortizationSchedule[29].balance).toBeCloseTo(0, 0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('amortization schedule balance decreases over time', () => {
|
|
68
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
69
|
+
const schedule = result.amortizationSchedule;
|
|
70
|
+
|
|
71
|
+
for (let i = 1; i < schedule.length; i++) {
|
|
72
|
+
expect(schedule[i].balance).toBeLessThan(schedule[i - 1].balance);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('throws error when loan amount is zero or negative', () => {
|
|
77
|
+
const input: MortgageInput = {
|
|
78
|
+
...defaultInput,
|
|
79
|
+
downPayment: 400000,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
expect(() => service.calculate(input, usaMortgageRules)).toThrow('Invalid loan amount');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('otherFees includes transfer tax label', () => {
|
|
86
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
87
|
+
|
|
88
|
+
expect(result.otherFees.notaryFees.label).toBe('TRANSFER_TAX');
|
|
89
|
+
expect(result.otherFees.notaryFees.value).toBe(result.transferTax);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('totalInterestPaid equals totalPaid minus loanAmount', () => {
|
|
93
|
+
const result = service.calculate(defaultInput, usaMortgageRules);
|
|
94
|
+
|
|
95
|
+
expect(result.totalInterestPaid).toBeCloseTo(result.totalPaid - result.loanAmount, 2);
|
|
96
|
+
});
|
|
97
|
+
});
|