@fin.cx/skr 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist_ts/index.d.ts +10 -0
  2. package/dist_ts/index.js +11 -0
  3. package/dist_ts/plugins.d.ts +5 -0
  4. package/dist_ts/plugins.js +7 -0
  5. package/dist_ts/skr.api.d.ts +181 -0
  6. package/dist_ts/skr.api.js +402 -0
  7. package/dist_ts/skr.classes.account.d.ts +34 -0
  8. package/dist_ts/skr.classes.account.js +224 -0
  9. package/dist_ts/skr.classes.chartofaccounts.d.ts +99 -0
  10. package/dist_ts/skr.classes.chartofaccounts.js +377 -0
  11. package/dist_ts/skr.classes.journalentry.d.ts +33 -0
  12. package/dist_ts/skr.classes.journalentry.js +296 -0
  13. package/dist_ts/skr.classes.ledger.d.ts +61 -0
  14. package/dist_ts/skr.classes.ledger.js +363 -0
  15. package/dist_ts/skr.classes.reports.d.ts +55 -0
  16. package/dist_ts/skr.classes.reports.js +514 -0
  17. package/dist_ts/skr.classes.transaction.d.ts +37 -0
  18. package/dist_ts/skr.classes.transaction.js +263 -0
  19. package/dist_ts/skr.database.d.ts +5 -0
  20. package/dist_ts/skr.database.js +28 -0
  21. package/dist_ts/skr.types.d.ts +126 -0
  22. package/dist_ts/skr.types.js +2 -0
  23. package/dist_ts/skr03.data.d.ts +18 -0
  24. package/dist_ts/skr03.data.js +890 -0
  25. package/dist_ts/skr04.data.d.ts +18 -0
  26. package/dist_ts/skr04.data.js +912 -0
  27. package/npmextra.json +17 -0
  28. package/package.json +61 -0
  29. package/readme.hints.md +3 -0
  30. package/readme.md +409 -0
  31. package/readme.plan.md +243 -0
  32. package/ts/index.ts +10 -0
  33. package/ts/plugins.ts +7 -0
  34. package/ts/skr.api.ts +533 -0
  35. package/ts/skr.classes.account.ts +238 -0
  36. package/ts/skr.classes.chartofaccounts.ts +508 -0
  37. package/ts/skr.classes.journalentry.ts +318 -0
  38. package/ts/skr.classes.ledger.ts +528 -0
  39. package/ts/skr.classes.reports.ts +721 -0
  40. package/ts/skr.classes.transaction.ts +300 -0
  41. package/ts/skr.database.ts +39 -0
  42. package/ts/skr.types.ts +154 -0
  43. package/ts/skr03.data.ts +901 -0
  44. package/ts/skr04.data.ts +923 -0
