@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.
Files changed (36) hide show
  1. package/dist/corporate/australia/AustraliaCorporateTaxService.js +3 -0
  2. package/dist/corporate/australia/AustraliaCorporateTaxServiceImpl.js +71 -0
  3. package/dist/corporate/australia/domain/types.js +3 -0
  4. package/dist/income-tax/australia/AustraliaIncomeTaxService.js +3 -0
  5. package/dist/income-tax/australia/AustraliaIncomeTaxServiceImpl.js +97 -0
  6. package/dist/income-tax/australia/domain/types.js +3 -0
  7. package/dist/index.js +9 -2
  8. package/dist/mortgage/australia/AustraliaMortgageService.js +3 -0
  9. package/dist/mortgage/australia/AustraliaMortgageServiceImpl.js +115 -0
  10. package/dist/mortgage/australia/domain/types.js +3 -0
  11. package/dist/types/corporate/australia/AustraliaCorporateTaxService.d.ts +4 -0
  12. package/dist/types/corporate/australia/AustraliaCorporateTaxServiceImpl.d.ts +9 -0
  13. package/dist/types/corporate/australia/domain/types.d.ts +34 -0
  14. package/dist/types/income-tax/australia/AustraliaIncomeTaxService.d.ts +4 -0
  15. package/dist/types/income-tax/australia/AustraliaIncomeTaxServiceImpl.d.ts +12 -0
  16. package/dist/types/income-tax/australia/domain/types.d.ts +35 -0
  17. package/dist/types/index.d.ts +6 -0
  18. package/dist/types/mortgage/australia/AustraliaMortgageService.d.ts +4 -0
  19. package/dist/types/mortgage/australia/AustraliaMortgageServiceImpl.d.ts +9 -0
  20. package/dist/types/mortgage/australia/domain/types.d.ts +55 -0
  21. package/jest.config.js +1 -1
  22. package/package.json +1 -1
  23. package/src/corporate/australia/AustraliaCorporateTaxService.ts +5 -0
  24. package/src/corporate/australia/AustraliaCorporateTaxServiceImpl.ts +88 -0
  25. package/src/corporate/australia/domain/types.ts +41 -0
  26. package/src/income-tax/australia/AustraliaIncomeTaxService.ts +8 -0
  27. package/src/income-tax/australia/AustraliaIncomeTaxServiceImpl.ts +133 -0
  28. package/src/income-tax/australia/domain/types.ts +40 -0
  29. package/src/index.ts +21 -0
  30. package/src/mortgage/australia/AustraliaMortgageService.ts +5 -0
  31. package/src/mortgage/australia/AustraliaMortgageServiceImpl.ts +160 -0
  32. package/src/mortgage/australia/domain/types.ts +65 -0
  33. package/test/australia-corporate-tax.test.ts +97 -0
  34. package/test/australia-income-tax.test.ts +128 -0
  35. package/test/australia-mortgage.test.ts +125 -0
  36. 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
+ });
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "./dist-test"
6
+ },
7
+ "include": ["src/**/*", "test/**/*"],
8
+ "exclude": ["node_modules", "cdk.out"]
9
+ }