jaz-cli 2.3.0 → 2.5.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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Output formatters for calc results.
3
+ * Table format for human reading, JSON for programmatic use.
4
+ *
5
+ * Currency-aware: when a result has a currency code, it appears in
6
+ * the title and summary lines (e.g. "Loan Amortization Schedule (SGD)").
7
+ */
8
+ import chalk from 'chalk';
9
+ const fmt = (n) => n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
10
+ const fmtR = (n) => fmt(n).padStart(14);
11
+ const fmtPct = (n) => `${n}%`;
12
+ const currTag = (c) => c ? ` (${c})` : '';
13
+ const line = (w) => chalk.dim('─'.repeat(w));
14
+ function printJournal(journal) {
15
+ console.log(chalk.dim(` ${journal.description}`));
16
+ for (const l of journal.lines) {
17
+ if (l.debit > 0) {
18
+ console.log(chalk.dim(` Dr ${l.account.padEnd(35)} ${fmt(l.debit)}`));
19
+ }
20
+ if (l.credit > 0) {
21
+ console.log(chalk.dim(` Cr ${l.account.padEnd(35)} ${fmt(l.credit)}`));
22
+ }
23
+ }
24
+ }
25
+ // ── Loan ──────────────────────────────────────────────────────────
26
+ function printLoanTable(result) {
27
+ const W = 90;
28
+ console.log();
29
+ console.log(chalk.bold(`Loan Amortization Schedule${currTag(result.currency)}`));
30
+ console.log(line(W));
31
+ console.log(chalk.bold(` Principal: ${fmt(result.inputs.principal)}`));
32
+ console.log(chalk.bold(` Annual Rate: ${fmtPct(result.inputs.annualRate)}`));
33
+ console.log(chalk.bold(` Term: ${result.inputs.termMonths} months`));
34
+ console.log(chalk.bold(` Monthly PMT: ${fmt(result.monthlyPayment)}`));
35
+ console.log(line(W));
36
+ const header = [
37
+ 'Period'.padStart(6),
38
+ result.inputs.startDate ? 'Date'.padStart(12) : null,
39
+ 'Opening'.padStart(14),
40
+ 'Payment'.padStart(14),
41
+ 'Interest'.padStart(14),
42
+ 'Principal'.padStart(14),
43
+ 'Closing'.padStart(14),
44
+ ].filter(Boolean).join(' ');
45
+ console.log(chalk.dim(header));
46
+ console.log(line(W));
47
+ for (const row of result.schedule) {
48
+ const cols = [
49
+ String(row.period).padStart(6),
50
+ row.date ? row.date.padStart(12) : null,
51
+ fmtR(row.openingBalance),
52
+ fmtR(row.payment),
53
+ fmtR(row.interest),
54
+ fmtR(row.principal),
55
+ fmtR(row.closingBalance),
56
+ ].filter(Boolean).join(' ');
57
+ console.log(cols);
58
+ }
59
+ console.log(line(W));
60
+ const totalCols = [
61
+ 'TOTAL'.padStart(6),
62
+ result.inputs.startDate ? ''.padStart(12) : null,
63
+ ''.padStart(14),
64
+ fmtR(result.totalPayments),
65
+ fmtR(result.totalInterest),
66
+ fmtR(result.totalPrincipal),
67
+ fmtR(0),
68
+ ].filter(Boolean).join(' ');
69
+ console.log(chalk.bold(totalCols));
70
+ console.log();
71
+ console.log(chalk.bold('Journal Entries'));
72
+ console.log(line(60));
73
+ console.log(chalk.dim(' Disbursement:'));
74
+ console.log(chalk.dim(` Dr Cash / Bank Account${' '.repeat(14)}${fmt(result.inputs.principal)}`));
75
+ console.log(chalk.dim(` Cr Loan Payable${' '.repeat(21)} ${fmt(result.inputs.principal)}`));
76
+ console.log();
77
+ console.log(chalk.dim(` Per period (example — Month 1):`));
78
+ printJournal(result.schedule[0].journal);
79
+ console.log();
80
+ }
81
+ // ── Lease ─────────────────────────────────────────────────────────
82
+ function printLeaseTable(result) {
83
+ const W = 90;
84
+ console.log();
85
+ console.log(chalk.bold(`IFRS 16 Lease Schedule${currTag(result.currency)}`));
86
+ console.log(line(W));
87
+ console.log(chalk.bold(` Monthly Payment: ${fmt(result.inputs.monthlyPayment)}`));
88
+ console.log(chalk.bold(` Lease Term: ${result.inputs.termMonths} months`));
89
+ console.log(chalk.bold(` Discount Rate (IBR): ${fmtPct(result.inputs.annualRate)}`));
90
+ console.log(chalk.bold(` Present Value: ${fmt(result.presentValue)}`));
91
+ console.log(chalk.bold(` Monthly ROU Dep: ${fmt(result.monthlyRouDepreciation)}`));
92
+ console.log(line(W));
93
+ console.log(chalk.bold('\nInitial Recognition'));
94
+ printJournal(result.initialJournal);
95
+ console.log(chalk.bold('\nLiability Unwinding Schedule'));
96
+ const header = [
97
+ 'Period'.padStart(6),
98
+ result.inputs.startDate ? 'Date'.padStart(12) : null,
99
+ 'Opening'.padStart(14),
100
+ 'Payment'.padStart(14),
101
+ 'Interest'.padStart(14),
102
+ 'Principal'.padStart(14),
103
+ 'Closing'.padStart(14),
104
+ ].filter(Boolean).join(' ');
105
+ console.log(chalk.dim(header));
106
+ console.log(line(W));
107
+ for (const row of result.schedule) {
108
+ const cols = [
109
+ String(row.period).padStart(6),
110
+ row.date ? row.date.padStart(12) : null,
111
+ fmtR(row.openingBalance),
112
+ fmtR(row.payment),
113
+ fmtR(row.interest),
114
+ fmtR(row.principal),
115
+ fmtR(row.closingBalance),
116
+ ].filter(Boolean).join(' ');
117
+ console.log(cols);
118
+ }
119
+ console.log(line(W));
120
+ const totalCols = [
121
+ 'TOTAL'.padStart(6),
122
+ result.inputs.startDate ? ''.padStart(12) : null,
123
+ ''.padStart(14),
124
+ fmtR(result.totalCashPayments),
125
+ fmtR(result.totalInterest),
126
+ fmtR(result.presentValue),
127
+ fmtR(0),
128
+ ].filter(Boolean).join(' ');
129
+ console.log(chalk.bold(totalCols));
130
+ console.log();
131
+ console.log(chalk.bold('Monthly ROU Depreciation'));
132
+ console.log(chalk.dim(` Dr Depreciation Expense — ROU${' '.repeat(8)}${fmt(result.monthlyRouDepreciation)}`));
133
+ console.log(chalk.dim(` Cr Accumulated Depreciation — ROU${' '.repeat(4)} ${fmt(result.monthlyRouDepreciation)}`));
134
+ console.log();
135
+ console.log(chalk.bold('Summary'));
136
+ console.log(` Total cash payments: ${fmt(result.totalCashPayments)}`);
137
+ console.log(` Total interest expense: ${fmt(result.totalInterest)}`);
138
+ console.log(` Total ROU depreciation: ${fmt(result.totalDepreciation)}`);
139
+ console.log(` Total P&L impact: ${fmt(result.totalInterest + result.totalDepreciation)}`);
140
+ console.log();
141
+ }
142
+ // ── Depreciation ──────────────────────────────────────────────────
143
+ function printDepreciationTable(result) {
144
+ const isSL = result.inputs.method === 'sl';
145
+ const isMonthly = result.inputs.frequency === 'monthly';
146
+ const periodLabel = isMonthly ? 'Month' : 'Year';
147
+ const methodLabel = result.inputs.method.toUpperCase();
148
+ const W = isSL ? 72 : 100;
149
+ console.log();
150
+ console.log(chalk.bold(`${isSL ? 'Straight-Line' : 'Declining Balance'} Depreciation Schedule${isSL ? '' : ` (${methodLabel})`}${currTag(result.currency)}`));
151
+ console.log(line(W));
152
+ console.log(chalk.bold(` Asset Cost: ${fmt(result.inputs.cost)}`));
153
+ console.log(chalk.bold(` Salvage Value: ${fmt(result.inputs.salvageValue)}`));
154
+ console.log(chalk.bold(` Useful Life: ${result.inputs.usefulLifeYears} years`));
155
+ if (!isSL) {
156
+ const mult = result.inputs.method === 'ddb' ? 2 : 1.5;
157
+ console.log(chalk.bold(` Method: ${methodLabel} (${mult} / ${result.inputs.usefulLifeYears} = ${(mult / result.inputs.usefulLifeYears * 100).toFixed(1)}% per year)`));
158
+ }
159
+ console.log(line(W));
160
+ if (isSL) {
161
+ // Straight-line: simpler table — no DDB/SL comparison columns
162
+ const header = [
163
+ periodLabel.padStart(6),
164
+ 'Opening BV'.padStart(14),
165
+ 'Depreciation'.padStart(14),
166
+ 'Closing BV'.padStart(14),
167
+ ].join(' ');
168
+ console.log(chalk.dim(header));
169
+ console.log(line(W));
170
+ for (const row of result.schedule) {
171
+ const cols = [
172
+ String(row.period).padStart(6),
173
+ fmtR(row.openingBookValue),
174
+ fmtR(row.depreciation),
175
+ fmtR(row.closingBookValue),
176
+ ].join(' ');
177
+ console.log(cols);
178
+ }
179
+ }
180
+ else {
181
+ // DDB/150DB: full comparison table
182
+ const header = [
183
+ periodLabel.padStart(6),
184
+ 'Opening BV'.padStart(14),
185
+ 'DDB'.padStart(14),
186
+ 'SL'.padStart(14),
187
+ 'Method'.padStart(8),
188
+ 'Depreciation'.padStart(14),
189
+ 'Closing BV'.padStart(14),
190
+ ].join(' ');
191
+ console.log(chalk.dim(header));
192
+ console.log(line(W));
193
+ for (const row of result.schedule) {
194
+ const cols = [
195
+ String(row.period).padStart(6),
196
+ fmtR(row.openingBookValue),
197
+ fmtR(row.ddbAmount),
198
+ fmtR(row.slAmount),
199
+ row.methodUsed.padStart(8),
200
+ fmtR(row.depreciation),
201
+ fmtR(row.closingBookValue),
202
+ ].join(' ');
203
+ console.log(cols);
204
+ }
205
+ }
206
+ console.log(line(W));
207
+ console.log(chalk.bold(` Total depreciation: ${fmt(result.totalDepreciation)} (cost ${fmt(result.inputs.cost)} − salvage ${fmt(result.inputs.salvageValue)})`));
208
+ console.log();
209
+ console.log(chalk.bold('Journal Entry (per period)'));
210
+ printJournal(result.schedule[0].journal);
211
+ console.log();
212
+ }
213
+ // ── Prepaid Expense / Deferred Revenue ────────────────────────────
214
+ function printRecognitionTable(result) {
215
+ const isPrepaid = result.type === 'prepaid-expense';
216
+ const title = isPrepaid
217
+ ? 'Prepaid Expense Recognition Schedule'
218
+ : 'Deferred Revenue Recognition Schedule';
219
+ const amountLabel = isPrepaid ? 'Prepaid Amount' : 'Deferred Amount';
220
+ const W = 60;
221
+ console.log();
222
+ console.log(chalk.bold(`${title}${currTag(result.currency)}`));
223
+ console.log(line(W));
224
+ console.log(chalk.bold(` ${amountLabel}: ${fmt(result.inputs.amount)}`));
225
+ console.log(chalk.bold(` Periods: ${result.inputs.periods} (${result.inputs.frequency})`));
226
+ console.log(chalk.bold(` Per Period: ${fmt(result.perPeriodAmount)}`));
227
+ console.log(line(W));
228
+ const recognizedLabel = isPrepaid ? 'Expensed' : 'Recognized';
229
+ const header = [
230
+ 'Period'.padStart(6),
231
+ result.inputs.startDate ? 'Date'.padStart(12) : null,
232
+ recognizedLabel.padStart(14),
233
+ 'Remaining'.padStart(14),
234
+ ].filter(Boolean).join(' ');
235
+ console.log(chalk.dim(header));
236
+ console.log(line(W));
237
+ for (const row of result.schedule) {
238
+ const cols = [
239
+ String(row.period).padStart(6),
240
+ row.date ? row.date.padStart(12) : null,
241
+ fmtR(row.amortized),
242
+ fmtR(row.remainingBalance),
243
+ ].filter(Boolean).join(' ');
244
+ console.log(cols);
245
+ }
246
+ console.log(line(W));
247
+ console.log();
248
+ console.log(chalk.bold('Journal Entry (per period)'));
249
+ printJournal(result.schedule[0].journal);
250
+ console.log();
251
+ }
252
+ // ── FX Revaluation ────────────────────────────────────────────────
253
+ function printFxRevalTable(result) {
254
+ const W = 70;
255
+ const ccy = result.currency ?? 'FCY';
256
+ const base = result.inputs.baseCurrency;
257
+ console.log();
258
+ console.log(chalk.bold(`FX Revaluation — IAS 21`));
259
+ console.log(line(W));
260
+ console.log(chalk.bold(` Foreign Currency: ${ccy}`));
261
+ console.log(chalk.bold(` Base Currency: ${base}`));
262
+ console.log(chalk.bold(` Amount Outstanding: ${ccy} ${fmt(result.inputs.amount)}`));
263
+ console.log(chalk.bold(` Book Rate: ${result.inputs.bookRate}`));
264
+ console.log(chalk.bold(` Closing Rate: ${result.inputs.closingRate}`));
265
+ console.log(line(W));
266
+ console.log();
267
+ console.log(` Book Value (${base}): ${fmt(result.bookValue)} (${ccy} ${fmt(result.inputs.amount)} × ${result.inputs.bookRate})`);
268
+ console.log(` Closing Value (${base}): ${fmt(result.closingValue)} (${ccy} ${fmt(result.inputs.amount)} × ${result.inputs.closingRate})`);
269
+ console.log(line(W));
270
+ const label = result.isGain ? chalk.green('Unrealized GAIN') : chalk.red('Unrealized LOSS');
271
+ console.log(` ${label}: ${base} ${fmt(Math.abs(result.gainOrLoss))}`);
272
+ console.log();
273
+ console.log(chalk.bold('Revaluation Journal'));
274
+ printJournal(result.journal);
275
+ console.log();
276
+ console.log(chalk.bold('Day 1 Reversal'));
277
+ printJournal(result.reversalJournal);
278
+ console.log();
279
+ }
280
+ // ── ECL Provision ─────────────────────────────────────────────────
281
+ function printEclTable(result) {
282
+ const W = 75;
283
+ console.log();
284
+ console.log(chalk.bold(`Expected Credit Loss — IFRS 9 Provision Matrix${currTag(result.currency)}`));
285
+ console.log(line(W));
286
+ // Provision matrix table
287
+ const header = [
288
+ 'Aging Bucket'.padEnd(16),
289
+ 'Balance'.padStart(14),
290
+ 'Loss Rate'.padStart(10),
291
+ 'ECL'.padStart(14),
292
+ ].join(' ');
293
+ console.log(chalk.dim(header));
294
+ console.log(line(W));
295
+ for (const row of result.bucketDetails) {
296
+ const cols = [
297
+ row.bucket.padEnd(16),
298
+ fmtR(row.balance),
299
+ `${row.lossRate}%`.padStart(10),
300
+ fmtR(row.ecl),
301
+ ].join(' ');
302
+ console.log(cols);
303
+ }
304
+ console.log(line(W));
305
+ const totalCols = [
306
+ 'TOTAL'.padEnd(16),
307
+ fmtR(result.totalReceivables),
308
+ `${result.weightedRate}%`.padStart(10),
309
+ fmtR(result.totalEcl),
310
+ ].join(' ');
311
+ console.log(chalk.bold(totalCols));
312
+ console.log();
313
+ // Adjustment summary
314
+ console.log(chalk.bold('Provision Adjustment'));
315
+ console.log(` Required provision: ${fmt(result.totalEcl)}`);
316
+ console.log(` Existing provision: ${fmt(result.inputs.existingProvision)}`);
317
+ console.log(line(45));
318
+ const adjLabel = result.isIncrease
319
+ ? chalk.yellow(`Increase needed`)
320
+ : chalk.green(`Release available`);
321
+ console.log(` ${adjLabel}: ${fmt(Math.abs(result.adjustmentRequired))}`);
322
+ console.log();
323
+ console.log(chalk.bold('Journal Entry'));
324
+ printJournal(result.journal);
325
+ console.log();
326
+ }
327
+ // ── Provision PV Unwinding ────────────────────────────────────────
328
+ function printProvisionTable(result) {
329
+ const W = 80;
330
+ console.log();
331
+ console.log(chalk.bold(`IAS 37 Provision — PV Measurement & Unwinding${currTag(result.currency)}`));
332
+ console.log(line(W));
333
+ console.log(chalk.bold(` Nominal Outflow: ${fmt(result.nominalAmount)}`));
334
+ console.log(chalk.bold(` Discount Rate: ${fmtPct(result.inputs.annualRate)}`));
335
+ console.log(chalk.bold(` Settlement Term: ${result.inputs.termMonths} months`));
336
+ console.log(chalk.bold(` Present Value: ${fmt(result.presentValue)}`));
337
+ console.log(chalk.bold(` Total Unwinding: ${fmt(result.totalUnwinding)}`));
338
+ console.log(line(W));
339
+ console.log(chalk.bold('\nInitial Recognition'));
340
+ printJournal(result.initialJournal);
341
+ console.log(chalk.bold('\nDiscount Unwinding Schedule'));
342
+ const header = [
343
+ 'Period'.padStart(6),
344
+ result.inputs.startDate ? 'Date'.padStart(12) : null,
345
+ 'Opening'.padStart(14),
346
+ 'Unwinding'.padStart(14),
347
+ 'Closing'.padStart(14),
348
+ ].filter(Boolean).join(' ');
349
+ console.log(chalk.dim(header));
350
+ console.log(line(W));
351
+ for (const row of result.schedule) {
352
+ const cols = [
353
+ String(row.period).padStart(6),
354
+ row.date ? row.date.padStart(12) : null,
355
+ fmtR(row.openingBalance),
356
+ fmtR(row.interest),
357
+ fmtR(row.closingBalance),
358
+ ].filter(Boolean).join(' ');
359
+ console.log(cols);
360
+ }
361
+ console.log(line(W));
362
+ const finalRow = result.schedule[result.schedule.length - 1];
363
+ console.log(chalk.bold(` Final provision balance: ${fmt(finalRow.closingBalance)} (= nominal outflow)`));
364
+ console.log();
365
+ console.log(chalk.bold('Summary'));
366
+ console.log(` Initial recognition (PV): ${fmt(result.presentValue)}`);
367
+ console.log(` Total unwinding (P&L): ${fmt(result.totalUnwinding)}`);
368
+ console.log(` Nominal outflow: ${fmt(result.nominalAmount)}`);
369
+ console.log();
370
+ console.log(chalk.bold('Journal Entry (per period)'));
371
+ printJournal(result.schedule[0].journal);
372
+ console.log();
373
+ }
374
+ // ── Dispatch ──────────────────────────────────────────────────────
375
+ export function printResult(result) {
376
+ switch (result.type) {
377
+ case 'loan': return printLoanTable(result);
378
+ case 'lease': return printLeaseTable(result);
379
+ case 'depreciation': return printDepreciationTable(result);
380
+ case 'prepaid-expense': return printRecognitionTable(result);
381
+ case 'deferred-revenue': return printRecognitionTable(result);
382
+ case 'fx-reval': return printFxRevalTable(result);
383
+ case 'ecl': return printEclTable(result);
384
+ case 'provision': return printProvisionTable(result);
385
+ }
386
+ }
387
+ export function printJson(result) {
388
+ console.log(JSON.stringify(result, null, 2));
389
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * FX Revaluation calculator (IAS 21).
3
+ *
4
+ * Calculates unrealized gain/loss on non-AR/AP foreign currency monetary
5
+ * items at period-end. This is for items NOT auto-revalued by the platform
6
+ * (which handles invoices, bills, credit notes, cash, and bank balances).
7
+ *
8
+ * Use cases:
9
+ * - Intercompany loan receivables/payables (booked as manual journals)
10
+ * - Foreign currency term deposits or escrow
11
+ * - FX-denominated provisions
12
+ * - Any manual journal balance in a foreign currency account
13
+ *
14
+ * IAS 21.23: All monetary items translated at closing rate.
15
+ * IAS 21.28: Exchange differences recognized in P&L.
16
+ */
17
+ import { round2 } from './types.js';
18
+ import { validatePositive } from './validate.js';
19
+ import { journalStep } from './blueprint.js';
20
+ export function calculateFxReval(inputs) {
21
+ const { amount, bookRate, closingRate, currency = 'USD', baseCurrency = 'SGD', } = inputs;
22
+ validatePositive(amount, 'Foreign currency amount');
23
+ validatePositive(bookRate, 'Book rate');
24
+ validatePositive(closingRate, 'Closing rate');
25
+ // Base currency equivalents
26
+ const bookValue = round2(amount * bookRate);
27
+ const closingValue = round2(amount * closingRate);
28
+ const gainOrLoss = round2(closingValue - bookValue);
29
+ const isGain = gainOrLoss >= 0;
30
+ const absAmount = Math.abs(gainOrLoss);
31
+ // Journal entry for the revaluation adjustment
32
+ let journal;
33
+ if (isGain) {
34
+ journal = {
35
+ description: `FX revaluation — ${currency} ${amount.toLocaleString()} @ ${closingRate} (was ${bookRate})`,
36
+ lines: [
37
+ { account: `${currency} Monetary Item`, debit: absAmount, credit: 0 },
38
+ { account: 'FX Unrealized Gain', debit: 0, credit: absAmount },
39
+ ],
40
+ };
41
+ }
42
+ else {
43
+ journal = {
44
+ description: `FX revaluation — ${currency} ${amount.toLocaleString()} @ ${closingRate} (was ${bookRate})`,
45
+ lines: [
46
+ { account: 'FX Unrealized Loss', debit: absAmount, credit: 0 },
47
+ { account: `${currency} Monetary Item`, debit: 0, credit: absAmount },
48
+ ],
49
+ };
50
+ }
51
+ // Reversal journal (Day 1 of next period)
52
+ const reversalJournal = {
53
+ description: `Reversal of FX revaluation — ${currency} ${amount.toLocaleString()}`,
54
+ lines: journal.lines.map(l => ({
55
+ account: l.account,
56
+ debit: l.credit, // swap debit/credit
57
+ credit: l.debit,
58
+ })),
59
+ };
60
+ // Blueprint: revaluation + reversal
61
+ const blueprint = {
62
+ capsuleType: 'FX Revaluation',
63
+ capsuleName: `FX Reval — ${currency} ${amount.toLocaleString()} — ${bookRate} → ${closingRate}`,
64
+ tags: ['FX Revaluation', currency],
65
+ customFields: { 'Source Account': null, 'Period End Date': null },
66
+ steps: [
67
+ journalStep(1, journal.description, null, journal.lines),
68
+ journalStep(2, reversalJournal.description, null, reversalJournal.lines),
69
+ ],
70
+ };
71
+ return {
72
+ type: 'fx-reval',
73
+ currency,
74
+ inputs: { amount, bookRate, closingRate, baseCurrency },
75
+ bookValue,
76
+ closingValue,
77
+ gainOrLoss,
78
+ isGain,
79
+ journal,
80
+ reversalJournal,
81
+ blueprint,
82
+ };
83
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * IFRS 16 lease calculator.
3
+ *
4
+ * Compliance references:
5
+ * - IFRS 16.26: Initial measurement — PV of lease payments
6
+ * - IFRS 16.36-37: Subsequent measurement — effective interest method
7
+ * - IFRS 16.31-32: ROU depreciation — straight-line over lease term
8
+ *
9
+ * Uses `financial` package for PV calculation.
10
+ * Final period penny adjustment closes lease liability to exactly zero.
11
+ */
12
+ import { pv } from 'financial';
13
+ import { round2, addMonths } from './types.js';
14
+ import { validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
15
+ import { journalStep, noteStep, fmtCapsuleAmount } from './blueprint.js';
16
+ export function calculateLease(inputs) {
17
+ const { monthlyPayment, termMonths, annualRate, startDate, currency } = inputs;
18
+ validatePositive(monthlyPayment, 'Monthly payment');
19
+ validateRate(annualRate, 'Annual rate (IBR)');
20
+ validatePositiveInteger(termMonths, 'Term (months)');
21
+ validateDateFormat(startDate);
22
+ const monthlyRate = annualRate / 100 / 12;
23
+ // PV of an ordinary annuity (payments at end of period)
24
+ // pv() returns negative, negate for positive value
25
+ const presentValue = round2(-pv(monthlyRate, termMonths, monthlyPayment));
26
+ // ROU depreciation: straight-line over lease term (IFRS 16.31-32)
27
+ const monthlyRouDepreciation = round2(presentValue / termMonths);
28
+ const initialJournal = {
29
+ description: 'Initial recognition — IFRS 16 lease',
30
+ lines: [
31
+ { account: 'Right-of-Use Asset', debit: presentValue, credit: 0 },
32
+ { account: 'Lease Liability', debit: 0, credit: presentValue },
33
+ ],
34
+ };
35
+ // Liability unwinding schedule (effective interest method, IFRS 16.36-37)
36
+ const schedule = [];
37
+ let liability = presentValue;
38
+ let totalInterest = 0;
39
+ let totalPrincipal = 0;
40
+ for (let i = 1; i <= termMonths; i++) {
41
+ const openingBalance = round2(liability);
42
+ const isFinal = i === termMonths;
43
+ let interest;
44
+ let principalPortion;
45
+ let periodPayment;
46
+ if (isFinal) {
47
+ // Final period: close liability to exactly zero
48
+ interest = round2(openingBalance * monthlyRate);
49
+ principalPortion = openingBalance;
50
+ periodPayment = round2(principalPortion + interest);
51
+ }
52
+ else {
53
+ interest = round2(openingBalance * monthlyRate);
54
+ principalPortion = round2(monthlyPayment - interest);
55
+ periodPayment = monthlyPayment;
56
+ }
57
+ liability = round2(openingBalance - principalPortion);
58
+ totalInterest = round2(totalInterest + interest);
59
+ totalPrincipal = round2(totalPrincipal + principalPortion);
60
+ const date = startDate ? addMonths(startDate, i) : null;
61
+ const journal = {
62
+ description: `Lease payment — Month ${i} of ${termMonths}`,
63
+ lines: [
64
+ { account: 'Lease Liability', debit: principalPortion, credit: 0 },
65
+ { account: 'Interest Expense — Leases', debit: interest, credit: 0 },
66
+ { account: 'Cash / Bank Account', debit: 0, credit: periodPayment },
67
+ ],
68
+ };
69
+ schedule.push({
70
+ period: i,
71
+ date,
72
+ openingBalance,
73
+ payment: periodPayment,
74
+ interest,
75
+ principal: principalPortion,
76
+ closingBalance: liability,
77
+ journal,
78
+ });
79
+ }
80
+ // ROU depreciation total (final month absorbs rounding)
81
+ const totalDepreciation = round2(monthlyRouDepreciation * (termMonths - 1) +
82
+ (presentValue - monthlyRouDepreciation * (termMonths - 1)));
83
+ // Build blueprint for agent execution
84
+ let blueprint = null;
85
+ if (startDate) {
86
+ const steps = [
87
+ journalStep(1, initialJournal.description, startDate, initialJournal.lines),
88
+ noteStep(2, `Register Right-of-Use Asset in Fixed Asset register: cost ${presentValue}, salvage 0, life ${termMonths} months (straight-line). Jaz will auto-post monthly depreciation of ${monthlyRouDepreciation}.`, startDate),
89
+ ...schedule.map((row, idx) => journalStep(idx + 3, row.journal.description, row.date, row.journal.lines)),
90
+ ];
91
+ blueprint = {
92
+ capsuleType: 'Lease Accounting',
93
+ capsuleName: `IFRS 16 Lease — ${fmtCapsuleAmount(monthlyPayment, currency)}/month — ${termMonths} months`,
94
+ tags: ['Lease Accounting'],
95
+ customFields: { 'Lease Contract #': null },
96
+ steps,
97
+ };
98
+ }
99
+ return {
100
+ type: 'lease',
101
+ currency: currency ?? null,
102
+ inputs: {
103
+ monthlyPayment,
104
+ termMonths,
105
+ annualRate,
106
+ startDate: startDate ?? null,
107
+ },
108
+ presentValue,
109
+ monthlyRouDepreciation,
110
+ totalCashPayments: round2(totalInterest + totalPrincipal),
111
+ totalInterest,
112
+ totalDepreciation,
113
+ initialJournal,
114
+ schedule,
115
+ blueprint,
116
+ };
117
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Loan amortization calculator.
3
+ * Uses the `financial` package for PMT calculation.
4
+ * IFRS-compliant rounding: 2 decimals per period, final period closes to zero.
5
+ */
6
+ import { pmt } from 'financial';
7
+ import { round2, addMonths } from './types.js';
8
+ import { validatePositive, validatePositiveInteger, validateDateFormat, validateRate } from './validate.js';
9
+ import { journalStep, fmtCapsuleAmount } from './blueprint.js';
10
+ export function calculateLoan(inputs) {
11
+ const { principal, annualRate, termMonths, startDate, currency } = inputs;
12
+ validatePositive(principal, 'Principal');
13
+ validateRate(annualRate, 'Annual rate');
14
+ validatePositiveInteger(termMonths, 'Term (months)');
15
+ validateDateFormat(startDate);
16
+ const monthlyRate = annualRate / 100 / 12;
17
+ // PMT returns negative (cash outflow convention), negate for positive value
18
+ const payment = round2(-pmt(monthlyRate, termMonths, principal));
19
+ const schedule = [];
20
+ let balance = principal;
21
+ let totalInterest = 0;
22
+ let totalPrincipal = 0;
23
+ for (let i = 1; i <= termMonths; i++) {
24
+ const openingBalance = round2(balance);
25
+ const isFinal = i === termMonths;
26
+ let interest;
27
+ let principalPortion;
28
+ let periodPayment;
29
+ if (isFinal) {
30
+ // Final period: close balance to exactly zero
31
+ interest = round2(openingBalance * monthlyRate);
32
+ principalPortion = openingBalance;
33
+ periodPayment = round2(principalPortion + interest);
34
+ }
35
+ else {
36
+ interest = round2(openingBalance * monthlyRate);
37
+ principalPortion = round2(payment - interest);
38
+ periodPayment = payment;
39
+ }
40
+ balance = round2(openingBalance - principalPortion);
41
+ totalInterest = round2(totalInterest + interest);
42
+ totalPrincipal = round2(totalPrincipal + principalPortion);
43
+ const date = startDate ? addMonths(startDate, i) : null;
44
+ const journal = {
45
+ description: `Loan payment — Month ${i} of ${termMonths}`,
46
+ lines: [
47
+ { account: 'Loan Payable', debit: principalPortion, credit: 0 },
48
+ { account: 'Interest Expense', debit: interest, credit: 0 },
49
+ { account: 'Cash / Bank Account', debit: 0, credit: periodPayment },
50
+ ],
51
+ };
52
+ schedule.push({
53
+ period: i,
54
+ date,
55
+ openingBalance,
56
+ payment: periodPayment,
57
+ interest,
58
+ principal: principalPortion,
59
+ closingBalance: balance,
60
+ journal,
61
+ });
62
+ }
63
+ // Build blueprint for agent execution
64
+ let blueprint = null;
65
+ if (startDate) {
66
+ const steps = [
67
+ journalStep(1, 'Loan disbursement', startDate, [
68
+ { account: 'Cash / Bank Account', debit: principal, credit: 0 },
69
+ { account: 'Loan Payable', debit: 0, credit: principal },
70
+ ]),
71
+ ...schedule.map((row, idx) => journalStep(idx + 2, row.journal.description, row.date, row.journal.lines)),
72
+ ];
73
+ blueprint = {
74
+ capsuleType: 'Loan Repayment',
75
+ capsuleName: `Bank Loan — ${fmtCapsuleAmount(principal, currency)} — ${annualRate}% — ${termMonths} months`,
76
+ tags: ['Bank Loan'],
77
+ customFields: { 'Loan Reference': null },
78
+ steps,
79
+ };
80
+ }
81
+ return {
82
+ type: 'loan',
83
+ currency: currency ?? null,
84
+ inputs: {
85
+ principal,
86
+ annualRate,
87
+ termMonths,
88
+ startDate: startDate ?? null,
89
+ },
90
+ monthlyPayment: payment,
91
+ totalPayments: round2(totalInterest + totalPrincipal),
92
+ totalInterest,
93
+ totalPrincipal,
94
+ schedule,
95
+ blueprint,
96
+ };
97
+ }