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.
Files changed (35) hide show
  1. package/assets/skills/api/SKILL.md +12 -2
  2. package/assets/skills/api/references/dependencies.md +3 -2
  3. package/assets/skills/api/references/endpoints.md +78 -0
  4. package/assets/skills/api/references/feature-glossary.md +5 -5
  5. package/assets/skills/api/references/field-map.md +17 -0
  6. package/assets/skills/api/references/full-api-surface.md +1 -1
  7. package/assets/skills/conversion/SKILL.md +1 -1
  8. package/assets/skills/transaction-recipes/SKILL.md +53 -19
  9. package/assets/skills/transaction-recipes/references/asset-disposal.md +174 -0
  10. package/assets/skills/transaction-recipes/references/fixed-deposit.md +164 -0
  11. package/assets/skills/transaction-recipes/references/hire-purchase.md +190 -0
  12. package/dist/__tests__/amortization.test.js +101 -0
  13. package/dist/__tests__/asset-disposal.test.js +249 -0
  14. package/dist/__tests__/blueprint.test.js +72 -0
  15. package/dist/__tests__/depreciation.test.js +125 -0
  16. package/dist/__tests__/ecl.test.js +134 -0
  17. package/dist/__tests__/fixed-deposit.test.js +214 -0
  18. package/dist/__tests__/fx-reval.test.js +115 -0
  19. package/dist/__tests__/lease.test.js +96 -0
  20. package/dist/__tests__/loan.test.js +80 -0
  21. package/dist/__tests__/provision.test.js +141 -0
  22. package/dist/__tests__/validate.test.js +81 -0
  23. package/dist/calc/amortization.js +21 -3
  24. package/dist/calc/asset-disposal.js +155 -0
  25. package/dist/calc/blueprint.js +26 -1
  26. package/dist/calc/depreciation.js +24 -1
  27. package/dist/calc/ecl.js +13 -1
  28. package/dist/calc/fixed-deposit.js +178 -0
  29. package/dist/calc/format.js +107 -2
  30. package/dist/calc/fx-reval.js +11 -1
  31. package/dist/calc/lease.js +42 -9
  32. package/dist/calc/loan.js +12 -2
  33. package/dist/calc/provision.js +17 -1
  34. package/dist/commands/calc.js +54 -2
  35. 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
- journalStep(1, 'Initial prepaid payment (bill or cash-out entry)', startDate, [
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
- journalStep(1, 'Initial deferred receipt (invoice or cash-in entry)', startDate, [
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
+ }
@@ -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 note step (instruction, not a journal — e.g. "register fixed asset"). */
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
+ }