@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.
- package/dist_ts/skr.api.js +7 -3
- package/dist_ts/skr.classes.journalentry.d.ts +1 -0
- package/dist_ts/skr.classes.journalentry.js +55 -15
- package/dist_ts/skr.classes.ledger.d.ts +4 -0
- package/dist_ts/skr.classes.ledger.js +72 -1
- package/dist_ts/skr.classes.reports.js +56 -22
- package/package.json +3 -2
- package/readme.md +278 -140
- package/ts/skr.api.ts +4 -2
- package/ts/skr.classes.journalentry.ts +63 -14
- package/ts/skr.classes.ledger.ts +85 -0
- package/ts/skr.classes.reports.ts +64 -21
|
@@ -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
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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:
|
|
242
|
-
creditAccount:
|
|
243
|
-
amount: amount,
|
|
244
|
-
description: `${this.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:
|
|
292
|
+
costCenter: d.line.costCenter || c.line.costCenter,
|
|
248
293
|
});
|
|
249
294
|
transactions.push(transaction);
|
|
250
|
-
|
|
251
|
-
// Reduce amounts
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
package/ts/skr.classes.ledger.ts
CHANGED
|
@@ -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:
|
|
125
|
+
amount: balance, // Keep the sign for correct calculation
|
|
126
126
|
};
|
|
127
127
|
|
|
128
128
|
revenueEntries.push(entry);
|
|
129
|
-
totalRevenue +=
|
|
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:
|
|
141
|
+
amount: balance, // Keep the sign - negative balance reduces expenses
|
|
142
142
|
};
|
|
143
143
|
|
|
144
144
|
expenseEntries.push(entry);
|
|
145
|
-
totalExpenses +=
|
|
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
|
|
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
|
|
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:
|
|
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 +=
|
|
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:
|
|
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 +=
|
|
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:
|
|
272
|
+
amount: balance, // Keep the sign for display
|
|
272
273
|
};
|
|
273
274
|
|
|
274
275
|
equityEntries.push(entry);
|
|
275
|
-
totalEquity +=
|
|
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
|
-
|
|
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:
|
|
290
|
+
amount: incomeStatement.netIncome, // Keep the sign
|
|
286
291
|
});
|
|
287
|
-
totalEquity +=
|
|
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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
}
|