@fin.cx/skr 1.0.0 → 1.1.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.
@@ -96,6 +96,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
96
96
  this.postedAt = null;
97
97
  this.createdBy = 'system';
98
98
 
99
+ // Normalize any negative amounts to the correct side
100
+ this.sanitizeLines();
99
101
  // Calculate totals
100
102
  this.calculateTotals();
101
103
  }
@@ -107,6 +109,36 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
107
109
  return `JE-${timestamp}-${random}`;
108
110
  }
109
111
 
112
+ private sanitizeLines(): void {
113
+ for (const line of this.lines) {
114
+ // Check if both debit and credit are set (not allowed)
115
+ if (line.debit !== undefined && line.debit !== 0 &&
116
+ line.credit !== undefined && line.credit !== 0) {
117
+ throw new Error('A line cannot have both debit and credit amounts');
118
+ }
119
+
120
+ // Handle negative debit - convert to positive credit
121
+ if (line.debit !== undefined && line.debit < 0) {
122
+ line.credit = Math.abs(line.debit);
123
+ delete (line as any).debit;
124
+ }
125
+
126
+ // Handle negative credit - convert to positive debit
127
+ if (line.credit !== undefined && line.credit < 0) {
128
+ line.debit = Math.abs(line.credit);
129
+ delete (line as any).credit;
130
+ }
131
+
132
+ // Check that at least one side has a positive value
133
+ const hasDebit = line.debit !== undefined && line.debit > 0;
134
+ const hasCredit = line.credit !== undefined && line.credit > 0;
135
+
136
+ if (!hasDebit && !hasCredit) {
137
+ throw new Error('Either debit or credit must be a positive number');
138
+ }
139
+ }
140
+ }
141
+
110
142
  private calculateTotals(): void {
111
143
  this.totalDebits = 0;
112
144
  this.totalCredits = 0;
@@ -204,6 +236,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
204
236
  throw new Error('Journal entry is already posted');
205
237
  }
206
238
 
239
+ // Normalize any negative amounts to the correct side
240
+ this.sanitizeLines();
207
241
  // Validate before posting
208
242
  await this.validate();
209
243
 
@@ -230,28 +264,41 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
230
264
  transactions.push(transaction);
