jaz-cli 2.6.0 → 2.8.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/assets/skills/api/SKILL.md +12 -2
- package/assets/skills/api/references/dependencies.md +3 -2
- package/assets/skills/api/references/endpoints.md +78 -0
- package/assets/skills/api/references/feature-glossary.md +4 -4
- package/assets/skills/api/references/field-map.md +17 -0
- package/assets/skills/api/references/full-api-surface.md +1 -1
- package/assets/skills/conversion/SKILL.md +1 -1
- package/assets/skills/jobs/SKILL.md +104 -0
- package/assets/skills/jobs/references/audit-prep.md +319 -0
- package/assets/skills/jobs/references/bank-recon.md +234 -0
- package/assets/skills/jobs/references/building-blocks.md +135 -0
- package/assets/skills/jobs/references/credit-control.md +273 -0
- package/assets/skills/jobs/references/fa-review.md +267 -0
- package/assets/skills/jobs/references/gst-vat-filing.md +250 -0
- package/assets/skills/jobs/references/month-end-close.md +308 -0
- package/assets/skills/jobs/references/payment-run.md +246 -0
- package/assets/skills/jobs/references/quarter-end-close.md +268 -0
- package/assets/skills/jobs/references/supplier-recon.md +330 -0
- package/assets/skills/jobs/references/year-end-close.md +341 -0
- package/assets/skills/transaction-recipes/SKILL.md +1 -1
- package/dist/__tests__/amortization.test.js +101 -0
- package/dist/__tests__/asset-disposal.test.js +249 -0
- package/dist/__tests__/blueprint.test.js +72 -0
- package/dist/__tests__/depreciation.test.js +125 -0
- package/dist/__tests__/ecl.test.js +134 -0
- package/dist/__tests__/fixed-deposit.test.js +214 -0
- package/dist/__tests__/fx-reval.test.js +115 -0
- package/dist/__tests__/jobs-audit-prep.test.js +125 -0
- package/dist/__tests__/jobs-bank-recon.test.js +108 -0
- package/dist/__tests__/jobs-credit-control.test.js +98 -0
- package/dist/__tests__/jobs-fa-review.test.js +104 -0
- package/dist/__tests__/jobs-gst-vat.test.js +113 -0
- package/dist/__tests__/jobs-month-end.test.js +162 -0
- package/dist/__tests__/jobs-payment-run.test.js +106 -0
- package/dist/__tests__/jobs-quarter-end.test.js +155 -0
- package/dist/__tests__/jobs-supplier-recon.test.js +115 -0
- package/dist/__tests__/jobs-validate.test.js +181 -0
- package/dist/__tests__/jobs-year-end.test.js +149 -0
- package/dist/__tests__/lease.test.js +96 -0
- package/dist/__tests__/loan.test.js +80 -0
- package/dist/__tests__/provision.test.js +141 -0
- package/dist/__tests__/validate.test.js +81 -0
- package/dist/calc/asset-disposal.js +17 -13
- package/dist/calc/fixed-deposit.js +26 -17
- package/dist/calc/lease.js +7 -3
- package/dist/commands/jobs.js +184 -0
- package/dist/index.js +2 -0
- package/dist/jobs/audit-prep.js +211 -0
- package/dist/jobs/bank-recon.js +163 -0
- package/dist/jobs/credit-control.js +126 -0
- package/dist/jobs/fa-review.js +121 -0
- package/dist/jobs/format.js +102 -0
- package/dist/jobs/gst-vat.js +187 -0
- package/dist/jobs/month-end.js +232 -0
- package/dist/jobs/payment-run.js +199 -0
- package/dist/jobs/quarter-end.js +135 -0
- package/dist/jobs/supplier-recon.js +132 -0
- package/dist/jobs/types.js +36 -0
- package/dist/jobs/validate.js +115 -0
- package/dist/jobs/year-end.js +153 -0
- package/dist/types/index.js +2 -1
- package/package.json +5 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { calculateProvision } from '../calc/provision.js';
|
|
3
|
+
import { CalcValidationError } from '../calc/validate.js';
|
|
4
|
+
describe('calculateProvision (standard)', () => {
|
|
5
|
+
const base = { amount: 500000, annualRate: 4, termMonths: 60 };
|
|
6
|
+
it('PV < nominal amount', () => {
|
|
7
|
+
const r = calculateProvision(base);
|
|
8
|
+
expect(r.presentValue).toBeLessThan(r.nominalAmount);
|
|
9
|
+
});
|
|
10
|
+
it('totalUnwinding = nominal - PV', () => {
|
|
11
|
+
const r = calculateProvision(base);
|
|
12
|
+
expect(r.totalUnwinding).toBe(Math.round((r.nominalAmount - r.presentValue) * 100) / 100);
|
|
13
|
+
});
|
|
14
|
+
it('schedule length = termMonths', () => {
|
|
15
|
+
const r = calculateProvision(base);
|
|
16
|
+
expect(r.schedule).toHaveLength(60);
|
|
17
|
+
});
|
|
18
|
+
it('final closing balance = nominal amount exactly', () => {
|
|
19
|
+
const r = calculateProvision(base);
|
|
20
|
+
expect(r.schedule[r.schedule.length - 1].closingBalance).toBe(500000);
|
|
21
|
+
});
|
|
22
|
+
it('opening balance = previous closing balance', () => {
|
|
23
|
+
const r = calculateProvision(base);
|
|
24
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
25
|
+
expect(r.schedule[i].openingBalance).toBe(r.schedule[i - 1].closingBalance);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
it('first opening balance = PV', () => {
|
|
29
|
+
const r = calculateProvision(base);
|
|
30
|
+
expect(r.schedule[0].openingBalance).toBe(r.presentValue);
|
|
31
|
+
});
|
|
32
|
+
it('all interest amounts are positive', () => {
|
|
33
|
+
const r = calculateProvision(base);
|
|
34
|
+
for (const row of r.schedule) {
|
|
35
|
+
expect(row.interest).toBeGreaterThan(0);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
it('closing balance increases monotonically (unwinding)', () => {
|
|
39
|
+
const r = calculateProvision(base);
|
|
40
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
41
|
+
expect(r.schedule[i].closingBalance).toBeGreaterThan(r.schedule[i - 1].openingBalance);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('calculateProvision (journal entries)', () => {
|
|
46
|
+
const base = { amount: 500000, annualRate: 4, termMonths: 60 };
|
|
47
|
+
it('initial journal: debits Provision Expense, credits Provision for Obligations', () => {
|
|
48
|
+
const r = calculateProvision(base);
|
|
49
|
+
expect(r.initialJournal.lines[0].account).toBe('Provision Expense');
|
|
50
|
+
expect(r.initialJournal.lines[0].debit).toBe(r.presentValue);
|
|
51
|
+
expect(r.initialJournal.lines[1].account).toBe('Provision for Obligations');
|
|
52
|
+
expect(r.initialJournal.lines[1].credit).toBe(r.presentValue);
|
|
53
|
+
});
|
|
54
|
+
it('initial journal is balanced', () => {
|
|
55
|
+
const r = calculateProvision(base);
|
|
56
|
+
const debits = r.initialJournal.lines.reduce((s, l) => s + l.debit, 0);
|
|
57
|
+
const credits = r.initialJournal.lines.reduce((s, l) => s + l.credit, 0);
|
|
58
|
+
expect(debits).toBe(credits);
|
|
59
|
+
});
|
|
60
|
+
it('unwinding journals: debits Finance Cost, credits Provision', () => {
|
|
61
|
+
const r = calculateProvision(base);
|
|
62
|
+
for (const row of r.schedule) {
|
|
63
|
+
expect(row.journal.lines[0].account).toBe('Finance Cost \u2014 Unwinding');
|
|
64
|
+
expect(row.journal.lines[1].account).toBe('Provision for Obligations');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
it('every unwinding journal is balanced', () => {
|
|
68
|
+
const r = calculateProvision(base);
|
|
69
|
+
for (const row of r.schedule) {
|
|
70
|
+
const debits = row.journal.lines.reduce((s, l) => s + l.debit, 0);
|
|
71
|
+
const credits = row.journal.lines.reduce((s, l) => s + l.credit, 0);
|
|
72
|
+
expect(debits).toBe(credits);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('calculateProvision (blueprint)', () => {
|
|
77
|
+
it('blueprint present when startDate given', () => {
|
|
78
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 60, startDate: '2025-01-01' });
|
|
79
|
+
expect(r.blueprint).not.toBeNull();
|
|
80
|
+
expect(r.blueprint.capsuleDescription).toBeTruthy();
|
|
81
|
+
});
|
|
82
|
+
it('blueprint null without startDate', () => {
|
|
83
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 60 });
|
|
84
|
+
expect(r.blueprint).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
it('step count = 1 recognition + N unwinding + 1 settlement', () => {
|
|
87
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
88
|
+
expect(r.blueprint.steps).toHaveLength(14); // 1 + 12 + 1
|
|
89
|
+
});
|
|
90
|
+
it('step 1 = journal (initial recognition)', () => {
|
|
91
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
92
|
+
expect(r.blueprint.steps[0].action).toBe('journal');
|
|
93
|
+
});
|
|
94
|
+
it('last step = cash-out (settlement)', () => {
|
|
95
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
96
|
+
const lastStep = r.blueprint.steps[r.blueprint.steps.length - 1];
|
|
97
|
+
expect(lastStep.action).toBe('cash-out');
|
|
98
|
+
});
|
|
99
|
+
it('settlement amount = nominal obligation', () => {
|
|
100
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
101
|
+
const lastStep = r.blueprint.steps[r.blueprint.steps.length - 1];
|
|
102
|
+
const debits = lastStep.lines.reduce((s, l) => s + l.debit, 0);
|
|
103
|
+
expect(debits).toBe(500000);
|
|
104
|
+
});
|
|
105
|
+
it('capsuleType is Provisions', () => {
|
|
106
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
107
|
+
expect(r.blueprint.capsuleType).toBe('Provisions');
|
|
108
|
+
});
|
|
109
|
+
it('capsuleDescription contains IAS 37', () => {
|
|
110
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, startDate: '2025-01-01' });
|
|
111
|
+
expect(r.blueprint.capsuleDescription).toContain('IAS 37');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('calculateProvision (currency)', () => {
|
|
115
|
+
it('currency passes through', () => {
|
|
116
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12, currency: 'SGD' });
|
|
117
|
+
expect(r.currency).toBe('SGD');
|
|
118
|
+
});
|
|
119
|
+
it('currency null when not provided', () => {
|
|
120
|
+
const r = calculateProvision({ amount: 500000, annualRate: 4, termMonths: 12 });
|
|
121
|
+
expect(r.currency).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
describe('calculateProvision (validation)', () => {
|
|
125
|
+
it('rejects zero amount', () => {
|
|
126
|
+
expect(() => calculateProvision({ amount: 0, annualRate: 4, termMonths: 60 })).toThrow(CalcValidationError);
|
|
127
|
+
});
|
|
128
|
+
it('rejects negative rate', () => {
|
|
129
|
+
expect(() => calculateProvision({ amount: 500000, annualRate: -1, termMonths: 60 })).toThrow(CalcValidationError);
|
|
130
|
+
});
|
|
131
|
+
it('rejects zero term', () => {
|
|
132
|
+
expect(() => calculateProvision({ amount: 500000, annualRate: 4, termMonths: 0 })).toThrow(CalcValidationError);
|
|
133
|
+
});
|
|
134
|
+
it('rejects invalid date format', () => {
|
|
135
|
+
expect(() => calculateProvision({ amount: 500000, annualRate: 4, termMonths: 60, startDate: '01-01-2025' })).toThrow(CalcValidationError);
|
|
136
|
+
});
|
|
137
|
+
it('allows zero discount rate', () => {
|
|
138
|
+
const r = calculateProvision({ amount: 500000, annualRate: 0, termMonths: 12 });
|
|
139
|
+
expect(r.presentValue).toBe(500000); // no discounting
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { CalcValidationError, validatePositive, validateNonNegative, validatePositiveInteger, validateSalvageLessThanCost, validateDateFormat, validateRate, } from '../calc/validate.js';
|
|
3
|
+
describe('validatePositive', () => {
|
|
4
|
+
it('passes for positive values', () => {
|
|
5
|
+
expect(() => validatePositive(1, 'x')).not.toThrow();
|
|
6
|
+
expect(() => validatePositive(0.01, 'x')).not.toThrow();
|
|
7
|
+
expect(() => validatePositive(1_000_000, 'x')).not.toThrow();
|
|
8
|
+
});
|
|
9
|
+
it('rejects zero', () => {
|
|
10
|
+
expect(() => validatePositive(0, 'Amount')).toThrow(CalcValidationError);
|
|
11
|
+
expect(() => validatePositive(0, 'Amount')).toThrow('positive number');
|
|
12
|
+
});
|
|
13
|
+
it('rejects negative', () => {
|
|
14
|
+
expect(() => validatePositive(-5, 'Principal')).toThrow(CalcValidationError);
|
|
15
|
+
});
|
|
16
|
+
it('rejects NaN and Infinity', () => {
|
|
17
|
+
expect(() => validatePositive(NaN, 'x')).toThrow(CalcValidationError);
|
|
18
|
+
expect(() => validatePositive(Infinity, 'x')).toThrow(CalcValidationError);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('validateNonNegative', () => {
|
|
22
|
+
it('passes for zero and positive', () => {
|
|
23
|
+
expect(() => validateNonNegative(0, 'x')).not.toThrow();
|
|
24
|
+
expect(() => validateNonNegative(100, 'x')).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
it('rejects negative', () => {
|
|
27
|
+
expect(() => validateNonNegative(-1, 'Salvage')).toThrow(CalcValidationError);
|
|
28
|
+
expect(() => validateNonNegative(-1, 'Salvage')).toThrow('zero or positive');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe('validatePositiveInteger', () => {
|
|
32
|
+
it('passes for positive integers', () => {
|
|
33
|
+
expect(() => validatePositiveInteger(1, 'x')).not.toThrow();
|
|
34
|
+
expect(() => validatePositiveInteger(60, 'x')).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
it('rejects zero', () => {
|
|
37
|
+
expect(() => validatePositiveInteger(0, 'Term')).toThrow(CalcValidationError);
|
|
38
|
+
});
|
|
39
|
+
it('rejects non-integers', () => {
|
|
40
|
+
expect(() => validatePositiveInteger(1.5, 'Term')).toThrow(CalcValidationError);
|
|
41
|
+
});
|
|
42
|
+
it('rejects negative integers', () => {
|
|
43
|
+
expect(() => validatePositiveInteger(-3, 'Term')).toThrow(CalcValidationError);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('validateSalvageLessThanCost', () => {
|
|
47
|
+
it('passes when salvage < cost', () => {
|
|
48
|
+
expect(() => validateSalvageLessThanCost(5000, 50000)).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
it('rejects when salvage >= cost', () => {
|
|
51
|
+
expect(() => validateSalvageLessThanCost(50000, 50000)).toThrow(CalcValidationError);
|
|
52
|
+
expect(() => validateSalvageLessThanCost(60000, 50000)).toThrow(CalcValidationError);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('validateDateFormat', () => {
|
|
56
|
+
it('passes for valid YYYY-MM-DD', () => {
|
|
57
|
+
expect(() => validateDateFormat('2025-01-01')).not.toThrow();
|
|
58
|
+
expect(() => validateDateFormat('2025-12-31')).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
it('passes for undefined (optional)', () => {
|
|
61
|
+
expect(() => validateDateFormat(undefined)).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
it('rejects wrong format', () => {
|
|
64
|
+
expect(() => validateDateFormat('01-01-2025')).toThrow(CalcValidationError);
|
|
65
|
+
expect(() => validateDateFormat('2025/01/01')).toThrow(CalcValidationError);
|
|
66
|
+
expect(() => validateDateFormat('Jan 1 2025')).toThrow(CalcValidationError);
|
|
67
|
+
});
|
|
68
|
+
it('rejects invalid dates', () => {
|
|
69
|
+
expect(() => validateDateFormat('2025-13-01')).toThrow(CalcValidationError);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('validateRate', () => {
|
|
73
|
+
it('passes for normal rates', () => {
|
|
74
|
+
expect(() => validateRate(0, 'Rate')).not.toThrow();
|
|
75
|
+
expect(() => validateRate(6, 'Rate')).not.toThrow();
|
|
76
|
+
expect(() => validateRate(100, 'Rate')).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
it('rejects negative rates', () => {
|
|
79
|
+
expect(() => validateRate(-1, 'Rate')).toThrow(CalcValidationError);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -77,19 +77,23 @@ export function calculateAssetDisposal(inputs) {
|
|
|
77
77
|
validateNonNegative(proceeds, 'Disposal proceeds');
|
|
78
78
|
validateDateFormat(acquisitionDate);
|
|
79
79
|
validateDateFormat(disposalDate);
|
|
80
|
+
// Normalize monetary inputs to 2dp to guarantee balanced journals
|
|
81
|
+
const cost2 = round2(cost);
|
|
82
|
+
const salvage2 = round2(salvageValue);
|
|
83
|
+
const proceeds2 = round2(proceeds);
|
|
80
84
|
// Validate disposal is after acquisition
|
|
81
85
|
if (disposalDate <= acquisitionDate) {
|
|
82
86
|
throw new CalcValidationError('Disposal date must be after acquisition date.');
|
|
83
87
|
}
|
|
84
88
|
const monthsHeld = monthsBetween(acquisitionDate, disposalDate);
|
|
85
|
-
const accumulatedDepreciation = computeAccumDepreciation(
|
|
86
|
-
const netBookValue = round2(
|
|
87
|
-
const gainOrLoss = round2(
|
|
89
|
+
const accumulatedDepreciation = computeAccumDepreciation(cost2, salvage2, usefulLifeYears, method, monthsHeld);
|
|
90
|
+
const netBookValue = round2(cost2 - accumulatedDepreciation);
|
|
91
|
+
const gainOrLoss = round2(proceeds2 - netBookValue);
|
|
88
92
|
const isGain = gainOrLoss >= 0;
|
|
89
93
|
// Build disposal journal entry
|
|
90
94
|
const lines = [];
|
|
91
|
-
if (
|
|
92
|
-
lines.push({ account: 'Cash / Bank Account', debit:
|
|
95
|
+
if (proceeds2 > 0) {
|
|
96
|
+
lines.push({ account: 'Cash / Bank Account', debit: proceeds2, credit: 0 });
|
|
93
97
|
}
|
|
94
98
|
if (accumulatedDepreciation > 0) {
|
|
95
99
|
lines.push({ account: 'Accumulated Depreciation', debit: accumulatedDepreciation, credit: 0 });
|
|
@@ -97,7 +101,7 @@ export function calculateAssetDisposal(inputs) {
|
|
|
97
101
|
if (!isGain) {
|
|
98
102
|
lines.push({ account: 'Loss on Disposal', debit: Math.abs(gainOrLoss), credit: 0 });
|
|
99
103
|
}
|
|
100
|
-
lines.push({ account: 'Fixed Asset (at cost)', debit: 0, credit:
|
|
104
|
+
lines.push({ account: 'Fixed Asset (at cost)', debit: 0, credit: cost2 });
|
|
101
105
|
if (isGain && gainOrLoss > 0) {
|
|
102
106
|
lines.push({ account: 'Gain on Disposal', debit: 0, credit: gainOrLoss });
|
|
103
107
|
}
|
|
@@ -110,16 +114,16 @@ export function calculateAssetDisposal(inputs) {
|
|
|
110
114
|
const methodLabel = method === 'sl' ? 'Straight-line' : method === 'ddb' ? 'Double declining' : '150% declining';
|
|
111
115
|
const workings = [
|
|
112
116
|
`Asset Disposal Workings (IAS 16)`,
|
|
113
|
-
`Cost: ${fmtAmt(
|
|
117
|
+
`Cost: ${fmtAmt(cost2, c)} | Salvage: ${fmtAmt(salvage2, c)} | Life: ${usefulLifeYears} years (${methodLabel})`,
|
|
114
118
|
`Acquired: ${acquisitionDate} | Disposed: ${disposalDate} | Held: ${monthsHeld} months`,
|
|
115
119
|
`Accumulated depreciation: ${fmtAmt(accumulatedDepreciation, c)} | NBV: ${fmtAmt(netBookValue, c)}`,
|
|
116
|
-
`Proceeds: ${fmtAmt(
|
|
117
|
-
`Method: IAS 16.68 — Gain/Loss = Proceeds − NBV = ${fmtAmt(
|
|
120
|
+
`Proceeds: ${fmtAmt(proceeds2, c)} | ${isGain ? (gainOrLoss > 0 ? `Gain: ${fmtAmt(gainOrLoss, c)}` : 'At book value (no gain/loss)') : `Loss: ${fmtAmt(Math.abs(gainOrLoss), c)}`}`,
|
|
121
|
+
`Method: IAS 16.68 — Gain/Loss = Proceeds − NBV = ${fmtAmt(proceeds2, c)} − ${fmtAmt(netBookValue, c)} = ${fmtAmt(gainOrLoss, c)}`,
|
|
118
122
|
].join('\n');
|
|
119
123
|
let blueprint = null;
|
|
120
124
|
blueprint = {
|
|
121
125
|
capsuleType: 'Asset Disposal',
|
|
122
|
-
capsuleName: `Asset Disposal — ${fmtCapsuleAmount(
|
|
126
|
+
capsuleName: `Asset Disposal — ${fmtCapsuleAmount(cost2, currency)} asset — ${disposalDate}`,
|
|
123
127
|
capsuleDescription: workings,
|
|
124
128
|
tags: ['Asset Disposal'],
|
|
125
129
|
customFields: { 'Asset Description': null },
|
|
@@ -132,12 +136,12 @@ export function calculateAssetDisposal(inputs) {
|
|
|
132
136
|
type: 'asset-disposal',
|
|
133
137
|
currency: currency ?? null,
|
|
134
138
|
inputs: {
|
|
135
|
-
cost,
|
|
136
|
-
salvageValue,
|
|
139
|
+
cost: cost2,
|
|
140
|
+
salvageValue: salvage2,
|
|
137
141
|
usefulLifeYears,
|
|
138
142
|
acquisitionDate,
|
|
139
143
|
disposalDate,
|
|
140
|
-
proceeds,
|
|
144
|
+
proceeds: proceeds2,
|
|
141
145
|
method,
|
|
142
146
|
},
|
|
143
147
|
monthsHeld,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { fv } from 'financial';
|
|
12
12
|
import { round2, addMonths } from './types.js';
|
|
13
|
-
import { validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
13
|
+
import { CalcValidationError, validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
14
14
|
import { journalStep, cashOutStep, cashInStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
15
15
|
export function calculateFixedDeposit(inputs) {
|
|
16
16
|
const { principal, annualRate, termMonths, compounding = 'none', startDate, currency, } = inputs;
|
|
@@ -18,26 +18,28 @@ export function calculateFixedDeposit(inputs) {
|
|
|
18
18
|
validateRate(annualRate, 'Annual rate');
|
|
19
19
|
validatePositiveInteger(termMonths, 'Term (months)');
|
|
20
20
|
validateDateFormat(startDate);
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// Compound interval validation — term must align with compounding frequency
|
|
22
|
+
const compIntervalMonths = compounding === 'monthly' ? 1 : compounding === 'quarterly' ? 3 : compounding === 'annually' ? 12 : 0;
|
|
23
|
+
if (compounding !== 'none' && termMonths % compIntervalMonths !== 0) {
|
|
24
|
+
throw new CalcValidationError(`Term (${termMonths} months) must be a multiple of ${compIntervalMonths} for ${compounding} compounding.`);
|
|
25
|
+
}
|
|
26
|
+
// Compute maturity value
|
|
23
27
|
let maturityValue;
|
|
24
28
|
if (compounding === 'none') {
|
|
25
29
|
// Simple interest: Principal × Rate × Time
|
|
26
30
|
maturityValue = round2(principal + principal * (annualRate / 100) * (termMonths / 12));
|
|
27
31
|
}
|
|
28
32
|
else {
|
|
29
|
-
// Compound interest using fv()
|
|
30
|
-
|
|
31
|
-
const periodsPerYear = compounding === 'monthly' ? 12 : compounding === 'quarterly' ? 4 : 1;
|
|
33
|
+
// Compound interest using fv() — one consistent periodic rate
|
|
34
|
+
const periodsPerYear = 12 / compIntervalMonths;
|
|
32
35
|
const compoundRate = annualRate / 100 / periodsPerYear;
|
|
33
|
-
const totalCompoundPeriods =
|
|
34
|
-
// fv() with negative pv (outflow) returns positive (inflow at maturity)
|
|
36
|
+
const totalCompoundPeriods = termMonths / compIntervalMonths;
|
|
35
37
|
maturityValue = round2(fv(compoundRate, totalCompoundPeriods, 0, -principal));
|
|
36
38
|
}
|
|
37
39
|
const totalInterest = round2(maturityValue - principal);
|
|
38
40
|
// Effective annual rate (for display)
|
|
39
41
|
const effectiveRate = round2((Math.pow(maturityValue / principal, 12 / termMonths) - 1) * 100 * 100) / 100;
|
|
40
|
-
// Build
|
|
42
|
+
// Build accrual schedule — accrue at compound intervals using the same periodic rate
|
|
41
43
|
const schedule = [];
|
|
42
44
|
let accruedTotal = 0;
|
|
43
45
|
if (compounding === 'none') {
|
|
@@ -67,31 +69,38 @@ export function calculateFixedDeposit(inputs) {
|
|
|
67
69
|
}
|
|
68
70
|
}
|
|
69
71
|
else {
|
|
70
|
-
// Compound interest:
|
|
72
|
+
// Compound interest: accrue at compound intervals using the periodic rate
|
|
73
|
+
// e.g. quarterly = every 3 months, interest accrues on the growing carrying amount
|
|
74
|
+
const periodicRate = annualRate / 100 / (12 / compIntervalMonths);
|
|
71
75
|
let balance = principal;
|
|
72
|
-
|
|
76
|
+
const totalPeriods = termMonths / compIntervalMonths;
|
|
77
|
+
for (let p = 1; p <= totalPeriods; p++) {
|
|
73
78
|
const openingBalance = round2(balance);
|
|
74
|
-
const isFinal =
|
|
79
|
+
const isFinal = p === totalPeriods;
|
|
75
80
|
let interest;
|
|
76
81
|
if (isFinal) {
|
|
77
82
|
// Final period: close to exact maturity value
|
|
78
83
|
interest = round2(maturityValue - openingBalance);
|
|
79
84
|
}
|
|
80
85
|
else {
|
|
81
|
-
interest = round2(openingBalance *
|
|
86
|
+
interest = round2(openingBalance * periodicRate);
|
|
82
87
|
}
|
|
83
88
|
balance = round2(openingBalance + interest);
|
|
84
89
|
accruedTotal = round2(accruedTotal + interest);
|
|
85
|
-
const
|
|
90
|
+
const monthOffset = p * compIntervalMonths;
|
|
91
|
+
const date = startDate ? addMonths(startDate, monthOffset) : null;
|
|
92
|
+
const periodLabel = compounding === 'monthly'
|
|
93
|
+
? `Month ${p}` : compounding === 'quarterly'
|
|
94
|
+
? `Quarter ${p}` : `Year ${p}`;
|
|
86
95
|
const journal = {
|
|
87
|
-
description: `Interest accrual —
|
|
96
|
+
description: `Interest accrual — ${periodLabel} of ${totalPeriods}`,
|
|
88
97
|
lines: [
|
|
89
98
|
{ account: 'Accrued Interest Receivable', debit: interest, credit: 0 },
|
|
90
99
|
{ account: 'Interest Income', debit: 0, credit: interest },
|
|
91
100
|
],
|
|
92
101
|
};
|
|
93
102
|
schedule.push({
|
|
94
|
-
period:
|
|
103
|
+
period: p,
|
|
95
104
|
date,
|
|
96
105
|
openingBalance,
|
|
97
106
|
interest,
|
|
@@ -121,7 +130,7 @@ export function calculateFixedDeposit(inputs) {
|
|
|
121
130
|
const steps = [
|
|
122
131
|
cashOutStep(1, 'Transfer funds from operating account to fixed deposit', startDate, placementJournal.lines),
|
|
123
132
|
...schedule.map((row, idx) => journalStep(idx + 2, row.journal.description, row.date, row.journal.lines)),
|
|
124
|
-
cashInStep(
|
|
133
|
+
cashInStep(schedule.length + 2, 'Fixed deposit maturity — principal + accrued interest returned to Cash', addMonths(startDate, termMonths), maturityJournal.lines),
|
|
125
134
|
];
|
|
126
135
|
const c = currency ?? undefined;
|
|
127
136
|
const compoundLabel = compounding === 'none' ? 'Simple interest' : `Compound ${compounding}`;
|
package/dist/calc/lease.js
CHANGED
|
@@ -11,17 +11,21 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { pv } from 'financial';
|
|
13
13
|
import { round2, addMonths } from './types.js';
|
|
14
|
-
import { validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
14
|
+
import { CalcValidationError, validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
15
15
|
import { journalStep, fixedAssetStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
16
16
|
export function calculateLease(inputs) {
|
|
17
17
|
const { monthlyPayment, termMonths, annualRate, usefulLifeMonths, startDate, currency } = inputs;
|
|
18
18
|
validatePositive(monthlyPayment, 'Monthly payment');
|
|
19
19
|
validateRate(annualRate, 'Annual rate (IBR)');
|
|
20
20
|
validatePositiveInteger(termMonths, 'Term (months)');
|
|
21
|
-
if (usefulLifeMonths !== undefined)
|
|
21
|
+
if (usefulLifeMonths !== undefined) {
|
|
22
22
|
validatePositiveInteger(usefulLifeMonths, 'Useful life (months)');
|
|
23
|
+
if (usefulLifeMonths < termMonths) {
|
|
24
|
+
throw new CalcValidationError(`Useful life (${usefulLifeMonths} months) must be >= lease term (${termMonths} months) for hire purchase.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
23
27
|
validateDateFormat(startDate);
|
|
24
|
-
const isHirePurchase = usefulLifeMonths !== undefined
|
|
28
|
+
const isHirePurchase = usefulLifeMonths !== undefined;
|
|
25
29
|
const monthlyRate = annualRate / 100 / 12;
|
|
26
30
|
// PV of an ordinary annuity (payments at end of period)
|
|
27
31
|
// pv() returns negative, negate for positive value
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { generateMonthEndBlueprint } from '../jobs/month-end.js';
|
|
3
|
+
import { generateQuarterEndBlueprint } from '../jobs/quarter-end.js';
|
|
4
|
+
import { generateYearEndBlueprint } from '../jobs/year-end.js';
|
|
5
|
+
import { generateBankReconBlueprint } from '../jobs/bank-recon.js';
|
|
6
|
+
import { generateGstVatBlueprint } from '../jobs/gst-vat.js';
|
|
7
|
+
import { generatePaymentRunBlueprint } from '../jobs/payment-run.js';
|
|
8
|
+
import { generateCreditControlBlueprint } from '../jobs/credit-control.js';
|
|
9
|
+
import { generateSupplierReconBlueprint } from '../jobs/supplier-recon.js';
|
|
10
|
+
import { generateAuditPrepBlueprint } from '../jobs/audit-prep.js';
|
|
11
|
+
import { generateFaReviewBlueprint } from '../jobs/fa-review.js';
|
|
12
|
+
import { printBlueprint, printBlueprintJson } from '../jobs/format.js';
|
|
13
|
+
import { JobValidationError } from '../jobs/validate.js';
|
|
14
|
+
/** Wrap job action with validation error handling. */
|
|
15
|
+
function jobAction(fn) {
|
|
16
|
+
return (opts) => {
|
|
17
|
+
try {
|
|
18
|
+
fn(opts);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
if (err instanceof JobValidationError) {
|
|
22
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
throw err;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function registerJobsCommand(program) {
|
|
30
|
+
const jobs = program
|
|
31
|
+
.command('jobs')
|
|
32
|
+
.description('Accounting job blueprints — month-end, quarter-end, year-end, bank-recon, gst-vat, payment-run, credit-control, supplier-recon, audit-prep, fa-review');
|
|
33
|
+
// ── jaz jobs month-end ──────────────────────────────────────────
|
|
34
|
+
jobs
|
|
35
|
+
.command('month-end')
|
|
36
|
+
.description('Month-end close blueprint (5 phases, 18 steps)')
|
|
37
|
+
.requiredOption('--period <YYYY-MM>', 'Month period (e.g., 2025-01)')
|
|
38
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
39
|
+
.option('--json', 'Output as JSON')
|
|
40
|
+
.action(jobAction((opts) => {
|
|
41
|
+
const bp = generateMonthEndBlueprint({
|
|
42
|
+
period: opts.period,
|
|
43
|
+
currency: opts.currency,
|
|
44
|
+
});
|
|
45
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
46
|
+
}));
|
|
47
|
+
// ── jaz jobs quarter-end ────────────────────────────────────────
|
|
48
|
+
jobs
|
|
49
|
+
.command('quarter-end')
|
|
50
|
+
.description('Quarter-end close blueprint (monthly × 3 + quarterly extras)')
|
|
51
|
+
.requiredOption('--period <YYYY-QN>', 'Quarter period (e.g., 2025-Q1)')
|
|
52
|
+
.option('--incremental', 'Generate only quarterly extras (assumes months already closed)')
|
|
53
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
54
|
+
.option('--json', 'Output as JSON')
|
|
55
|
+
.action(jobAction((opts) => {
|
|
56
|
+
const bp = generateQuarterEndBlueprint({
|
|
57
|
+
period: opts.period,
|
|
58
|
+
currency: opts.currency,
|
|
59
|
+
incremental: opts.incremental,
|
|
60
|
+
});
|
|
61
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
62
|
+
}));
|
|
63
|
+
// ── jaz jobs year-end ───────────────────────────────────────────
|
|
64
|
+
jobs
|
|
65
|
+
.command('year-end')
|
|
66
|
+
.description('Year-end close blueprint (quarterly × 4 + annual extras)')
|
|
67
|
+
.requiredOption('--period <YYYY>', 'Fiscal year (e.g., 2025)')
|
|
68
|
+
.option('--incremental', 'Generate only annual extras (assumes quarters already closed)')
|
|
69
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
70
|
+
.option('--json', 'Output as JSON')
|
|
71
|
+
.action(jobAction((opts) => {
|
|
72
|
+
const bp = generateYearEndBlueprint({
|
|
73
|
+
period: opts.period,
|
|
74
|
+
currency: opts.currency,
|
|
75
|
+
incremental: opts.incremental,
|
|
76
|
+
});
|
|
77
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
78
|
+
}));
|
|
79
|
+
// ── jaz jobs bank-recon ─────────────────────────────────────────
|
|
80
|
+
jobs
|
|
81
|
+
.command('bank-recon')
|
|
82
|
+
.description('Bank reconciliation catch-up blueprint')
|
|
83
|
+
.option('--account <name>', 'Specific bank account name')
|
|
84
|
+
.option('--period <YYYY-MM>', 'Month period to reconcile')
|
|
85
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
86
|
+
.option('--json', 'Output as JSON')
|
|
87
|
+
.action(jobAction((opts) => {
|
|
88
|
+
const bp = generateBankReconBlueprint({
|
|
89
|
+
account: opts.account,
|
|
90
|
+
period: opts.period,
|
|
91
|
+
currency: opts.currency,
|
|
92
|
+
});
|
|
93
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
94
|
+
}));
|
|
95
|
+
// ── jaz jobs gst-vat ────────────────────────────────────────────
|
|
96
|
+
jobs
|
|
97
|
+
.command('gst-vat')
|
|
98
|
+
.description('GST/VAT filing preparation blueprint')
|
|
99
|
+
.requiredOption('--period <YYYY-QN>', 'Quarter period (e.g., 2025-Q1)')
|
|
100
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
101
|
+
.option('--json', 'Output as JSON')
|
|
102
|
+
.action(jobAction((opts) => {
|
|
103
|
+
const bp = generateGstVatBlueprint({
|
|
104
|
+
period: opts.period,
|
|
105
|
+
currency: opts.currency,
|
|
106
|
+
});
|
|
107
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
108
|
+
}));
|
|
109
|
+
// ── jaz jobs payment-run ────────────────────────────────────────
|
|
110
|
+
jobs
|
|
111
|
+
.command('payment-run')
|
|
112
|
+
.description('Payment run blueprint (bulk bill payments)')
|
|
113
|
+
.option('--due-before <YYYY-MM-DD>', 'Pay bills due on or before this date')
|
|
114
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
115
|
+
.option('--json', 'Output as JSON')
|
|
116
|
+
.action(jobAction((opts) => {
|
|
117
|
+
const bp = generatePaymentRunBlueprint({
|
|
118
|
+
dueBefore: opts.dueBefore,
|
|
119
|
+
currency: opts.currency,
|
|
120
|
+
});
|
|
121
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
122
|
+
}));
|
|
123
|
+
// ── jaz jobs credit-control ─────────────────────────────────────
|
|
124
|
+
jobs
|
|
125
|
+
.command('credit-control')
|
|
126
|
+
.description('Credit control / AR chase blueprint')
|
|
127
|
+
.option('--overdue-days <days>', 'Minimum overdue days to include', (v) => {
|
|
128
|
+
const n = Number.parseInt(v, 10);
|
|
129
|
+
if (!Number.isFinite(n))
|
|
130
|
+
throw new JobValidationError(`overdue-days must be an integer (got "${v}")`);
|
|
131
|
+
return n;
|
|
132
|
+
})
|
|
133
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
134
|
+
.option('--json', 'Output as JSON')
|
|
135
|
+
.action(jobAction((opts) => {
|
|
136
|
+
const bp = generateCreditControlBlueprint({
|
|
137
|
+
overdueDays: opts.overdueDays,
|
|
138
|
+
currency: opts.currency,
|
|
139
|
+
});
|
|
140
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
141
|
+
}));
|
|
142
|
+
// ── jaz jobs supplier-recon ─────────────────────────────────────
|
|
143
|
+
jobs
|
|
144
|
+
.command('supplier-recon')
|
|
145
|
+
.description('Supplier statement reconciliation blueprint')
|
|
146
|
+
.option('--supplier <name>', 'Specific supplier name')
|
|
147
|
+
.option('--period <YYYY-MM>', 'Month period')
|
|
148
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
149
|
+
.option('--json', 'Output as JSON')
|
|
150
|
+
.action(jobAction((opts) => {
|
|
151
|
+
const bp = generateSupplierReconBlueprint({
|
|
152
|
+
supplier: opts.supplier,
|
|
153
|
+
period: opts.period,
|
|
154
|
+
currency: opts.currency,
|
|
155
|
+
});
|
|
156
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
157
|
+
}));
|
|
158
|
+
// ── jaz jobs audit-prep ─────────────────────────────────────────
|
|
159
|
+
jobs
|
|
160
|
+
.command('audit-prep')
|
|
161
|
+
.description('Audit preparation pack blueprint')
|
|
162
|
+
.requiredOption('--period <YYYY>', 'Fiscal year (e.g., 2025)')
|
|
163
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
164
|
+
.option('--json', 'Output as JSON')
|
|
165
|
+
.action(jobAction((opts) => {
|
|
166
|
+
const bp = generateAuditPrepBlueprint({
|
|
167
|
+
period: opts.period,
|
|
168
|
+
currency: opts.currency,
|
|
169
|
+
});
|
|
170
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
171
|
+
}));
|
|
172
|
+
// ── jaz jobs fa-review ──────────────────────────────────────────
|
|
173
|
+
jobs
|
|
174
|
+
.command('fa-review')
|
|
175
|
+
.description('Fixed asset register review blueprint')
|
|
176
|
+
.option('--currency <code>', 'Currency code (e.g. SGD, USD)')
|
|
177
|
+
.option('--json', 'Output as JSON')
|
|
178
|
+
.action(jobAction((opts) => {
|
|
179
|
+
const bp = generateFaReviewBlueprint({
|
|
180
|
+
currency: opts.currency,
|
|
181
|
+
});
|
|
182
|
+
opts.json ? printBlueprintJson(bp) : printBlueprint(bp);
|
|
183
|
+
}));
|
|
184
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { initCommand } from './commands/init.js';
|
|
|
7
7
|
import { versionsCommand } from './commands/versions.js';
|
|
8
8
|
import { updateCommand } from './commands/update.js';
|
|
9
9
|
import { registerCalcCommand } from './commands/calc.js';
|
|
10
|
+
import { registerJobsCommand } from './commands/jobs.js';
|
|
10
11
|
import { SKILL_TYPES } from './types/index.js';
|
|
11
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
13
|
const __dirname = dirname(__filename);
|
|
@@ -51,4 +52,5 @@ program
|
|
|
51
52
|
});
|
|
52
53
|
});
|
|
53
54
|
registerCalcCommand(program);
|
|
55
|
+
registerJobsCommand(program);
|
|
54
56
|
program.parse();
|