jaz-cli 2.5.0 → 2.7.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 +5 -5
- 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/transaction-recipes/SKILL.md +53 -19
- package/assets/skills/transaction-recipes/references/asset-disposal.md +174 -0
- package/assets/skills/transaction-recipes/references/fixed-deposit.md +164 -0
- package/assets/skills/transaction-recipes/references/hire-purchase.md +190 -0
- 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__/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/amortization.js +21 -3
- package/dist/calc/asset-disposal.js +155 -0
- package/dist/calc/blueprint.js +26 -1
- package/dist/calc/depreciation.js +24 -1
- package/dist/calc/ecl.js +13 -1
- package/dist/calc/fixed-deposit.js +178 -0
- package/dist/calc/format.js +107 -2
- package/dist/calc/fx-reval.js +11 -1
- package/dist/calc/lease.js +42 -9
- package/dist/calc/loan.js +12 -2
- package/dist/calc/provision.js +17 -1
- package/dist/commands/calc.js +54 -2
- package/package.json +5 -2
|
@@ -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
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { round2, addMonths } from './types.js';
|
|
6
6
|
import { validatePositive, validatePositiveInteger, validateDateFormat } from './validate.js';
|
|
7
|
-
import { journalStep, fmtCapsuleAmount } from './blueprint.js';
|
|
7
|
+
import { journalStep, billStep, invoiceStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
8
8
|
export function calculatePrepaidExpense(inputs) {
|
|
9
9
|
const { amount, periods, frequency = 'monthly', startDate, currency } = inputs;
|
|
10
10
|
validatePositive(amount, 'Amount');
|
|
@@ -14,15 +14,24 @@ export function calculatePrepaidExpense(inputs) {
|
|
|
14
14
|
let blueprint = null;
|
|
15
15
|
if (startDate) {
|
|
16
16
|
const steps = [
|
|
17
|
-
|
|
17
|
+
billStep(1, 'Create bill from supplier coded to Prepaid Asset, then pay from Cash / Bank Account', startDate, [
|
|
18
18
|
{ account: 'Prepaid Asset', debit: amount, credit: 0 },
|
|
19
19
|
{ account: 'Cash / Bank Account', debit: 0, credit: amount },
|
|
20
20
|
]),
|
|
21
21
|
...schedule.map((row, idx) => journalStep(idx + 2, row.journal.description, row.date, row.journal.lines)),
|
|
22
22
|
];
|
|
23
|
+
const c = currency ?? undefined;
|
|
24
|
+
const workings = [
|
|
25
|
+
`Prepaid Expense Recognition Workings`,
|
|
26
|
+
`Total prepaid: ${fmtAmt(amount, c)} | Periods: ${periods} (${frequency})`,
|
|
27
|
+
`Per period: ${fmtAmt(round2(amount / periods), c)}`,
|
|
28
|
+
`Method: Straight-line recognition over ${periods} ${frequency} periods`,
|
|
29
|
+
`Rounding: 2dp per period, final period absorbs remainder`,
|
|
30
|
+
].join('\n');
|
|
23
31
|
blueprint = {
|
|
24
32
|
capsuleType: 'Prepaid Expenses',
|
|
25
33
|
capsuleName: `Prepaid Expense — ${fmtCapsuleAmount(amount, currency)} — ${periods} periods`,
|
|
34
|
+
capsuleDescription: workings,
|
|
26
35
|
tags: ['Prepaid Expense'],
|
|
27
36
|
customFields: { 'Policy / Contract #': null },
|
|
28
37
|
steps,
|
|
@@ -46,15 +55,24 @@ export function calculateDeferredRevenue(inputs) {
|
|
|
46
55
|
let blueprint = null;
|
|
47
56
|
if (startDate) {
|
|
48
57
|
const steps = [
|
|
49
|
-
|
|
58
|
+
invoiceStep(1, 'Create invoice to customer coded to Deferred Revenue, record payment to Cash / Bank Account', startDate, [
|
|
50
59
|
{ account: 'Cash / Bank Account', debit: amount, credit: 0 },
|
|
51
60
|
{ account: 'Deferred Revenue', debit: 0, credit: amount },
|
|
52
61
|
]),
|
|
53
62
|
...schedule.map((row, idx) => journalStep(idx + 2, row.journal.description, row.date, row.journal.lines)),
|
|
54
63
|
];
|
|
64
|
+
const c2 = currency ?? undefined;
|
|
65
|
+
const workings2 = [
|
|
66
|
+
`Deferred Revenue Recognition Workings`,
|
|
67
|
+
`Total deferred: ${fmtAmt(amount, c2)} | Periods: ${periods} (${frequency})`,
|
|
68
|
+
`Per period: ${fmtAmt(round2(amount / periods), c2)}`,
|
|
69
|
+
`Method: Straight-line recognition over ${periods} ${frequency} periods`,
|
|
70
|
+
`Rounding: 2dp per period, final period absorbs remainder`,
|
|
71
|
+
].join('\n');
|
|
55
72
|
blueprint = {
|
|
56
73
|
capsuleType: 'Deferred Revenue',
|
|
57
74
|
capsuleName: `Deferred Revenue — ${fmtCapsuleAmount(amount, currency)} — ${periods} periods`,
|
|
75
|
+
capsuleDescription: workings2,
|
|
58
76
|
tags: ['Deferred Revenue'],
|
|
59
77
|
customFields: { 'Contract #': null },
|
|
60
78
|
steps,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asset disposal gain/loss calculator.
|
|
3
|
+
*
|
|
4
|
+
* Compliance references:
|
|
5
|
+
* - IAS 16.67-72: Derecognition on disposal or when no future economic benefits expected
|
|
6
|
+
* - IAS 16.68: Gain/loss = Net disposal proceeds - Carrying amount
|
|
7
|
+
* - IAS 16.71: Gain ≠ revenue (classified separately in P&L)
|
|
8
|
+
*
|
|
9
|
+
* Computes depreciation to the disposal date (pro-rated partial period),
|
|
10
|
+
* then calculates NBV and gain/loss. Supports SL, DDB, and 150DB methods.
|
|
11
|
+
*/
|
|
12
|
+
import { round2 } from './types.js';
|
|
13
|
+
import { CalcValidationError, validatePositive, validateNonNegative, validatePositiveInteger, validateSalvageLessThanCost, validateDateFormat, } from './validate.js';
|
|
14
|
+
import { journalStep, noteStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
15
|
+
/**
|
|
16
|
+
* Count full months between two YYYY-MM-DD dates.
|
|
17
|
+
* Partial months are rounded up (any day in a month counts as a full month).
|
|
18
|
+
*/
|
|
19
|
+
function monthsBetween(from, to) {
|
|
20
|
+
const d1 = new Date(from + 'T00:00:00');
|
|
21
|
+
const d2 = new Date(to + 'T00:00:00');
|
|
22
|
+
const months = (d2.getFullYear() - d1.getFullYear()) * 12 + (d2.getMonth() - d1.getMonth());
|
|
23
|
+
// If disposal day > acquisition day, it's a partial month — round up
|
|
24
|
+
return d2.getDate() >= d1.getDate() ? months + 1 : Math.max(months, 0);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Compute accumulated depreciation from acquisition to disposal date.
|
|
28
|
+
*/
|
|
29
|
+
function computeAccumDepreciation(cost, salvageValue, usefulLifeYears, method, monthsHeld) {
|
|
30
|
+
const totalMonths = usefulLifeYears * 12;
|
|
31
|
+
const depreciableBase = round2(cost - salvageValue);
|
|
32
|
+
// Cap at useful life — asset is fully depreciated
|
|
33
|
+
const effectiveMonths = Math.min(monthsHeld, totalMonths);
|
|
34
|
+
if (method === 'sl') {
|
|
35
|
+
// Straight-line: pro-rate by months
|
|
36
|
+
const monthlyDep = depreciableBase / totalMonths;
|
|
37
|
+
return round2(Math.min(monthlyDep * effectiveMonths, depreciableBase));
|
|
38
|
+
}
|
|
39
|
+
// DDB / 150DB: compute year-by-year then pro-rate final partial year
|
|
40
|
+
const multiplier = method === 'ddb' ? 2 : 1.5;
|
|
41
|
+
const annualRate = multiplier / usefulLifeYears;
|
|
42
|
+
let bookValue = cost;
|
|
43
|
+
let totalDep = 0;
|
|
44
|
+
let monthsRemaining = effectiveMonths;
|
|
45
|
+
for (let year = 1; year <= usefulLifeYears && monthsRemaining > 0; year++) {
|
|
46
|
+
const remainingYears = usefulLifeYears - year + 1;
|
|
47
|
+
const ddbAmount = round2(bookValue * annualRate);
|
|
48
|
+
const slAmount = round2((bookValue - salvageValue) / remainingYears);
|
|
49
|
+
const useSL = slAmount >= ddbAmount || round2(bookValue - ddbAmount) < salvageValue;
|
|
50
|
+
let annualDep = useSL ? slAmount : ddbAmount;
|
|
51
|
+
// Don't breach salvage floor
|
|
52
|
+
if (round2(bookValue - annualDep) < salvageValue) {
|
|
53
|
+
annualDep = round2(bookValue - salvageValue);
|
|
54
|
+
}
|
|
55
|
+
if (monthsRemaining >= 12) {
|
|
56
|
+
// Full year
|
|
57
|
+
totalDep = round2(totalDep + annualDep);
|
|
58
|
+
bookValue = round2(bookValue - annualDep);
|
|
59
|
+
monthsRemaining -= 12;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
// Partial year: pro-rate
|
|
63
|
+
const partialDep = round2(annualDep * monthsRemaining / 12);
|
|
64
|
+
totalDep = round2(totalDep + partialDep);
|
|
65
|
+
monthsRemaining = 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Math.min(totalDep, depreciableBase);
|
|
69
|
+
}
|
|
70
|
+
export function calculateAssetDisposal(inputs) {
|
|
71
|
+
const { cost, salvageValue, usefulLifeYears, acquisitionDate, disposalDate, proceeds, method = 'sl', currency, } = inputs;
|
|
72
|
+
validatePositive(cost, 'Asset cost');
|
|
73
|
+
validateNonNegative(salvageValue, 'Salvage value');
|
|
74
|
+
validatePositiveInteger(usefulLifeYears, 'Useful life (years)');
|
|
75
|
+
if (salvageValue > 0)
|
|
76
|
+
validateSalvageLessThanCost(salvageValue, cost);
|
|
77
|
+
validateNonNegative(proceeds, 'Disposal proceeds');
|
|
78
|
+
validateDateFormat(acquisitionDate);
|
|
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);
|
|
84
|
+
// Validate disposal is after acquisition
|
|
85
|
+
if (disposalDate <= acquisitionDate) {
|
|
86
|
+
throw new CalcValidationError('Disposal date must be after acquisition date.');
|
|
87
|
+
}
|
|
88
|
+
const monthsHeld = monthsBetween(acquisitionDate, disposalDate);
|
|
89
|
+
const accumulatedDepreciation = computeAccumDepreciation(cost2, salvage2, usefulLifeYears, method, monthsHeld);
|
|
90
|
+
const netBookValue = round2(cost2 - accumulatedDepreciation);
|
|
91
|
+
const gainOrLoss = round2(proceeds2 - netBookValue);
|
|
92
|
+
const isGain = gainOrLoss >= 0;
|
|
93
|
+
// Build disposal journal entry
|
|
94
|
+
const lines = [];
|
|
95
|
+
if (proceeds2 > 0) {
|
|
96
|
+
lines.push({ account: 'Cash / Bank Account', debit: proceeds2, credit: 0 });
|
|
97
|
+
}
|
|
98
|
+
if (accumulatedDepreciation > 0) {
|
|
99
|
+
lines.push({ account: 'Accumulated Depreciation', debit: accumulatedDepreciation, credit: 0 });
|
|
100
|
+
}
|
|
101
|
+
if (!isGain) {
|
|
102
|
+
lines.push({ account: 'Loss on Disposal', debit: Math.abs(gainOrLoss), credit: 0 });
|
|
103
|
+
}
|
|
104
|
+
lines.push({ account: 'Fixed Asset (at cost)', debit: 0, credit: cost2 });
|
|
105
|
+
if (isGain && gainOrLoss > 0) {
|
|
106
|
+
lines.push({ account: 'Gain on Disposal', debit: 0, credit: gainOrLoss });
|
|
107
|
+
}
|
|
108
|
+
const disposalJournal = {
|
|
109
|
+
description: `Asset disposal — ${isGain ? (gainOrLoss > 0 ? 'gain' : 'at book value') : 'loss'}`,
|
|
110
|
+
lines,
|
|
111
|
+
};
|
|
112
|
+
// Build blueprint
|
|
113
|
+
const c = currency ?? undefined;
|
|
114
|
+
const methodLabel = method === 'sl' ? 'Straight-line' : method === 'ddb' ? 'Double declining' : '150% declining';
|
|
115
|
+
const workings = [
|
|
116
|
+
`Asset Disposal Workings (IAS 16)`,
|
|
117
|
+
`Cost: ${fmtAmt(cost2, c)} | Salvage: ${fmtAmt(salvage2, c)} | Life: ${usefulLifeYears} years (${methodLabel})`,
|
|
118
|
+
`Acquired: ${acquisitionDate} | Disposed: ${disposalDate} | Held: ${monthsHeld} months`,
|
|
119
|
+
`Accumulated depreciation: ${fmtAmt(accumulatedDepreciation, c)} | NBV: ${fmtAmt(netBookValue, c)}`,
|
|
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)}`,
|
|
122
|
+
].join('\n');
|
|
123
|
+
let blueprint = null;
|
|
124
|
+
blueprint = {
|
|
125
|
+
capsuleType: 'Asset Disposal',
|
|
126
|
+
capsuleName: `Asset Disposal — ${fmtCapsuleAmount(cost2, currency)} asset — ${disposalDate}`,
|
|
127
|
+
capsuleDescription: workings,
|
|
128
|
+
tags: ['Asset Disposal'],
|
|
129
|
+
customFields: { 'Asset Description': null },
|
|
130
|
+
steps: [
|
|
131
|
+
journalStep(1, disposalJournal.description, disposalDate, disposalJournal.lines),
|
|
132
|
+
noteStep(2, `Update Jaz FA register: use POST /mark-as-sold/fixed-assets (if sold) or POST /discard-fixed-assets/:id (if scrapped).`, disposalDate),
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
type: 'asset-disposal',
|
|
137
|
+
currency: currency ?? null,
|
|
138
|
+
inputs: {
|
|
139
|
+
cost: cost2,
|
|
140
|
+
salvageValue: salvage2,
|
|
141
|
+
usefulLifeYears,
|
|
142
|
+
acquisitionDate,
|
|
143
|
+
disposalDate,
|
|
144
|
+
proceeds: proceeds2,
|
|
145
|
+
method,
|
|
146
|
+
},
|
|
147
|
+
monthsHeld,
|
|
148
|
+
accumulatedDepreciation,
|
|
149
|
+
netBookValue,
|
|
150
|
+
gainOrLoss,
|
|
151
|
+
isGain,
|
|
152
|
+
disposalJournal,
|
|
153
|
+
blueprint,
|
|
154
|
+
};
|
|
155
|
+
}
|
package/dist/calc/blueprint.js
CHANGED
|
@@ -10,7 +10,27 @@
|
|
|
10
10
|
export function journalStep(stepNum, description, date, lines) {
|
|
11
11
|
return { step: stepNum, action: 'journal', description, date, lines };
|
|
12
12
|
}
|
|
13
|
-
/** Build a
|
|
13
|
+
/** Build a bill step (create supplier bill, e.g. prepaid expense). Lines show net accounting effect. */
|
|
14
|
+
export function billStep(stepNum, description, date, lines) {
|
|
15
|
+
return { step: stepNum, action: 'bill', description, date, lines };
|
|
16
|
+
}
|
|
17
|
+
/** Build an invoice step (create customer invoice, e.g. deferred revenue). Lines show net accounting effect. */
|
|
18
|
+
export function invoiceStep(stepNum, description, date, lines) {
|
|
19
|
+
return { step: stepNum, action: 'invoice', description, date, lines };
|
|
20
|
+
}
|
|
21
|
+
/** Build a cash-out step (cash disbursement, e.g. deposit placement). */
|
|
22
|
+
export function cashOutStep(stepNum, description, date, lines) {
|
|
23
|
+
return { step: stepNum, action: 'cash-out', description, date, lines };
|
|
24
|
+
}
|
|
25
|
+
/** Build a cash-in step (cash receipt, e.g. deposit maturity). */
|
|
26
|
+
export function cashInStep(stepNum, description, date, lines) {
|
|
27
|
+
return { step: stepNum, action: 'cash-in', description, date, lines };
|
|
28
|
+
}
|
|
29
|
+
/** Build a fixed-asset registration step (e.g. register ROU asset in FA module). */
|
|
30
|
+
export function fixedAssetStep(stepNum, description, date = null) {
|
|
31
|
+
return { step: stepNum, action: 'fixed-asset', description, date, lines: [] };
|
|
32
|
+
}
|
|
33
|
+
/** Build a note step (instruction, not a journal — e.g. "update FA register"). */
|
|
14
34
|
export function noteStep(stepNum, description, date = null) {
|
|
15
35
|
return { step: stepNum, action: 'note', description, date, lines: [] };
|
|
16
36
|
}
|
|
@@ -19,3 +39,8 @@ export function fmtCapsuleAmount(amount, currency) {
|
|
|
19
39
|
const formatted = amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
20
40
|
return currency ? `${currency} ${formatted}` : formatted;
|
|
21
41
|
}
|
|
42
|
+
/** Format a currency amount for workings text (e.g. "SGD 100,000.00"). */
|
|
43
|
+
export function fmtAmt(amount, currency) {
|
|
44
|
+
const formatted = amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
45
|
+
return currency ? `${currency} ${formatted}` : formatted;
|
|
46
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { round2 } from './types.js';
|
|
13
13
|
import { validatePositive, validateNonNegative, validatePositiveInteger, validateSalvageLessThanCost } from './validate.js';
|
|
14
|
-
import { journalStep } from './blueprint.js';
|
|
14
|
+
import { journalStep, fmtAmt } from './blueprint.js';
|
|
15
15
|
export function calculateDepreciation(inputs) {
|
|
16
16
|
const { cost, salvageValue, usefulLifeYears, method = 'ddb', frequency = 'annual', currency, } = inputs;
|
|
17
17
|
validatePositive(cost, 'Asset cost');
|
|
@@ -105,6 +105,16 @@ function buildStraightLineResult(cost, salvageValue, usefulLifeYears, depreciabl
|
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
const blueprintSteps = schedule.map((row, idx) => journalStep(idx + 1, row.journal.description, row.date, row.journal.lines));
|
|
108
|
+
const c = currency ?? undefined;
|
|
109
|
+
const workings = [
|
|
110
|
+
`Straight-Line Depreciation Workings`,
|
|
111
|
+
`Cost: ${fmtAmt(cost, c)} | Salvage: ${fmtAmt(salvageValue, c)} | Depreciable base: ${fmtAmt(depreciableBase, c)}`,
|
|
112
|
+
`Useful life: ${usefulLifeYears} years | Frequency: ${frequency}`,
|
|
113
|
+
`Per ${frequency === 'monthly' ? 'month' : 'year'}: ${fmtAmt(perPeriod, c)}`,
|
|
114
|
+
`Total depreciation: ${fmtAmt(depreciableBase, c)}`,
|
|
115
|
+
`Method: (Cost − Salvage) ÷ Life = ${fmtAmt(depreciableBase, c)} ÷ ${totalPeriods} = ${fmtAmt(perPeriod, c)}`,
|
|
116
|
+
`Rounding: 2dp per period, final period closes to salvage value`,
|
|
117
|
+
].join('\n');
|
|
108
118
|
return {
|
|
109
119
|
type: 'depreciation',
|
|
110
120
|
currency: currency ?? null,
|
|
@@ -114,6 +124,7 @@ function buildStraightLineResult(cost, salvageValue, usefulLifeYears, depreciabl
|
|
|
114
124
|
blueprint: {
|
|
115
125
|
capsuleType: 'Depreciation',
|
|
116
126
|
capsuleName: `SL Depreciation — ${usefulLifeYears} years`,
|
|
127
|
+
capsuleDescription: workings,
|
|
117
128
|
tags: ['Depreciation'],
|
|
118
129
|
customFields: { 'Asset ID': null },
|
|
119
130
|
steps: blueprintSteps,
|
|
@@ -160,6 +171,17 @@ function buildDecliningSchedule(cost, salvageValue, usefulLifeYears, annualRate,
|
|
|
160
171
|
});
|
|
161
172
|
}
|
|
162
173
|
const blueprintSteps = schedule.map((row, idx) => journalStep(idx + 1, row.journal.description, row.date, row.journal.lines));
|
|
174
|
+
const c = currency ?? undefined;
|
|
175
|
+
const methodLabel = method === 'ddb' ? 'Double Declining Balance' : '150% Declining Balance';
|
|
176
|
+
const ratePercent = round2(annualRate * 100);
|
|
177
|
+
const workings = [
|
|
178
|
+
`${methodLabel} Depreciation Workings`,
|
|
179
|
+
`Cost: ${fmtAmt(cost, c)} | Salvage: ${fmtAmt(salvageValue, c)} | Depreciable base: ${fmtAmt(depreciableBase, c)}`,
|
|
180
|
+
`Useful life: ${usefulLifeYears} years | Rate: ${ratePercent}% (${method === 'ddb' ? '2' : '1.5'} ÷ ${usefulLifeYears})`,
|
|
181
|
+
`Total depreciation: ${fmtAmt(depreciableBase, c)}`,
|
|
182
|
+
`Method: ${method.toUpperCase()} with auto switch to SL when SL ≥ declining or floor hit`,
|
|
183
|
+
`Rounding: 2dp per period, book value never falls below salvage`,
|
|
184
|
+
].join('\n');
|
|
163
185
|
return {
|
|
164
186
|
type: 'depreciation',
|
|
165
187
|
currency: currency ?? null,
|
|
@@ -169,6 +191,7 @@ function buildDecliningSchedule(cost, salvageValue, usefulLifeYears, annualRate,
|
|
|
169
191
|
blueprint: {
|
|
170
192
|
capsuleType: 'Depreciation',
|
|
171
193
|
capsuleName: `${method.toUpperCase()} Depreciation — ${usefulLifeYears} years`,
|
|
194
|
+
capsuleDescription: workings,
|
|
172
195
|
tags: ['Depreciation'],
|
|
173
196
|
customFields: { 'Asset ID': null },
|
|
174
197
|
steps: blueprintSteps,
|
package/dist/calc/ecl.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { round2 } from './types.js';
|
|
17
17
|
import { validateNonNegative } from './validate.js';
|
|
18
|
-
import { journalStep, fmtCapsuleAmount } from './blueprint.js';
|
|
18
|
+
import { journalStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
19
19
|
export function calculateEcl(inputs) {
|
|
20
20
|
const { buckets, existingProvision = 0, currency } = inputs;
|
|
21
21
|
// Validate each bucket
|
|
@@ -61,9 +61,21 @@ export function calculateEcl(inputs) {
|
|
|
61
61
|
};
|
|
62
62
|
}
|
|
63
63
|
// Blueprint
|
|
64
|
+
const c = currency ?? undefined;
|
|
65
|
+
const bucketWorkings = bucketDetails.map(b => ` ${b.bucket}: ${fmtAmt(b.balance, c)} × ${b.lossRate}% = ${fmtAmt(b.ecl, c)}`).join('\n');
|
|
66
|
+
const workings = [
|
|
67
|
+
`ECL Provision Matrix Workings (IFRS 9)`,
|
|
68
|
+
`Total receivables: ${fmtAmt(totalReceivables, c)} | Weighted avg loss rate: ${weightedRate}%`,
|
|
69
|
+
`Provision matrix:`,
|
|
70
|
+
bucketWorkings,
|
|
71
|
+
`Total ECL required: ${fmtAmt(totalEcl, c)} | Existing provision: ${fmtAmt(existingProvision, c)}`,
|
|
72
|
+
`Adjustment: ${fmtAmt(Math.abs(adjustmentRequired), c)} (${isIncrease ? 'increase' : 'release'})`,
|
|
73
|
+
`Method: IFRS 9.5.5.15 simplified approach — lifetime ECL, provision matrix`,
|
|
74
|
+
].join('\n');
|
|
64
75
|
const blueprint = {
|
|
65
76
|
capsuleType: 'ECL Provision',
|
|
66
77
|
capsuleName: `ECL Provision — ${fmtCapsuleAmount(totalEcl, currency)} — ${buckets.length} buckets`,
|
|
78
|
+
capsuleDescription: workings,
|
|
67
79
|
tags: ['ECL', 'Bad Debt'],
|
|
68
80
|
customFields: { 'Reporting Period': null, 'Aged Receivables Report Date': null },
|
|
69
81
|
steps: [
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed deposit interest accrual calculator.
|
|
3
|
+
*
|
|
4
|
+
* Compliance references:
|
|
5
|
+
* - IFRS 9.4.1.2: Classification at amortized cost (hold-to-collect, passes SPPI)
|
|
6
|
+
* - IFRS 9.5.4.1: Interest revenue via effective interest rate method
|
|
7
|
+
*
|
|
8
|
+
* Supports simple interest (default) and compound interest (monthly/quarterly/annually).
|
|
9
|
+
* Final period absorbs rounding — maturity value is exact.
|
|
10
|
+
*/
|
|
11
|
+
import { fv } from 'financial';
|
|
12
|
+
import { round2, addMonths } from './types.js';
|
|
13
|
+
import { CalcValidationError, validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
|
|
14
|
+
import { journalStep, cashOutStep, cashInStep, fmtCapsuleAmount, fmtAmt } from './blueprint.js';
|
|
15
|
+
export function calculateFixedDeposit(inputs) {
|
|
16
|
+
const { principal, annualRate, termMonths, compounding = 'none', startDate, currency, } = inputs;
|
|
17
|
+
validatePositive(principal, 'Principal');
|
|
18
|
+
validateRate(annualRate, 'Annual rate');
|
|
19
|
+
validatePositiveInteger(termMonths, 'Term (months)');
|
|
20
|
+
validateDateFormat(startDate);
|
|
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
|
|
27
|
+
let maturityValue;
|
|
28
|
+
if (compounding === 'none') {
|
|
29
|
+
// Simple interest: Principal × Rate × Time
|
|
30
|
+
maturityValue = round2(principal + principal * (annualRate / 100) * (termMonths / 12));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// Compound interest using fv() — one consistent periodic rate
|
|
34
|
+
const periodsPerYear = 12 / compIntervalMonths;
|
|
35
|
+
const compoundRate = annualRate / 100 / periodsPerYear;
|
|
36
|
+
const totalCompoundPeriods = termMonths / compIntervalMonths;
|
|
37
|
+
maturityValue = round2(fv(compoundRate, totalCompoundPeriods, 0, -principal));
|
|
38
|
+
}
|
|
39
|
+
const totalInterest = round2(maturityValue - principal);
|
|
40
|
+
// Effective annual rate (for display)
|
|
41
|
+
const effectiveRate = round2((Math.pow(maturityValue / principal, 12 / termMonths) - 1) * 100 * 100) / 100;
|
|
42
|
+
// Build accrual schedule — accrue at compound intervals using the same periodic rate
|
|
43
|
+
const schedule = [];
|
|
44
|
+
let accruedTotal = 0;
|
|
45
|
+
if (compounding === 'none') {
|
|
46
|
+
// Simple interest: equal accrual each month
|
|
47
|
+
const monthlyInterest = round2(totalInterest / termMonths);
|
|
48
|
+
let balance = principal;
|
|
49
|
+
for (let i = 1; i <= termMonths; i++) {
|
|
50
|
+
const isFinal = i === termMonths;
|
|
51
|
+
const interest = isFinal ? round2(totalInterest - accruedTotal) : monthlyInterest;
|
|
52
|
+
accruedTotal = round2(accruedTotal + interest);
|
|
53
|
+
const date = startDate ? addMonths(startDate, i) : null;
|
|
54
|
+
const journal = {
|
|
55
|
+
description: `Interest accrual — Month ${i} of ${termMonths}`,
|
|
56
|
+
lines: [
|
|
57
|
+
{ account: 'Accrued Interest Receivable', debit: interest, credit: 0 },
|
|
58
|
+
{ account: 'Interest Income', debit: 0, credit: interest },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
schedule.push({
|
|
62
|
+
period: i,
|
|
63
|
+
date,
|
|
64
|
+
openingBalance: balance,
|
|
65
|
+
interest,
|
|
66
|
+
closingBalance: balance, // simple interest: principal unchanged
|
|
67
|
+
journal,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
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);
|
|
75
|
+
let balance = principal;
|
|
76
|
+
const totalPeriods = termMonths / compIntervalMonths;
|
|
77
|
+
for (let p = 1; p <= totalPeriods; p++) {
|
|
78
|
+
const openingBalance = round2(balance);
|
|
79
|
+
const isFinal = p === totalPeriods;
|
|
80
|
+
let interest;
|
|
81
|
+
if (isFinal) {
|
|
82
|
+
// Final period: close to exact maturity value
|
|
83
|
+
interest = round2(maturityValue - openingBalance);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
interest = round2(openingBalance * periodicRate);
|
|
87
|
+
}
|
|
88
|
+
balance = round2(openingBalance + interest);
|
|
89
|
+
accruedTotal = round2(accruedTotal + interest);
|
|
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}`;
|
|
95
|
+
const journal = {
|
|
96
|
+
description: `Interest accrual — ${periodLabel} of ${totalPeriods}`,
|
|
97
|
+
lines: [
|
|
98
|
+
{ account: 'Accrued Interest Receivable', debit: interest, credit: 0 },
|
|
99
|
+
{ account: 'Interest Income', debit: 0, credit: interest },
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
schedule.push({
|
|
103
|
+
period: p,
|
|
104
|
+
date,
|
|
105
|
+
openingBalance,
|
|
106
|
+
interest,
|
|
107
|
+
closingBalance: balance,
|
|
108
|
+
journal,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const placementJournal = {
|
|
113
|
+
description: 'Fixed deposit placement',
|
|
114
|
+
lines: [
|
|
115
|
+
{ account: 'Fixed Deposit', debit: principal, credit: 0 },
|
|
116
|
+
{ account: 'Cash / Bank Account', debit: 0, credit: principal },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
const maturityJournal = {
|
|
120
|
+
description: 'Fixed deposit maturity',
|
|
121
|
+
lines: [
|
|
122
|
+
{ account: 'Cash / Bank Account', debit: maturityValue, credit: 0 },
|
|
123
|
+
{ account: 'Fixed Deposit', debit: 0, credit: principal },
|
|
124
|
+
{ account: 'Accrued Interest Receivable', debit: 0, credit: totalInterest },
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
// Build blueprint
|
|
128
|
+
let blueprint = null;
|
|
129
|
+
if (startDate) {
|
|
130
|
+
const steps = [
|
|
131
|
+
cashOutStep(1, 'Transfer funds from operating account to fixed deposit', startDate, placementJournal.lines),
|
|
132
|
+
...schedule.map((row, idx) => journalStep(idx + 2, row.journal.description, row.date, row.journal.lines)),
|
|
133
|
+
cashInStep(schedule.length + 2, 'Fixed deposit maturity — principal + accrued interest returned to Cash', addMonths(startDate, termMonths), maturityJournal.lines),
|
|
134
|
+
];
|
|
135
|
+
const c = currency ?? undefined;
|
|
136
|
+
const compoundLabel = compounding === 'none' ? 'Simple interest' : `Compound ${compounding}`;
|
|
137
|
+
const monthlyAccrual = compounding === 'none' ? round2(totalInterest / termMonths) : null;
|
|
138
|
+
const workingsLines = [
|
|
139
|
+
`Fixed Deposit Interest Accrual Workings (IFRS 9)`,
|
|
140
|
+
`Principal: ${fmtAmt(principal, c)} | Rate: ${annualRate}% p.a. | Term: ${termMonths} months`,
|
|
141
|
+
`Method: ${compoundLabel}`,
|
|
142
|
+
];
|
|
143
|
+
if (compounding === 'none') {
|
|
144
|
+
workingsLines.push(`Interest: ${fmtAmt(principal, c)} × ${annualRate}% × ${termMonths}/12 = ${fmtAmt(totalInterest, c)}`, `Monthly accrual: ${fmtAmt(monthlyAccrual, c)}`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const periodsPerYear = compounding === 'monthly' ? 12 : compounding === 'quarterly' ? 4 : 1;
|
|
148
|
+
workingsLines.push(`Compounding: ${periodsPerYear}× per year | Effective annual rate: ${effectiveRate}%`, `Total interest: ${fmtAmt(totalInterest, c)}`);
|
|
149
|
+
}
|
|
150
|
+
workingsLines.push(`Maturity value: ${fmtAmt(maturityValue, c)}`, `Classification: Amortized cost (hold-to-collect, passes SPPI)`, `Rounding: 2dp per period, final period closes to exact maturity value`);
|
|
151
|
+
blueprint = {
|
|
152
|
+
capsuleType: 'Fixed Deposit',
|
|
153
|
+
capsuleName: `Fixed Deposit — ${fmtCapsuleAmount(principal, currency)} — ${annualRate}% — ${termMonths} months`,
|
|
154
|
+
capsuleDescription: workingsLines.join('\n'),
|
|
155
|
+
tags: ['Fixed Deposit'],
|
|
156
|
+
customFields: { 'Deposit Reference': null, 'Bank Name': null },
|
|
157
|
+
steps,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
type: 'fixed-deposit',
|
|
162
|
+
currency: currency ?? null,
|
|
163
|
+
inputs: {
|
|
164
|
+
principal,
|
|
165
|
+
annualRate,
|
|
166
|
+
termMonths,
|
|
167
|
+
compounding,
|
|
168
|
+
startDate: startDate ?? null,
|
|
169
|
+
},
|
|
170
|
+
maturityValue,
|
|
171
|
+
totalInterest,
|
|
172
|
+
effectiveRate,
|
|
173
|
+
schedule,
|
|
174
|
+
placementJournal,
|
|
175
|
+
maturityJournal,
|
|
176
|
+
blueprint,
|
|
177
|
+
};
|
|
178
|
+
}
|