@novha/calc-engines 1.7.2 → 2.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/australia/AustraliaCorporateTaxService.js +3 -0
- package/dist/corporate/australia/AustraliaCorporateTaxServiceImpl.js +71 -0
- package/dist/corporate/australia/domain/types.js +3 -0
- package/dist/income-tax/australia/AustraliaIncomeTaxService.js +3 -0
- package/dist/income-tax/australia/AustraliaIncomeTaxServiceImpl.js +97 -0
- package/dist/income-tax/australia/domain/types.js +3 -0
- package/dist/index.js +9 -2
- package/dist/mortgage/australia/AustraliaMortgageService.js +3 -0
- package/dist/mortgage/australia/AustraliaMortgageServiceImpl.js +115 -0
- package/dist/mortgage/australia/domain/types.js +3 -0
- package/dist/types/corporate/australia/AustraliaCorporateTaxService.d.ts +4 -0
- package/dist/types/corporate/australia/AustraliaCorporateTaxServiceImpl.d.ts +9 -0
- package/dist/types/corporate/australia/domain/types.d.ts +34 -0
- package/dist/types/income-tax/australia/AustraliaIncomeTaxService.d.ts +4 -0
- package/dist/types/income-tax/australia/AustraliaIncomeTaxServiceImpl.d.ts +12 -0
- package/dist/types/income-tax/australia/domain/types.d.ts +35 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/mortgage/australia/AustraliaMortgageService.d.ts +4 -0
- package/dist/types/mortgage/australia/AustraliaMortgageServiceImpl.d.ts +9 -0
- package/dist/types/mortgage/australia/domain/types.d.ts +55 -0
- package/jest.config.js +1 -1
- package/package.json +1 -1
- package/src/corporate/australia/AustraliaCorporateTaxService.ts +5 -0
- package/src/corporate/australia/AustraliaCorporateTaxServiceImpl.ts +88 -0
- package/src/corporate/australia/domain/types.ts +41 -0
- package/src/income-tax/australia/AustraliaIncomeTaxService.ts +8 -0
- package/src/income-tax/australia/AustraliaIncomeTaxServiceImpl.ts +133 -0
- package/src/income-tax/australia/domain/types.ts +40 -0
- package/src/index.ts +21 -0
- package/src/mortgage/australia/AustraliaMortgageService.ts +5 -0
- package/src/mortgage/australia/AustraliaMortgageServiceImpl.ts +160 -0
- package/src/mortgage/australia/domain/types.ts +65 -0
- package/test/australia-corporate-tax.test.ts +97 -0
- package/test/australia-income-tax.test.ts +128 -0
- package/test/australia-mortgage.test.ts +125 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { AustraliaIncomeTaxServiceImpl } from '../src/income-tax/australia/AustraliaIncomeTaxServiceImpl';
|
|
2
|
+
import { IncomeTaxRules } from '../src/income-tax/australia/domain/types';
|
|
3
|
+
|
|
4
|
+
// Australia 2024-25 tax rules (Stage 3 tax cuts)
|
|
5
|
+
const australiaRules: IncomeTaxRules = {
|
|
6
|
+
taxBrackets: [
|
|
7
|
+
{ from: 0, to: 18200, rate: 0 },
|
|
8
|
+
{ from: 18200, to: 45000, rate: 0.19 },
|
|
9
|
+
{ from: 45000, to: 120000, rate: 0.325 },
|
|
10
|
+
{ from: 120000, to: 180000, rate: 0.37 },
|
|
11
|
+
{ from: 180000, to: null, rate: 0.45 },
|
|
12
|
+
],
|
|
13
|
+
medicareLevy: {
|
|
14
|
+
rate: 0.02,
|
|
15
|
+
shadingInThreshold: 26000,
|
|
16
|
+
fullLevyThreshold: 32500,
|
|
17
|
+
reductionRate: 0.1,
|
|
18
|
+
},
|
|
19
|
+
lowIncomeTaxOffset: {
|
|
20
|
+
maxOffset: 700,
|
|
21
|
+
phaseOutStart: 37500,
|
|
22
|
+
phaseOutEnd: 45000,
|
|
23
|
+
phaseOutRate: 0.05,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('AustraliaIncomeTaxServiceImpl', () => {
|
|
28
|
+
it('returns zero tax for income below the tax-free threshold', () => {
|
|
29
|
+
const service = new AustraliaIncomeTaxServiceImpl(18200, australiaRules);
|
|
30
|
+
const result = service.calculateNetIncome();
|
|
31
|
+
|
|
32
|
+
expect(result.incomeTax).toBe(0);
|
|
33
|
+
expect(result.grossIncome).toBe(18200);
|
|
34
|
+
expect(result.netIncome).toBe(result.grossIncome - result.totalDeductions);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('correctly calculates income tax for $60,000 income', () => {
|
|
38
|
+
const service = new AustraliaIncomeTaxServiceImpl(60000, australiaRules);
|
|
39
|
+
const result = service.calculateNetIncome();
|
|
40
|
+
|
|
41
|
+
// Bracket 1: 0 → 18200 @ 0% = 0
|
|
42
|
+
// Bracket 2: 18200 → 45000 @ 19% = 5092
|
|
43
|
+
// Bracket 3: 45000 → 60000 @ 32.5% = 4875
|
|
44
|
+
// Gross tax = 9967
|
|
45
|
+
// LITO: income (60000) > phaseOutEnd (45000) → offset = 0
|
|
46
|
+
// Net income tax = 9967
|
|
47
|
+
expect(result.incomeTax).toBe(9967);
|
|
48
|
+
expect(result.grossIncome).toBe(60000);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('applies the Low Income Tax Offset for low incomes', () => {
|
|
52
|
+
const service = new AustraliaIncomeTaxServiceImpl(30000, australiaRules);
|
|
53
|
+
const result = service.calculateNetIncome();
|
|
54
|
+
|
|
55
|
+
// Bracket 2: 18200 → 30000 @ 19% = 2242
|
|
56
|
+
// LITO: income 30000 <= 37500 → max offset 700
|
|
57
|
+
// Net tax = max(0, 2242 - 700) = 1542
|
|
58
|
+
expect(result.lowIncomeTaxOffset).toBe(700);
|
|
59
|
+
expect(result.incomeTax).toBe(1542);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('partially phases out LITO between $37,500 and $45,000', () => {
|
|
63
|
+
const service = new AustraliaIncomeTaxServiceImpl(40000, australiaRules);
|
|
64
|
+
const result = service.calculateNetIncome();
|
|
65
|
+
|
|
66
|
+
// Gross tax: 18200→40000 @ 19% = 3382
|
|
67
|
+
// LITO: 700 - (40000 - 37500) * 0.05 = 700 - 125 = 575
|
|
68
|
+
expect(result.lowIncomeTaxOffset).toBe(575);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('applies Medicare Levy correctly', () => {
|
|
72
|
+
const service = new AustraliaIncomeTaxServiceImpl(60000, australiaRules);
|
|
73
|
+
const result = service.calculateNetIncome();
|
|
74
|
+
|
|
75
|
+
// Medicare Levy: 60000 * 2% = 1200
|
|
76
|
+
expect(result.medicareLevy).toBe(1200);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('applies shading-in Medicare Levy for income in the shading-in range', () => {
|
|
80
|
+
const income = 28000;
|
|
81
|
+
const service = new AustraliaIncomeTaxServiceImpl(income, australiaRules);
|
|
82
|
+
const result = service.calculateNetIncome();
|
|
83
|
+
|
|
84
|
+
// income (28000) > shadingInThreshold (26000), < fullLevyThreshold (32500)
|
|
85
|
+
// levy = (28000 - 26000) * 0.1 = 200
|
|
86
|
+
expect(result.medicareLevy).toBe(200);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns zero Medicare Levy below shading-in threshold', () => {
|
|
90
|
+
const service = new AustraliaIncomeTaxServiceImpl(25000, australiaRules);
|
|
91
|
+
const result = service.calculateNetIncome();
|
|
92
|
+
|
|
93
|
+
expect(result.medicareLevy).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('calculates high income tax correctly', () => {
|
|
97
|
+
const service = new AustraliaIncomeTaxServiceImpl(200000, australiaRules);
|
|
98
|
+
const result = service.calculateNetIncome();
|
|
99
|
+
|
|
100
|
+
// Bracket 1: 0→18200 @ 0% = 0
|
|
101
|
+
// Bracket 2: 18200→45000 @ 19% = 5092
|
|
102
|
+
// Bracket 3: 45000→120000 @ 32.5% = 24375
|
|
103
|
+
// Bracket 4: 120000→180000 @ 37% = 22200
|
|
104
|
+
// Bracket 5: 180000→200000 @ 45% = 9000
|
|
105
|
+
// Gross tax = 60667
|
|
106
|
+
// LITO: 0 (income > 45000)
|
|
107
|
+
// Net income tax = 60667
|
|
108
|
+
expect(result.incomeTax).toBe(60667);
|
|
109
|
+
expect(result.medicareLevy).toBe(4000); // 200000 * 2%
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('net income equals gross income minus total deductions', () => {
|
|
113
|
+
const service = new AustraliaIncomeTaxServiceImpl(80000, australiaRules);
|
|
114
|
+
const result = service.calculateNetIncome();
|
|
115
|
+
|
|
116
|
+
expect(result.netIncome).toBeCloseTo(
|
|
117
|
+
result.grossIncome - result.totalDeductions,
|
|
118
|
+
2,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('effective tax rate is income tax divided by gross income', () => {
|
|
123
|
+
const service = new AustraliaIncomeTaxServiceImpl(100000, australiaRules);
|
|
124
|
+
const result = service.calculateNetIncome();
|
|
125
|
+
|
|
126
|
+
expect(result.effectiveTaxRate).toBeCloseTo(result.incomeTax / result.grossIncome, 4);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { AustraliaMortgageServiceImpl } from '../src/mortgage/australia/AustraliaMortgageServiceImpl';
|
|
2
|
+
import { MortgageInput, MortgageRules } from '../src/mortgage/australia/domain/types';
|
|
3
|
+
|
|
4
|
+
const australiaMortgageRules: MortgageRules = {
|
|
5
|
+
loanConstraints: {
|
|
6
|
+
maxLvr: 0.95,
|
|
7
|
+
maxAmortizationYears: 30,
|
|
8
|
+
},
|
|
9
|
+
lendersMortgageInsurance: {
|
|
10
|
+
requiredAboveLvr: 0.8,
|
|
11
|
+
premiumRates: [
|
|
12
|
+
{ maxLvr: 0.85, rate: 0.006 },
|
|
13
|
+
{ maxLvr: 0.90, rate: 0.012 },
|
|
14
|
+
{ maxLvr: 0.95, rate: 0.022 },
|
|
15
|
+
],
|
|
16
|
+
premiumAddedToLoan: true,
|
|
17
|
+
},
|
|
18
|
+
interest: {
|
|
19
|
+
compounding: 'MONTHLY',
|
|
20
|
+
},
|
|
21
|
+
stampDuty: {
|
|
22
|
+
brackets: [
|
|
23
|
+
{ upTo: 14000, rate: 0.014 },
|
|
24
|
+
{ upTo: 32000, rate: 0.035 },
|
|
25
|
+
{ upTo: 85000, rate: 0.045 },
|
|
26
|
+
{ upTo: 319000, rate: 0.0475 },
|
|
27
|
+
{ upTo: 1000000, rate: 0.05 },
|
|
28
|
+
{ above: 1000000, rate: 0.055 },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const defaultInput: MortgageInput = {
|
|
34
|
+
propertyPrice: 600000,
|
|
35
|
+
downPayment: 120000, // 20% down, LVR = 80%
|
|
36
|
+
annualInterestRate: 6.0,
|
|
37
|
+
amortizationYears: 30,
|
|
38
|
+
paymentFrequency: 'MONTHLY',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe('AustraliaMortgageServiceImpl', () => {
|
|
42
|
+
const service = new AustraliaMortgageServiceImpl();
|
|
43
|
+
|
|
44
|
+
it('calculates loan amount correctly', () => {
|
|
45
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
46
|
+
|
|
47
|
+
expect(result.loanAmount).toBe(480000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('does not apply LMI when LVR is exactly 80%', () => {
|
|
51
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
52
|
+
|
|
53
|
+
expect(result.lmiPremium).toBe(0);
|
|
54
|
+
expect(result.totalMortgage).toBe(480000);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('applies LMI when LVR exceeds 80%', () => {
|
|
58
|
+
const input: MortgageInput = {
|
|
59
|
+
...defaultInput,
|
|
60
|
+
downPayment: 60000, // 10% down, LVR = 90%
|
|
61
|
+
};
|
|
62
|
+
const result = service.calculate(input, australiaMortgageRules);
|
|
63
|
+
|
|
64
|
+
// LVR = 540000 / 600000 = 90%, rate = 1.2%
|
|
65
|
+
expect(result.lmiPremium).toBe(540000 * 0.012);
|
|
66
|
+
expect(result.totalMortgage).toBe(540000 + 540000 * 0.012);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('calculates monthly payment for standard mortgage', () => {
|
|
70
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
71
|
+
|
|
72
|
+
// Standard amortization formula
|
|
73
|
+
const r = 0.06 / 12;
|
|
74
|
+
const n = 30 * 12;
|
|
75
|
+
const expected = 480000 * (r * Math.pow(1 + r, n)) / (Math.pow(1 + r, n) - 1);
|
|
76
|
+
expect(result.monthlyPayment).toBeCloseTo(expected, 2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('totalPaid = monthlyPayment * totalPayments', () => {
|
|
80
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
81
|
+
const totalPayments = 30 * 12;
|
|
82
|
+
|
|
83
|
+
expect(result.totalPaid).toBeCloseTo(result.monthlyPayment * totalPayments, 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('calculates stamp duty', () => {
|
|
87
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
88
|
+
|
|
89
|
+
// stamp duty for 600000: falls in upTo: 1000000 bracket
|
|
90
|
+
expect(result.stampDuty).toBeGreaterThan(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('throws error when loan amount is zero or negative', () => {
|
|
94
|
+
const input: MortgageInput = {
|
|
95
|
+
...defaultInput,
|
|
96
|
+
downPayment: 600000,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
expect(() => service.calculate(input, australiaMortgageRules)).toThrow('Invalid loan amount');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('generates amortization schedule', () => {
|
|
103
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
104
|
+
|
|
105
|
+
expect(result.amortizationSchedule.length).toBe(30);
|
|
106
|
+
expect(result.amortizationSchedule[0].year).toBe(1);
|
|
107
|
+
expect(result.amortizationSchedule[29].balance).toBeCloseTo(0, 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('amortization schedule balance decreases over time', () => {
|
|
111
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
112
|
+
const schedule = result.amortizationSchedule;
|
|
113
|
+
|
|
114
|
+
for (let i = 1; i < schedule.length; i++) {
|
|
115
|
+
expect(schedule[i].balance).toBeLessThan(schedule[i - 1].balance);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('otherFees includes stamp duty and LMI', () => {
|
|
120
|
+
const result = service.calculate(defaultInput, australiaMortgageRules);
|
|
121
|
+
|
|
122
|
+
expect(result.otherFees.notaryFees.label).toBe('STAMP_DUTY');
|
|
123
|
+
expect(result.otherFees.monthlyInsuranceFees.label).toBe('LMI_PREMIUM');
|
|
124
|
+
});
|
|
125
|
+
});
|