231
265
  } else {
232
266
  // Complex entry: multiple debits and/or credits
233
- // Create transactions to balance the entry
234
- for (const debitLine of debitLines) {
235
- for (const creditLine of creditLines) {
236
- const amount = Math.min(debitLine.debit || 0, creditLine.credit || 0);
267
+ // Build working queues with remaining amounts (don't mutate original lines)
268
+ const debitQueue = debitLines.map(l => ({
269
+ line: l,
270
+ remaining: l.debit || 0
271
+ }));
272
+
273
+ const creditQueue = creditLines.map(l => ({
274
+ line: l,
275
+ remaining: l.credit || 0
276
+ }));
237
277
 
238
- if (amount > 0) {
278
+ // Create transactions to balance the entry
279
+ for (const d of debitQueue) {
280
+ for (const c of creditQueue) {
281
+ const amount = Math.min(d.remaining, c.remaining);
282
+
283
+ if (amount > 0.0000001) { // small epsilon to avoid float artifacts
239
284
  const transaction = await Transaction.createTransaction({
240
285
  date: this.date,
241
- debitAccount: debitLine.accountNumber,
242
- creditAccount: creditLine.accountNumber,
243
- amount: amount,
244
- description: `${this.description} - ${debitLine.description || creditLine.description || ''}`,
286
+ debitAccount: d.line.accountNumber,
287
+ creditAccount: c.line.accountNumber,
288
+ amount: Math.round(amount * 100) / 100, // round to 2 decimals
289
+ description: `${this.description} - ${d.line.description || c.line.description || ''}`,
245
290
  reference: this.reference,
246
291
  skrType: this.skrType,
247
- costCenter: debitLine.costCenter || creditLine.costCenter,
292
+ costCenter: d.line.costCenter || c.line.costCenter,
248
293
  });
249
294
  transactions.push(transaction);
250
-
251
- // Reduce amounts for tracking
252
- if (debitLine.debit) debitLine.debit -= amount;
253
- if (creditLine.credit) creditLine.credit -= amount;
295
+
296
+ // Reduce remaining amounts in working copies (not original lines)
297
+ d.remaining -= amount;
298
+ c.remaining -= amount;
254
299
  }
300
+
301
+ if (d.remaining <= 0.0000001) break;
255
302
  }
256
303
  }
257
304
  }
@@ -299,6 +346,8 @@ export class JournalEntry extends SmartDataDbDoc<JournalEntry, JournalEntry> {
299
346
  }
300
347
 
301
348
  public async beforeSave(): Promise<void> {
349
+ // Normalize any negative amounts to the correct side
350
+ this.sanitizeLines();
302
351
  // Recalculate totals before saving
303
352
  this.calculateTotals();
304
353
 
@@ -9,6 +9,14 @@ import type {
9
9
  IJournalEntryLine,
10
10
  IAccountBalance,
11
11
  } from './skr.types.js';
12
+ import { SKR03_ACCOUNTS } from './skr03.data.js';
13
+ import { SKR04_ACCOUNTS } from './skr04.data.js';
14
+
15
+ // Module-level Maps for O(1) SKR standard lookups
16
+ const STANDARD_SKR_MAP = {
17
+ SKR03: new Map(SKR03_ACCOUNTS.map(a => [a.accountNumber, a])),
18
+ SKR04: new Map(SKR04_ACCOUNTS.map(a => [a.accountNumber, a])),
19
+ };
12
20
 
13
21
  export class Ledger {
14
22
  private logger: plugins.smartlog.Smartlog;
@@ -81,6 +89,12 @@ export class Ledger {
81
89
  const accountNumbers = journalData.lines.map((line) => line.accountNumber);
82
90
  await this.validateAccounts(accountNumbers);
83
91
 
92
+ // Validate against SKR standard (warnings only by default)
93
+ await this.validateAccountsAgainstSKR(journalData.lines, {
94
+ strict: false, // Start with warnings only
95
+ warnOnNameMismatch: false // Names vary, don't spam logs
96
+ });
97
+
84
98
  // Validate journal entry is balanced
85
99
  this.validateJournalBalance(journalData.lines);
86
100
 
@@ -139,6 +153,77 @@ export class Ledger {
139
153
  }
140
154
  }
141
155
 
156
+ /**
157
+ * Validate accounts against SKR standard data
158
+ */
159
+ private async validateAccountsAgainstSKR(
160
+ lines: IJournalEntryLine[],
161
+ options?: { strict?: boolean; warnOnNameMismatch?: boolean }
162
+ ): Promise<void> {
163
+ const { strict = false, warnOnNameMismatch = false } = options || {};
164
+ const skrMap = STANDARD_SKR_MAP[this.skrType];
165
+
166
+ if (!skrMap) {
167
+ this.logger.log('warn', `No SKR standard map available for ${this.skrType}`);
168
+ return;
169
+ }
170
+
171
+ const uniqueAccountNumbers = [...new Set(lines.map(line => line.accountNumber))];
172
+
173
+ for (const accountNumber of uniqueAccountNumbers) {
174
+ const standardAccount = skrMap.get(accountNumber);
175
+
176
+ if (!standardAccount) {
177
+ // Special case: SKR04 class 8 is designated for custom accounts ("frei")
178
+ if (this.skrType === 'SKR04' && accountNumber.startsWith('8')) {
179
+ this.logger.log('debug', `Account ${accountNumber} is in SKR04 class 8 (custom accounts allowed)`);
180
+ continue;
181
+ }
182
+
183
+ const message = `Account ${accountNumber} is not a standard ${this.skrType} account`;
184
+ if (strict) {
185
+ throw new Error(message);
186
+ } else {
187
+ this.logger.log('warn', message);
188
+ }
189
+ continue;
190
+ }
191
+
192
+ // Get actual account from database to compare
193
+ const dbAccount = await Account.getAccountByNumber(accountNumber, this.skrType);
194
+ if (!dbAccount) {
195
+ // Account doesn't exist in DB, will be caught by validateAccounts()
196
+ continue;
197
+ }
198
+
199
+ // Validate type and class match SKR standard
200
+ if (dbAccount.accountType !== standardAccount.accountType) {
201
+ const message = `Account ${accountNumber} type mismatch: expected '${standardAccount.accountType}', got '${dbAccount.accountType}'`;
202
+ if (strict) {
203
+ throw new Error(message);
204
+ } else {
205
+ this.logger.log('warn', message);
206
+ }
207
+ }
208
+
209
+ if (dbAccount.accountClass !== standardAccount.accountClass) {
210
+ const message = `Account ${accountNumber} class mismatch: expected ${standardAccount.accountClass}, got ${dbAccount.accountClass}`;
211
+ if (strict) {
212
+ throw new Error(message);
213
+ } else {
214
+ this.logger.log('warn', message);
215
+ }
216
+ }
217
+
218
+ // Warn on name mismatch (common and acceptable in practice)
219
+ if (warnOnNameMismatch && dbAccount.accountName !== standardAccount.accountName) {
220
+ this.logger.log('info',
221
+ `Account ${accountNumber} name differs from SKR standard: '${dbAccount.accountName}' vs '${standardAccount.accountName}'`
222
+ );
223
+ }
224
+ }
225
+ }
226
+
142
227
  /**
143
228
  * Reverse a transaction
144
229
  */
@@ -122,11 +122,11 @@ export class Reports {
122
122
  const entry: IIncomeStatementEntry = {
123
123
  accountNumber: account.accountNumber,
124
124
  accountName: account.accountName,
125
- amount: Math.abs(balance),
125
+ amount: balance, // Keep the sign for correct calculation
126
126
  };
127
127
 
128
128
  revenueEntries.push(entry);
129
- totalRevenue += Math.abs(balance);
129
+ totalRevenue += balance; // Revenue accounts normally have credit balance (positive)
130
130
  }
131
131
  }
132
132
 
@@ -138,23 +138,24 @@ export class Reports {
138
138
  const entry: IIncomeStatementEntry = {
139
139
  accountNumber: account.accountNumber,
140
140
  accountName: account.accountName,
141
- amount: Math.abs(balance),
141
+ amount: balance, // Keep the sign - negative balance reduces expenses
142
142
  };
143
143
 
144
144
  expenseEntries.push(entry);
145
- totalExpenses += Math.abs(balance);
145
+ totalExpenses += balance; // Expense accounts normally have debit balance (positive)
146
+ // But credit balances (negative) reduce total expenses
146
147
  }
147
148
  }
148
149
 
149
- // Calculate percentages
150
+ // Calculate percentages using absolute values to avoid negative percentages
150
151
  revenueEntries.forEach((entry) => {
151
152
  entry.percentage =
152
- totalRevenue > 0 ? (entry.amount / totalRevenue) * 100 : 0;
153
+ totalRevenue !== 0 ? (Math.abs(entry.amount) / Math.abs(totalRevenue)) * 100 : 0;
153
154
  });
154
155
 
155
156
  expenseEntries.forEach((entry) => {
156
157
  entry.percentage =
157
- totalRevenue > 0 ? (entry.amount / totalRevenue) * 100 : 0;
158
+ totalRevenue !== 0 ? (Math.abs(entry.amount) / Math.abs(totalRevenue)) * 100 : 0;
158
159
  });
159
160
 
160
161
  // Sort entries by account number
@@ -214,7 +215,7 @@ export class Reports {
214
215
  const entry: IBalanceSheetEntry = {
215
216
  accountNumber: account.accountNumber,
216
217
  accountName: account.accountName,
217
- amount: Math.abs(balance),
218
+ amount: balance, // Keep the sign for display
218
219
  };
219
220
 
220
221
  // Classify as current or fixed based on account class
@@ -224,7 +225,7 @@ export class Reports {
224
225
  fixedAssets.push(entry);
225
226
  }
226
227
 
227
- totalAssets += Math.abs(balance);
228
+ totalAssets += balance; // Add with sign to get correct total
228
229
  }
229
230
  }
230
231
 
@@ -240,7 +241,7 @@ export class Reports {
240
241
  const entry: IBalanceSheetEntry = {
241
242
  accountNumber: account.accountNumber,
242
243
  accountName: account.accountName,
243
- amount: Math.abs(balance),
244
+ amount: balance, // Keep the sign for display
244
245
  };
245
246
 
246
247
  // Classify as current or long-term based on account number
@@ -253,7 +254,7 @@ export class Reports {
253
254
  longTermLiabilities.push(entry);
254
255
  }
255
256
 
256
- totalLiabilities += Math.abs(balance);
257
+ totalLiabilities += balance; // Add with sign to get correct total
257
258
  }
258
259
  }
259
260
 
@@ -268,23 +269,27 @@ export class Reports {
268
269
  const entry: IBalanceSheetEntry = {
269
270
  accountNumber: account.accountNumber,
270
271
  accountName: account.accountName,
271
- amount: Math.abs(balance),
272
+ amount: balance, // Keep the sign for display
272
273
  };
273
274
 
274
275
  equityEntries.push(entry);
275
- totalEquity += Math.abs(balance);
276
+ totalEquity += balance; // Add with sign to get correct total
276
277
  }
277
278
  }