@@ -0,0 +1,528 @@
1
+ import * as plugins from './plugins.js';
2
+ import { Account } from './skr.classes.account.js';
3
+ import { Transaction } from './skr.classes.transaction.js';
4
+ import { JournalEntry } from './skr.classes.journalentry.js';
5
+ import type {
6
+ TSKRType,
7
+ ITransactionData,
8
+ IJournalEntry,
9
+ IJournalEntryLine,
10
+ IAccountBalance,
11
+ } from './skr.types.js';
12
+
13
+ export class Ledger {
14
+ private logger: plugins.smartlog.Smartlog;
15
+
16
+ constructor(private skrType: TSKRType) {
17
+ this.logger = new plugins.smartlog.Smartlog({
18
+ logContext: {
19
+ company: 'fin.cx',
20
+ companyunit: 'skr',
21
+ containerName: 'Ledger',
22
+ environment: 'local',
23
+ runtime: 'node',
24
+ zone: 'local',
25
+ },
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Post a transaction with validation
31
+ */
32
+ public async postTransaction(
33
+ transactionData: ITransactionData,
34
+ ): Promise<Transaction> {
35
+ this.logger.log(
36
+ 'info',
37
+ `Posting transaction: ${transactionData.description}`,
38
+ );
39
+
40
+ // Ensure SKR type matches
41
+ const fullTransactionData: ITransactionData = {
42
+ ...transactionData,
43
+ skrType: this.skrType,
44
+ };
45
+
46
+ // Validate accounts exist
47
+ await this.validateAccounts([
48
+ transactionData.debitAccount,
49
+ transactionData.creditAccount,
50
+ ]);
51
+
52
+ // Create and post transaction
53
+ const transaction =
54
+ await Transaction.createTransaction(fullTransactionData);
55
+
56
+ this.logger.log(
57
+ 'info',
58
+ `Transaction ${transaction.transactionNumber} posted successfully`,
59
+ );
60
+ return transaction;
61
+ }
62
+
63
+ /**
64
+ * Post a journal entry with validation
65
+ */
66
+ public async postJournalEntry(
67
+ journalData: IJournalEntry,
68
+ ): Promise<JournalEntry> {
69
+ this.logger.log(
70
+ 'info',
71
+ `Posting journal entry: ${journalData.description}`,
72
+ );
73
+
74
+ // Ensure SKR type matches
75
+ const fullJournalData: IJournalEntry = {
76
+ ...journalData,
77
+ skrType: this.skrType,
78
+ };
79
+
80
+ // Validate all accounts exist
81
+ const accountNumbers = journalData.lines.map((line) => line.accountNumber);
82
+ await this.validateAccounts(accountNumbers);
83
+
84
+ // Validate journal entry is balanced
85
+ this.validateJournalBalance(journalData.lines);
86
+
87
+ // Create and post journal entry
88
+ const journalEntry = await JournalEntry.createJournalEntry(fullJournalData);
89
+ await journalEntry.post();
90
+
91
+ this.logger.log(
92
+ 'info',
93
+ `Journal entry ${journalEntry.journalNumber} posted successfully`,
94
+ );
95
+ return journalEntry;
96
+ }
97
+
98
+ /**
99
+ * Validate that accounts exist and are active
100
+ */
101
+ private async validateAccounts(accountNumbers: string[]): Promise<void> {
102
+ const uniqueAccountNumbers = [...new Set(accountNumbers)];
103
+
104
+ for (const accountNumber of uniqueAccountNumbers) {
105
+ const account = await Account.getAccountByNumber(
106
+ accountNumber,
107
+ this.skrType,
108
+ );
109
+
110
+ if (!account) {
111
+ throw new Error(
112
+ `Account ${accountNumber} not found for ${this.skrType}`,
113
+ );
114
+ }
115
+
116
+ if (!account.isActive) {
117
+ throw new Error(`Account ${accountNumber} is not active`);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Validate journal entry balance
124
+ */
125
+ private validateJournalBalance(lines: IJournalEntryLine[]): void {
126
+ let totalDebits = 0;
127
+ let totalCredits = 0;
128
+
129
+ for (const line of lines) {
130
+ if (line.debit) totalDebits += line.debit;
131
+ if (line.credit) totalCredits += line.credit;
132
+ }
133
+
134
+ const difference = Math.abs(totalDebits - totalCredits);
135
+ if (difference >= 0.01) {
136
+ throw new Error(
137
+ `Journal entry is not balanced. Debits: ${totalDebits}, Credits: ${totalCredits}`,
138
+ );
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Reverse a transaction
144
+ */
145
+ public async reverseTransaction(transactionId: string): Promise<Transaction> {
146
+ this.logger.log('info', `Reversing transaction: ${transactionId}`);
147
+
148
+ const transaction = await Transaction.getTransactionById(transactionId);
149
+ if (!transaction) {
150
+ throw new Error(`Transaction ${transactionId} not found`);
151
+ }
152
+
153
+ if (transaction.skrType !== this.skrType) {
154
+ throw new Error(
155
+ `Transaction ${transactionId} belongs to different SKR type`,
156
+ );
157
+ }
158
+
159
+ const reversalTransaction = await transaction.reverseTransaction();
160
+
161
+ this.logger.log(
162
+ 'info',
163
+ `Transaction reversed: ${reversalTransaction.transactionNumber}`,
164
+ );
165
+ return reversalTransaction;
166
+ }
167
+
168
+ /**
169
+ * Reverse a journal entry
170
+ */
171
+ public async reverseJournalEntry(journalId: string): Promise<JournalEntry> {
172
+ this.logger.log('info', `Reversing journal entry: ${journalId}`);
173
+
174
+ const journalEntry = await JournalEntry.getInstance({ id: journalId });
175
+ if (!journalEntry) {
176
+ throw new Error(`Journal entry ${journalId} not found`);
177
+ }
178
+
179
+ if (journalEntry.skrType !== this.skrType) {
180
+ throw new Error(
181
+ `Journal entry ${journalId} belongs to different SKR type`,
182
+ );
183
+ }
184
+
185
+ const reversalEntry = await journalEntry.reverse();
186
+
187
+ this.logger.log(
188
+ 'info',
189
+ `Journal entry reversed: ${reversalEntry.journalNumber}`,
190
+ );
191
+ return reversalEntry;
192
+ }
193
+
194
+ /**
195
+ * Get account history (all transactions for an account)
196
+ */
197
+ public async getAccountHistory(
198
+ accountNumber: string,
199
+ dateFrom?: Date,
200
+ dateTo?: Date,
201
+ ): Promise<Transaction[]> {
202
+ const account = await Account.getAccountByNumber(
203
+ accountNumber,
204
+ this.skrType,
205
+ );
206
+ if (!account) {
207
+ throw new Error(`Account ${accountNumber} not found`);
208
+ }
209
+
210
+ let transactions = await Transaction.getTransactionsByAccount(
211
+ accountNumber,
212
+ this.skrType,
213
+ );
214
+
215
+ // Apply date filter if provided
216
+ if (dateFrom || dateTo) {
217
+ transactions = transactions.filter((transaction) => {
218
+ if (dateFrom && transaction.date < dateFrom) return false;
219
+ if (dateTo && transaction.date > dateTo) return false;
220
+ return true;
221
+ });
222
+ }
223
+
224
+ // Sort by date
225
+ transactions.sort((a, b) => a.date.getTime() - b.date.getTime());
226
+
227
+ return transactions;
228
+ }
229
+
230
+ /**
231
+ * Get account balance at a specific date
232
+ */
233
+ public async getAccountBalance(
234
+ accountNumber: string,
235
+ asOfDate?: Date,
236
+ ): Promise<IAccountBalance> {
237
+ const account = await Account.getAccountByNumber(
238
+ accountNumber,
239
+ this.skrType,
240
+ );
241
+ if (!account) {
242
+ throw new Error(`Account ${accountNumber} not found`);
243
+ }
244
+
245
+ let transactions = await Transaction.getTransactionsByAccount(
246
+ accountNumber,
247
+ this.skrType,
248
+ );
249
+
250
+ // Filter transactions up to the specified date
251
+ if (asOfDate) {
252
+ transactions = transactions.filter((t) => t.date <= asOfDate);
253
+ }
254
+
255
+ // Calculate balance
256
+ let debitTotal = 0;
257
+ let creditTotal = 0;
258
+
259
+ for (const transaction of transactions) {
260
+ if (transaction.debitAccount === accountNumber) {
261
+ debitTotal += transaction.amount;
262
+ }
263
+ if (transaction.creditAccount === accountNumber) {
264
+ creditTotal += transaction.amount;
265
+ }
266
+ }
267
+
268
+ // Calculate net balance based on account type
269
+ let balance: number;
270
+ switch (account.accountType) {
271
+ case 'asset':
272
+ case 'expense':
273
+ // Normal debit accounts
274
+ balance = debitTotal - creditTotal;
275
+ break;
276
+ case 'liability':
277
+ case 'equity':
278
+ case 'revenue':
279
+ // Normal credit accounts
280
+ balance = creditTotal - debitTotal;
281
+ break;
282
+ }
283
+
284
+ return {
285
+ accountNumber,
286
+ debitTotal,
287
+ creditTotal,
288
+ balance,
289
+ lastUpdated: new Date(),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Close accounting period (create closing entries)
295
+ */
296
+ public async closeAccountingPeriod(
297
+ period: string, // Format: YYYY-MM
298
+ closingAccountNumber: string = '9400', // Default P&L account
299
+ ): Promise<JournalEntry[]> {
300
+ this.logger.log('info', `Closing accounting period: ${period}`);
301
+
302
+ const closingEntries: JournalEntry[] = [];
303
+
304
+ // Get all revenue and expense accounts
305
+ const revenueAccounts = await Account.getAccountsByType(
306
+ 'revenue',
307
+ this.skrType,
308
+ );
309
+ const expenseAccounts = await Account.getAccountsByType(
310
+ 'expense',
311
+ this.skrType,
312
+ );
313
+
314
+ // Calculate totals for each account in the period
315
+ const periodTransactions = await Transaction.getTransactionsByPeriod(
316
+ period,
317
+ this.skrType,
318
+ );
319
+
320
+ // Create closing entry for revenue accounts
321
+ const revenueLines: IJournalEntryLine[] = [];
322
+ let totalRevenue = 0;
323
+
324
+ for (const account of revenueAccounts) {
325
+ const balance = await this.getAccountBalanceForPeriod(
326
+ account.accountNumber,
327
+ periodTransactions,
328
+ );
329
+
330
+ if (balance !== 0) {
331
+ // Revenue accounts have credit balance, so debit to close
332
+ revenueLines.push({
333
+ accountNumber: account.accountNumber,
334
+ debit: Math.abs(balance),
335
+ description: `Closing ${account.accountName}`,
336
+ });
337
+ totalRevenue += Math.abs(balance);
338
+ }
339
+ }
340
+
341
+ if (totalRevenue > 0) {
342
+ // Credit the closing account
343
+ revenueLines.push({
344
+ accountNumber: closingAccountNumber,
345
+ credit: totalRevenue,
346
+ description: 'Revenue closing to P&L',
347
+ });
348
+
349
+ const revenueClosingEntry = await this.postJournalEntry({
350
+ date: new Date(),
351
+ description: `Closing revenue accounts for period ${period}`,
352
+ reference: `CLOSE-REV-${period}`,
353
+ lines: revenueLines,
354
+ skrType: this.skrType,
355
+ });
356
+
357
+ closingEntries.push(revenueClosingEntry);
358
+ }
359
+
360
+ // Create closing entry for expense accounts
361
+ const expenseLines: IJournalEntryLine[] = [];
362
+ let totalExpense = 0;
363
+
364
+ for (const account of expenseAccounts) {
365
+ const balance = await this.getAccountBalanceForPeriod(
366
+ account.accountNumber,
367
+ periodTransactions,
368
+ );
369
+
370
+ if (balance !== 0) {
371
+ // Expense accounts have debit balance, so credit to close
372
+ expenseLines.push({
373
+ accountNumber: account.accountNumber,
374
+ credit: Math.abs(balance),
375
+ description: `Closing ${account.accountName}`,
376
+ });
377
+ totalExpense += Math.abs(balance);
378
+ }
379
+ }
380
+
381
+ if (totalExpense > 0) {
382
+ // Debit the closing account
383
+ expenseLines.push({
384
+ accountNumber: closingAccountNumber,
385
+ debit: totalExpense,
386
+ description: 'Expense closing to P&L',
387
+ });
388
+
389
+ const expenseClosingEntry = await this.postJournalEntry({
390
+ date: new Date(),
391
+ description: `Closing expense accounts for period ${period}`,
392
+ reference: `CLOSE-EXP-${period}`,
393
+ lines: expenseLines,
394
+ skrType: this.skrType,
395
+ });
396
+
397
+ closingEntries.push(expenseClosingEntry);
398
+ }
399
+
400
+ this.logger.log(
401
+ 'info',
402
+ `Period ${period} closed with ${closingEntries.length} entries`,
403
+ );
404
+ return closingEntries;
405
+ }
406
+
407
+ /**
408
+ * Calculate account balance for a specific set of transactions
409
+ */
410
+ private async getAccountBalanceForPeriod(
411
+ accountNumber: string,
412
+ transactions: Transaction[],
413
+ ): Promise<number> {
414
+ const account = await Account.getAccountByNumber(
415
+ accountNumber,
416
+ this.skrType,
417
+ );
418
+ if (!account) return 0;
419
+
420
+ let debitTotal = 0;
421
+ let creditTotal = 0;
422
+
423
+ for (const transaction of transactions) {
424
+ if (transaction.debitAccount === accountNumber) {
425
+ debitTotal += transaction.amount;
426
+ }
427
+ if (transaction.creditAccount === accountNumber) {
428
+ creditTotal += transaction.amount;
429
+ }
430
+ }
431
+
432
+ // Calculate net balance based on account type
433
+ switch (account.accountType) {
434
+ case 'asset':
435
+ case 'expense':
436
+ return debitTotal - creditTotal;
437
+ case 'liability':
438
+ case 'equity':
439
+ case 'revenue':
440
+ return creditTotal - debitTotal;
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Validate double-entry rules
446
+ */
447
+ public validateDoubleEntry(
448
+ debitAmount: number,
449
+ creditAmount: number,
450
+ ): boolean {
451
+ return Math.abs(debitAmount - creditAmount) < 0.01;
452
+ }
453
+
454
+ /**
455
+ * Get unbalanced transactions (for audit)
456
+ */
457
+ public async getUnbalancedTransactions(): Promise<Transaction[]> {
458
+ // In a proper double-entry system, all posted transactions should be balanced
459
+ // This method is mainly for audit purposes
460
+ const allTransactions = await Transaction.getInstances({
461
+ skrType: this.skrType,
462
+ status: 'posted',
463
+ });
464
+
465
+ // Group transactions by journal entry if they have one
466
+ const unbalanced: Transaction[] = [];
467
+
468
+ // Since our system ensures balance at posting time,
469
+ // this should typically return an empty array
470
+ // But we include it for completeness and audit purposes
471
+
472
+ return unbalanced;
473
+ }
474
+
475
+ /**
476
+ * Recalculate all account balances
477
+ */
478
+ public async recalculateAllBalances(): Promise<void> {
479
+ this.logger.log('info', 'Recalculating all account balances');
480
+
481
+ // Get all accounts
482
+ const accounts = await Account.getInstances({ skrType: this.skrType });
483
+
484
+ for (const account of accounts) {
485
+ // Reset balances
486
+ account.debitTotal = 0;
487
+ account.creditTotal = 0;
488
+ account.balance = 0;
489
+
490
+ // Get all transactions for this account
491
+ const transactions = await Transaction.getTransactionsByAccount(
492
+ account.accountNumber,
493
+ this.skrType,
494
+ );
495
+
496
+ // Recalculate totals
497
+ for (const transaction of transactions) {
498
+ if (transaction.debitAccount === account.accountNumber) {
499
+ account.debitTotal += transaction.amount;
500
+ }
501
+ if (transaction.creditAccount === account.accountNumber) {
502
+ account.creditTotal += transaction.amount;
503
+ }
504
+ }
505
+
506
+ // Calculate balance based on account type
507
+ switch (account.accountType) {
508
+ case 'asset':
509
+ case 'expense':
510
+ account.balance = account.debitTotal - account.creditTotal;
511
+ break;
512
+ case 'liability':
513
+ case 'equity':
514
+ case 'revenue':
515
+ account.balance = account.creditTotal - account.debitTotal;
516
+ break;
517
+ }
518
+
519
+ account.updatedAt = new Date();
520
+ await account.save();
521
+ }
522
+
523
+ this.logger.log(
524
+ 'info',
525
+ `Recalculated balances for ${accounts.length} accounts`,
526
+ );
527
+ }
528
+ }