@novha/calc-engines 2.0.3 → 3.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/uk/UKCorporateTaxService.js +3 -0
- package/dist/corporate/uk/UKCorporateTaxServiceImpl.js +70 -0
- package/dist/corporate/uk/domain/types.js +3 -0
- package/dist/income-tax/uk/UKIncomeTaxService.js +3 -0
- package/dist/income-tax/uk/UKIncomeTaxServiceImpl.js +96 -0
- package/dist/income-tax/uk/domain/types.js +3 -0
- package/dist/index.js +9 -2
- package/dist/mortgage/uk/UKMortgageService.js +3 -0
- package/dist/mortgage/uk/UKMortgageServiceImpl.js +99 -0
- package/dist/mortgage/uk/domain/types.js +3 -0
- package/dist/types/corporate/uk/UKCorporateTaxService.d.ts +4 -0
- package/dist/types/corporate/uk/UKCorporateTaxServiceImpl.d.ts +10 -0
- package/dist/types/corporate/uk/domain/types.d.ts +32 -0
- package/dist/types/income-tax/uk/UKIncomeTaxService.d.ts +4 -0
- package/dist/types/income-tax/uk/UKIncomeTaxServiceImpl.d.ts +12 -0
- package/dist/types/income-tax/uk/domain/types.d.ts +27 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/mortgage/uk/UKMortgageService.d.ts +4 -0
- package/dist/types/mortgage/uk/UKMortgageServiceImpl.d.ts +9 -0
- package/dist/types/mortgage/uk/domain/types.d.ts +49 -0
- package/package.json +1 -1
- package/src/corporate/uk/UKCorporateTaxService.ts +5 -0
- package/src/corporate/uk/UKCorporateTaxServiceImpl.ts +82 -0
- package/src/corporate/uk/domain/types.ts +38 -0
- package/src/income-tax/uk/UKIncomeTaxService.ts +5 -0
- package/src/income-tax/uk/UKIncomeTaxServiceImpl.ts +133 -0
- package/src/income-tax/uk/domain/types.ts +31 -0
- package/src/index.ts +21 -0
- package/src/mortgage/uk/UKMortgageService.ts +5 -0
- package/src/mortgage/uk/UKMortgageServiceImpl.ts +140 -0
- package/src/mortgage/uk/domain/types.ts +58 -0
- package/test/australia-income-tax.test.ts +10 -10
- package/test/uk-corporate-tax.test.ts +119 -0
- package/test/uk-income-tax.test.ts +146 -0
- package/test/uk-mortgage.test.ts +154 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Breakdown } from "../domain/types";
|
|
2
|
+
import { UKCorporateTaxService } from "./UKCorporateTaxService";
|
|
3
|
+
import { Input, MarginalReliefRule, Result, Rules } from "./domain/types";
|
|
4
|
+
|
|
5
|
+
export class UKCorporateTaxServiceImpl implements UKCorporateTaxService {
|
|
6
|
+
private _input: Input;
|
|
7
|
+
private _rules: Rules;
|
|
8
|
+
|
|
9
|
+
constructor(input: Input, rules: Rules) {
|
|
10
|
+
this._input = input;
|
|
11
|
+
this._rules = rules;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
calculate(): Result {
|
|
15
|
+
const income = this._input.taxableIncome;
|
|
16
|
+
const { smallProfits, main, marginalRelief } = this._rules.regimes;
|
|
17
|
+
|
|
18
|
+
let tax = 0;
|
|
19
|
+
let breakdowns: Breakdown[] = [];
|
|
20
|
+
|
|
21
|
+
if (income <= 0) {
|
|
22
|
+
return { corporateTax: 0, effectiveTaxRate: 0, breakdowns: [] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (income <= marginalRelief.lowerLimit) {
|
|
26
|
+
tax = income * smallProfits.rate;
|
|
27
|
+
breakdowns = [{
|
|
28
|
+
from: '0',
|
|
29
|
+
to: `${marginalRelief.lowerLimit}`,
|
|
30
|
+
rate: smallProfits.rate,
|
|
31
|
+
amount: tax,
|
|
32
|
+
}];
|
|
33
|
+
} else if (income >= marginalRelief.upperLimit) {
|
|
34
|
+
tax = income * main.rate;
|
|
35
|
+
breakdowns = [{
|
|
36
|
+
from: '0',
|
|
37
|
+
to: 'Above',
|
|
38
|
+
rate: main.rate,
|
|
39
|
+
amount: tax,
|
|
40
|
+
}];
|
|
41
|
+
} else {
|
|
42
|
+
tax = this.applyMarginalRelief(income, marginalRelief);
|
|
43
|
+
breakdowns = this.buildMarginalReliefBreakdowns(income, tax, marginalRelief);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
corporateTax: tax,
|
|
48
|
+
effectiveTaxRate: income > 0 ? (tax / income) * 100 : 0,
|
|
49
|
+
breakdowns,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private applyMarginalRelief(income: number, rules: MarginalReliefRule): number {
|
|
54
|
+
const grossTax = income * rules.mainRate;
|
|
55
|
+
const relief = rules.standardFraction * (rules.upperLimit - income);
|
|
56
|
+
return grossTax - relief;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private buildMarginalReliefBreakdowns(
|
|
60
|
+
income: number,
|
|
61
|
+
tax: number,
|
|
62
|
+
rules: MarginalReliefRule,
|
|
63
|
+
): Breakdown[] {
|
|
64
|
+
const grossTax = income * rules.mainRate;
|
|
65
|
+
const relief = rules.standardFraction * (rules.upperLimit - income);
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
from: '0',
|
|
70
|
+
to: 'Above',
|
|
71
|
+
rate: rules.mainRate,
|
|
72
|
+
amount: grossTax,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
from: `${rules.lowerLimit}`,
|
|
76
|
+
to: `${rules.upperLimit}`,
|
|
77
|
+
rate: -(rules.standardFraction),
|
|
78
|
+
amount: -relief,
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Breakdown } from "../../domain/types";
|
|
2
|
+
|
|
3
|
+
export interface FlatTaxRule {
|
|
4
|
+
type: 'flat';
|
|
5
|
+
rate: number;
|
|
6
|
+
conditions?: {
|
|
7
|
+
maxIncome?: number;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MarginalReliefRule {
|
|
12
|
+
type: 'marginal_relief';
|
|
13
|
+
mainRate: number;
|
|
14
|
+
smallProfitsRate: number;
|
|
15
|
+
upperLimit: number;
|
|
16
|
+
lowerLimit: number;
|
|
17
|
+
standardFraction: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TaxRule = FlatTaxRule | MarginalReliefRule;
|
|
21
|
+
|
|
22
|
+
export interface Rules {
|
|
23
|
+
regimes: {
|
|
24
|
+
smallProfits: FlatTaxRule;
|
|
25
|
+
main: FlatTaxRule;
|
|
26
|
+
marginalRelief: MarginalReliefRule;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface Input {
|
|
31
|
+
taxableIncome: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Result {
|
|
35
|
+
corporateTax: number;
|
|
36
|
+
effectiveTaxRate: number;
|
|
37
|
+
breakdowns: Breakdown[];
|
|
38
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { BracketAllocation } from "../domain/types";
|
|
2
|
+
import { UKIncomeTaxService } from "./UKIncomeTaxService";
|
|
3
|
+
import {
|
|
4
|
+
ComputedIncomeTaxValues,
|
|
5
|
+
IncomeTaxRules,
|
|
6
|
+
NationalInsuranceRules,
|
|
7
|
+
PersonalAllowanceRules,
|
|
8
|
+
} from "./domain/types";
|
|
9
|
+
|
|
10
|
+
export class UKIncomeTaxServiceImpl implements UKIncomeTaxService {
|
|
11
|
+
private _income: number;
|
|
12
|
+
private _rules: IncomeTaxRules;
|
|
13
|
+
|
|
14
|
+
constructor(income: number, rules: IncomeTaxRules) {
|
|
15
|
+
this._income = income;
|
|
16
|
+
this._rules = rules;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public calculateNetIncome(): ComputedIncomeTaxValues {
|
|
20
|
+
const effectivePA = this.computePersonalAllowance(
|
|
21
|
+
this._income,
|
|
22
|
+
this._rules.personalAllowance,
|
|
23
|
+
);
|
|
24
|
+
const taxableIncome = Math.max(0, this._income - effectivePA);
|
|
25
|
+
|
|
26
|
+
const { grossTax, bracketBreakdown } = this.computeTaxBrackets(taxableIncome);
|
|
27
|
+
|
|
28
|
+
const ni = this.computeNationalInsurance(
|
|
29
|
+
this._income,
|
|
30
|
+
this._rules.nationalInsurance,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const totalDeductions = grossTax + ni;
|
|
34
|
+
const netIncome = this._income - totalDeductions;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
grossIncome: this._income,
|
|
38
|
+
incomeTax: this.round(grossTax),
|
|
39
|
+
nationalInsurance: this.round(ni),
|
|
40
|
+
personalAllowance: effectivePA,
|
|
41
|
+
totalDeductions: this.round(totalDeductions),
|
|
42
|
+
netIncome: this.round(netIncome),
|
|
43
|
+
effectiveTaxRate: this._income > 0 ? this.round(grossTax / this._income, 4) : 0,
|
|
44
|
+
taxBracketBreakdown: bracketBreakdown,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private computePersonalAllowance(
|
|
49
|
+
income: number,
|
|
50
|
+
rules: PersonalAllowanceRules,
|
|
51
|
+
): number {
|
|
52
|
+
if (income <= rules.taperThreshold) {
|
|
53
|
+
return rules.amount;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const reduction = Math.floor((income - rules.taperThreshold) * rules.taperRate);
|
|
57
|
+
return Math.max(0, rules.amount - reduction);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private computeTaxBrackets(
|
|
61
|
+
taxableIncome: number,
|
|
62
|
+
): { grossTax: number; bracketBreakdown: BracketAllocation[] } {
|
|
63
|
+
let tax = 0;
|
|
64
|
+
const bracketBreakdown: BracketAllocation[] = [];
|
|
65
|
+
|
|
66
|
+
for (let index = 0; index < this._rules.taxBrackets.length; index++) {
|
|
67
|
+
const bracket = this._rules.taxBrackets[index];
|
|
68
|
+
|
|
69
|
+
if (taxableIncome <= bracket.from) {
|
|
70
|
+
bracketBreakdown.push({
|
|
71
|
+
bracketIndex: index,
|
|
72
|
+
bracketName: `Bracket ${index + 1}`,
|
|
73
|
+
from: bracket.from,
|
|
74
|
+
to: bracket.to ?? null,
|
|
75
|
+
rate: bracket.rate,
|
|
76
|
+
amountInBracket: 0,
|
|
77
|
+
taxOnAmount: 0,
|
|
78
|
+
});
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const upper = bracket.to ?? taxableIncome;
|
|
83
|
+
const taxableAmount = Math.min(upper, taxableIncome) - bracket.from;
|
|
84
|
+
|
|
85
|
+
if (taxableAmount > 0) {
|
|
86
|
+
const taxOnAmount = taxableAmount * bracket.rate;
|
|
87
|
+
tax += taxOnAmount;
|
|
88
|
+
bracketBreakdown.push({
|
|
89
|
+
bracketIndex: index,
|
|
90
|
+
bracketName: `Bracket ${index + 1}`,
|
|
91
|
+
from: bracket.from,
|
|
92
|
+
to: bracket.to ?? null,
|
|
93
|
+
rate: bracket.rate,
|
|
94
|
+
amountInBracket: taxableAmount,
|
|
95
|
+
taxOnAmount,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
bracketBreakdown.push({
|
|
99
|
+
bracketIndex: index,
|
|
100
|
+
bracketName: `Bracket ${index + 1}`,
|
|
101
|
+
from: bracket.from,
|
|
102
|
+
to: bracket.to ?? null,
|
|
103
|
+
rate: bracket.rate,
|
|
104
|
+
amountInBracket: 0,
|
|
105
|
+
taxOnAmount: 0,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { grossTax: tax, bracketBreakdown };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private computeNationalInsurance(
|
|
114
|
+
income: number,
|
|
115
|
+
rules: NationalInsuranceRules,
|
|
116
|
+
): number {
|
|
117
|
+
if (income <= rules.primaryThreshold) {
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (income <= rules.upperEarningsLimit) {
|
|
122
|
+
return (income - rules.primaryThreshold) * rules.mainRate;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mainBand = (rules.upperEarningsLimit - rules.primaryThreshold) * rules.mainRate;
|
|
126
|
+
const upperBand = (income - rules.upperEarningsLimit) * rules.upperRate;
|
|
127
|
+
return mainBand + upperBand;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private round(value: number, decimals = 2): number {
|
|
131
|
+
return Number(value.toFixed(decimals));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { BracketAllocation, TaxBracket } from "../../domain/types";
|
|
2
|
+
|
|
3
|
+
export interface IncomeTaxRules {
|
|
4
|
+
taxBrackets: TaxBracket[];
|
|
5
|
+
personalAllowance: PersonalAllowanceRules;
|
|
6
|
+
nationalInsurance: NationalInsuranceRules;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PersonalAllowanceRules {
|
|
10
|
+
amount: number;
|
|
11
|
+
taperThreshold: number;
|
|
12
|
+
taperRate: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface NationalInsuranceRules {
|
|
16
|
+
primaryThreshold: number;
|
|
17
|
+
upperEarningsLimit: number;
|
|
18
|
+
mainRate: number;
|
|
19
|
+
upperRate: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ComputedIncomeTaxValues {
|
|
23
|
+
grossIncome: number;
|
|
24
|
+
incomeTax: number;
|
|
25
|
+
nationalInsurance: number;
|
|
26
|
+
personalAllowance: number;
|
|
27
|
+
totalDeductions: number;
|
|
28
|
+
netIncome: number;
|
|
29
|
+
effectiveTaxRate: number;
|
|
30
|
+
taxBracketBreakdown: BracketAllocation[];
|
|
31
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -81,5 +81,26 @@ export {
|
|
|
81
81
|
Result as AustraliaCorporateTaxResult,
|
|
82
82
|
} from './corporate/australia/domain/types';
|
|
83
83
|
|
|
84
|
+
// United Kingdom
|
|
85
|
+
export { UKIncomeTaxServiceImpl as UKIncomeTaxService } from './income-tax/uk/UKIncomeTaxServiceImpl';
|
|
86
|
+
export {
|
|
87
|
+
ComputedIncomeTaxValues as UKComputedIncomeTaxValues,
|
|
88
|
+
IncomeTaxRules as UKIncomeTaxRules,
|
|
89
|
+
} from './income-tax/uk/domain/types';
|
|
90
|
+
|
|
91
|
+
export { UKCorporateTaxServiceImpl as UKCorporateTaxService } from './corporate/uk/UKCorporateTaxServiceImpl';
|
|
92
|
+
export {
|
|
93
|
+
Input as UKCorporateTaxInput,
|
|
94
|
+
Rules as UKCorporateTaxRules,
|
|
95
|
+
Result as UKCorporateTaxResult,
|
|
96
|
+
} from './corporate/uk/domain/types';
|
|
97
|
+
|
|
98
|
+
export { UKMortgageServiceImpl as UKMortgageService } from './mortgage/uk/UKMortgageServiceImpl';
|
|
99
|
+
export {
|
|
100
|
+
MortgageRules as UKMortgageRules,
|
|
101
|
+
MortgageInput as UKMortgageInput,
|
|
102
|
+
MortgageOutput as UKMortgageOutput,
|
|
103
|
+
} from './mortgage/uk/domain/types';
|
|
104
|
+
|
|
84
105
|
export { IncomeTaxCalculatorSchema } from './income-tax/domain/types';
|
|
85
106
|
export * from './income-tax/domain/types';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { UKMortgageService } from "./UKMortgageService";
|
|
2
|
+
import {
|
|
3
|
+
AmortizationScheduleItem,
|
|
4
|
+
MortgageInput,
|
|
5
|
+
MortgageOutput,
|
|
6
|
+
MortgageRules,
|
|
7
|
+
StampDutyBracket,
|
|
8
|
+
} from "./domain/types";
|
|
9
|
+
|
|
10
|
+
export class UKMortgageServiceImpl implements UKMortgageService {
|
|
11
|
+
|
|
12
|
+
public calculate(input: MortgageInput, rules: MortgageRules): MortgageOutput {
|
|
13
|
+
const loanAmount = input.propertyPrice - input.downPayment;
|
|
14
|
+
|
|
15
|
+
if (loanAmount <= 0) {
|
|
16
|
+
throw new Error('Invalid loan amount: down payment must be less than property price');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const totalPayments = input.amortizationYears * 12;
|
|
20
|
+
const monthlyRate = (input.annualInterestRate / 100) / 12;
|
|
21
|
+
|
|
22
|
+
const monthlyPayment = this.calculatePayment(loanAmount, monthlyRate, totalPayments);
|
|
23
|
+
const totalPaid = monthlyPayment * totalPayments;
|
|
24
|
+
const totalInterestPaid = totalPaid - loanAmount;
|
|
25
|
+
|
|
26
|
+
const stampDuty = this.calculateStampDuty(input, rules);
|
|
27
|
+
|
|
28
|
+
const amortizationSchedule = this.calculateAmortizationSchedule(
|
|
29
|
+
loanAmount,
|
|
30
|
+
monthlyRate,
|
|
31
|
+
monthlyPayment,
|
|
32
|
+
totalPayments,
|
|
33
|
+
input.amortizationYears,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
loanAmount,
|
|
38
|
+
totalMortgage: loanAmount,
|
|
39
|
+
monthlyPayment,
|
|
40
|
+
totalInterestPaid,
|
|
41
|
+
totalPaid,
|
|
42
|
+
stampDuty,
|
|
43
|
+
amortizationSchedule,
|
|
44
|
+
otherFees: {
|
|
45
|
+
notaryFees: {
|
|
46
|
+
value: stampDuty,
|
|
47
|
+
label: 'STAMP_DUTY',
|
|
48
|
+
},
|
|
49
|
+
bankFees: {
|
|
50
|
+
value: 0,
|
|
51
|
+
label: 'BANK_FEES',
|
|
52
|
+
},
|
|
53
|
+
monthlyInsuranceFees: {
|
|
54
|
+
value: 0,
|
|
55
|
+
label: 'MONTHLY_INSURANCE_FEES',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private calculateStampDuty(input: MortgageInput, rules: MortgageRules): number {
|
|
62
|
+
const { propertyPrice, isFirstTimeBuyer } = input;
|
|
63
|
+
const { firstTimeBuyer, standardBrackets } = rules.stampDuty;
|
|
64
|
+
|
|
65
|
+
const useFirstTimeBuyerRules =
|
|
66
|
+
isFirstTimeBuyer && propertyPrice <= firstTimeBuyer.maxEligiblePropertyPrice;
|
|
67
|
+
|
|
68
|
+
const brackets = useFirstTimeBuyerRules ? firstTimeBuyer.brackets : standardBrackets;
|
|
69
|
+
|
|
70
|
+
return this.applyStampDutyBrackets(propertyPrice, brackets);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private applyStampDutyBrackets(propertyPrice: number, brackets: StampDutyBracket[]): number {
|
|
74
|
+
let duty = 0;
|
|
75
|
+
let previousLimit = 0;
|
|
76
|
+
|
|
77
|
+
for (const bracket of brackets) {
|
|
78
|
+
if (bracket.upTo !== undefined && propertyPrice > previousLimit) {
|
|
79
|
+
const taxable = Math.min(propertyPrice, bracket.upTo) - previousLimit;
|
|
80
|
+
duty += taxable * bracket.rate;
|
|
81
|
+
previousLimit = bracket.upTo;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (bracket.above !== undefined && propertyPrice > bracket.above) {
|
|
85
|
+
duty += (propertyPrice - bracket.above) * bracket.rate;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return duty;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private calculateAmortizationSchedule(
|
|
94
|
+
principal: number,
|
|
95
|
+
monthlyRate: number,
|
|
96
|
+
monthlyPayment: number,
|
|
97
|
+
totalPayments: number,
|
|
98
|
+
amortizationYears: number,
|
|
99
|
+
): AmortizationScheduleItem[] {
|
|
100
|
+
const schedule: AmortizationScheduleItem[] = [];
|
|
101
|
+
let balance = principal;
|
|
102
|
+
|
|
103
|
+
for (let year = 1; year <= amortizationYears; year++) {
|
|
104
|
+
let yearlyPrincipal = 0;
|
|
105
|
+
let yearlyInterest = 0;
|
|
106
|
+
|
|
107
|
+
const paymentsInYear = Math.min(12, totalPayments - (year - 1) * 12);
|
|
108
|
+
|
|
109
|
+
for (let payment = 1; payment <= paymentsInYear; payment++) {
|
|
110
|
+
const interestPayment = balance * monthlyRate;
|
|
111
|
+
const principalPayment = monthlyPayment - interestPayment;
|
|
112
|
+
|
|
113
|
+
yearlyInterest += interestPayment;
|
|
114
|
+
yearlyPrincipal += principalPayment;
|
|
115
|
+
balance -= principalPayment;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
schedule.push({
|
|
119
|
+
year,
|
|
120
|
+
principal: yearlyPrincipal,
|
|
121
|
+
interest: yearlyInterest,
|
|
122
|
+
balance: Math.max(0, balance),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (balance <= 0) break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return schedule;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private calculatePayment(principal: number, rate: number, periods: number): number {
|
|
132
|
+
if (rate === 0) {
|
|
133
|
+
return principal / periods;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return principal *
|
|
137
|
+
(rate * Math.pow(1 + rate, periods)) /
|
|
138
|
+
(Math.pow(1 + rate, periods) - 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { OtherFees } from "../../domain/types";
|
|
2
|
+
|
|
3
|
+
export interface MortgageRules {
|
|
4
|
+
loanConstraints: LoanConstraints;
|
|
5
|
+
interest: InterestRules;
|
|
6
|
+
stampDuty: StampDutyRules;
|
|
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 StampDutyBracket {
|
|
19
|
+
upTo?: number;
|
|
20
|
+
above?: number;
|
|
21
|
+
rate: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StampDutyRules {
|
|
25
|
+
standardBrackets: StampDutyBracket[];
|
|
26
|
+
firstTimeBuyer: FirstTimeBuyerStampDuty;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FirstTimeBuyerStampDuty {
|
|
30
|
+
brackets: StampDutyBracket[];
|
|
31
|
+
maxEligiblePropertyPrice: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AmortizationScheduleItem {
|
|
35
|
+
year: number;
|
|
36
|
+
principal: number;
|
|
37
|
+
interest: number;
|
|
38
|
+
balance: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MortgageInput {
|
|
42
|
+
propertyPrice: number;
|
|
43
|
+
downPayment: number;
|
|
44
|
+
annualInterestRate: number;
|
|
45
|
+
amortizationYears: number;
|
|
46
|
+
isFirstTimeBuyer: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MortgageOutput {
|
|
50
|
+
loanAmount: number;
|
|
51
|
+
totalMortgage: number;
|
|
52
|
+
monthlyPayment: number;
|
|
53
|
+
totalInterestPaid: number;
|
|
54
|
+
totalPaid: number;
|
|
55
|
+
stampDuty: number;
|
|
56
|
+
amortizationSchedule: AmortizationScheduleItem[];
|
|
57
|
+
otherFees: OtherFees;
|
|
58
|
+
}
|
|
@@ -31,7 +31,7 @@ const australiaRules: IncomeTaxRules = {
|
|
|
31
31
|
|
|
32
32
|
describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
33
33
|
it('returns zero tax for income below the tax-free threshold', () => {
|
|
34
|
-
const service = new AustraliaIncomeTaxServiceImpl(18200, australiaRules);
|
|
34
|
+
const service = new AustraliaIncomeTaxServiceImpl(18200, australiaRules, true, false);
|
|
35
35
|
const result = service.calculateNetIncome();
|
|
36
36
|
|
|
37
37
|
expect(result.incomeTax).toBe(0);
|
|
@@ -40,7 +40,7 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
it('correctly calculates income tax for $60,000 income', () => {
|
|
43
|
-
const service = new AustraliaIncomeTaxServiceImpl(60000, australiaRules);
|
|
43
|
+
const service = new AustraliaIncomeTaxServiceImpl(60000, australiaRules, true, false);
|
|
44
44
|
const result = service.calculateNetIncome();
|
|
45
45
|
|
|
46
46
|
// Bracket 1: 0 → 18200 @ 0% = 0
|
|
@@ -54,7 +54,7 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
it('applies the Low Income Tax Offset for low incomes', () => {
|
|
57
|
-
const service = new AustraliaIncomeTaxServiceImpl(30000, australiaRules);
|
|
57
|
+
const service = new AustraliaIncomeTaxServiceImpl(30000, australiaRules, true, false);
|
|
58
58
|
const result = service.calculateNetIncome();
|
|
59
59
|
|
|
60
60
|
// Bracket 2: 18200 → 30000 @ 19% = 2242
|
|
@@ -65,7 +65,7 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
it('partially phases out LITO between $37,500 and $45,000', () => {
|
|
68
|
-
const service = new AustraliaIncomeTaxServiceImpl(40000, australiaRules);
|
|
68
|
+
const service = new AustraliaIncomeTaxServiceImpl(40000, australiaRules, true, false);
|
|
69
69
|
const result = service.calculateNetIncome();
|
|
70
70
|
|
|
71
71
|
// Gross tax: 18200→40000 @ 19% = 3382
|
|
@@ -74,7 +74,7 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
it('applies Medicare Levy correctly', () => {
|
|
77
|
-
const service = new AustraliaIncomeTaxServiceImpl(60000, australiaRules);
|
|
77
|
+
const service = new AustraliaIncomeTaxServiceImpl(60000, australiaRules, true, true);
|
|
78
78
|
const result = service.calculateNetIncome();
|
|
79
79
|
|
|
80
80
|
// Medicare Levy: 60000 * 2% = 1200
|
|
@@ -83,7 +83,7 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
83
83
|
|
|
84
84
|
it('applies shading-in Medicare Levy for income in the shading-in range', () => {
|
|
85
85
|
const income = 28000;
|
|
86
|
-
const service = new AustraliaIncomeTaxServiceImpl(income, australiaRules);
|
|
86
|
+
const service = new AustraliaIncomeTaxServiceImpl(income, australiaRules, true, true);
|
|
87
87
|
const result = service.calculateNetIncome();
|
|
88
88
|
|
|
89
89
|
// income (28000) > shadingInThreshold (26000), < fullLevyThreshold (32500)
|
|
@@ -92,14 +92,14 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
it('returns zero Medicare Levy below shading-in threshold', () => {
|
|
95
|
-
const service = new AustraliaIncomeTaxServiceImpl(25000, australiaRules);
|
|
95
|
+
const service = new AustraliaIncomeTaxServiceImpl(25000, australiaRules, true, true);
|
|
96
96
|
const result = service.calculateNetIncome();
|
|
97
97
|
|
|
98
98
|
expect(result.medicareLevy).toBe(0);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
it('calculates high income tax correctly', () => {
|
|
102
|
-
const service = new AustraliaIncomeTaxServiceImpl(200000, australiaRules);
|
|
102
|
+
const service = new AustraliaIncomeTaxServiceImpl(200000, australiaRules, true, true);
|
|
103
103
|
const result = service.calculateNetIncome();
|
|
104
104
|
|
|
105
105
|
// Bracket 1: 0→18200 @ 0% = 0
|
|
@@ -115,7 +115,7 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it('net income equals gross income minus total deductions', () => {
|
|
118
|
-
const service = new AustraliaIncomeTaxServiceImpl(80000, australiaRules);
|
|
118
|
+
const service = new AustraliaIncomeTaxServiceImpl(80000, australiaRules, true, true);
|
|
119
119
|
const result = service.calculateNetIncome();
|
|
120
120
|
|
|
121
121
|
expect(result.netIncome).toBeCloseTo(
|
|
@@ -125,7 +125,7 @@ describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
|
125
125
|
});
|
|
126
126
|
|
|
127
127
|
it('effective tax rate is income tax divided by gross income', () => {
|
|
128
|
-
const service = new AustraliaIncomeTaxServiceImpl(100000, australiaRules);
|
|
128
|
+
const service = new AustraliaIncomeTaxServiceImpl(100000, australiaRules, true, false);
|
|
129
129
|
const result = service.calculateNetIncome();
|
|
130
130
|
|
|
131
131
|
expect(result.effectiveTaxRate).toBeCloseTo(result.incomeTax / result.grossIncome, 4);
|