278
279
 
279
- // Add current year profit/loss
280
+ // Add current year profit/loss only if accounts haven't been closed
281
+ // Check if revenue/expense accounts have non-zero balances (indicates not closed)
280
282
  const incomeStatement = await this.getIncomeStatement(params);
281
- if (incomeStatement.netIncome !== 0) {
283
+
284
+ // Only add current year profit/loss if we have unclosed revenue/expense accounts
285
+ // (i.e., the income statement shows non-zero revenue or expenses)
286
+ if (incomeStatement.netIncome !== 0 && (incomeStatement.totalRevenue !== 0 || incomeStatement.totalExpenses !== 0)) {
282
287
  equityEntries.push({
283
288
  accountNumber: '9999',
284
289
  accountName: 'Current Year Profit/Loss',
285
- amount: Math.abs(incomeStatement.netIncome),
290
+ amount: incomeStatement.netIncome, // Keep the sign
286
291
  });
287
- totalEquity += Math.abs(incomeStatement.netIncome);
292
+ totalEquity += incomeStatement.netIncome; // Add with sign
288
293
  }
289
294
 
290
295
  // Sort entries
@@ -344,9 +349,28 @@ export class Reports {
344
349
 
345
350
  // Apply date filter if provided
346
351
  if (params?.dateFrom || params?.dateTo) {
352
+ // Normalize dates for inclusive comparison
353
+ const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null;
354
+ const dateTo = params.dateTo ? new Date(params.dateTo) : null;
355
+
356
+ // Set dateFrom to start of day (00:00:00.000)
357
+ if (dateFrom) {
358
+ dateFrom.setHours(0, 0, 0, 0);
359
+ }
360
+
361
+ // Set dateTo to end of day (23:59:59.999) for inclusive comparison
362
+ if (dateTo) {
363
+ dateTo.setHours(23, 59, 59, 999);
364
+ }
365
+
347
366
  transactions = transactions.filter((transaction) => {
348
- if (params.dateFrom && transaction.date < params.dateFrom) return false;
349
- if (params.dateTo && transaction.date > params.dateTo) return false;
367
+ const txDate = transaction.date instanceof Date
368
+ ? transaction.date
369
+ : new Date(transaction.date);
370
+ const txTime = txDate.getTime();
371
+
372
+ if (dateFrom && txTime < dateFrom.getTime()) return false;
373
+ if (dateTo && txTime > dateTo.getTime()) return false;
350
374
  return true;
351
375
  });
352
376
  }
@@ -453,9 +477,28 @@ export class Reports {
453
477
 
454
478
  // Apply date filter
455
479
  if (params?.dateFrom || params?.dateTo) {
480
+ // Normalize dates for inclusive comparison
481
+ const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null;
482
+ const dateTo = params.dateTo ? new Date(params.dateTo) : null;
483
+
484
+ // Set dateFrom to start of day (00:00:00.000)
485
+ if (dateFrom) {
486
+ dateFrom.setHours(0, 0, 0, 0);
487
+ }
488
+
489
+ // Set dateTo to end of day (23:59:59.999) for inclusive comparison
490
+ if (dateTo) {
491
+ dateTo.setHours(23, 59, 59, 999);
492
+ }
493
+
456
494
  transactions = transactions.filter((transaction) => {
457
- if (params.dateFrom && transaction.date < params.dateFrom) return false;
458
- if (params.dateTo && transaction.date > params.dateTo) return false;
495
+ const txDate = transaction.date instanceof Date
496
+ ? transaction.date
497
+ : new Date(transaction.date);
498
+ const txTime = txDate.getTime();
499
+
500
+ if (dateFrom && txTime < dateFrom.getTime()) return false;
501
+ if (dateTo && txTime > dateTo.getTime()) return false;
459
502
  return true;
460
503
  });
461
504
  }