From 10ca6f29923580ae177ae5aa87e9c7ffb7434d19 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 10 Aug 2025 19:52:23 +0000 Subject: [PATCH] feat(tests): integrate qenv for dynamic configuration and enhance SKR API tests --- package.json | 3 +- pnpm-lock.yaml | 3 + test/helpers/setup.ts | 41 +++ test/test.jahresabschluss.ts | 520 +++++++++++++++++++++++++++++++++ test/test.skr03.ts | 8 +- test/test.skr04.ts | 8 +- test/test.transactions.ts | 8 +- ts/skr.api.ts | 6 +- ts/skr.classes.journalentry.ts | 77 ++++- ts/skr.classes.reports.ts | 46 ++- 10 files changed, 693 insertions(+), 27 deletions(-) create mode 100644 test/helpers/setup.ts create mode 100644 test/test.jahresabschluss.ts diff --git a/package.json b/package.json index 8e4db5c..6828b4e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ "devDependencies": { "@git.zone/tsbuild": "^2.6.4", "@git.zone/tsrun": "^1.3.3", - "@git.zone/tstest": "^2.3.2" + "@git.zone/tstest": "^2.3.2", + "@push.rocks/qenv": "^6.1.0" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b7883b..fac3404 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: '@git.zone/tstest': specifier: ^2.3.2 version: 2.3.2(@aws-sdk/credential-providers@3.864.0)(socks@2.8.6)(typescript@5.8.3) + '@push.rocks/qenv': + specifier: ^6.1.0 + version: 6.1.0 packages: diff --git a/test/helpers/setup.ts b/test/helpers/setup.ts new file mode 100644 index 0000000..e98e367 --- /dev/null +++ b/test/helpers/setup.ts @@ -0,0 +1,41 @@ +import * as qenv from '@push.rocks/qenv'; + +// Initialize qenv to load environment variables from .nogit folder +const testQenv = new qenv.Qenv('./', './.nogit/'); + +// Export configuration for MongoDB and S3 +export const getTestConfig = async () => { + // Try to get individual MongoDB components first + const mongoHost = await testQenv.getEnvVarOnDemand('MONGODB_HOST') || 'localhost'; + const mongoPort = await testQenv.getEnvVarOnDemand('MONGODB_PORT') || '27017'; + const mongoUser = await testQenv.getEnvVarOnDemand('MONGODB_USER'); + const mongoPass = await testQenv.getEnvVarOnDemand('MONGODB_PASS'); + const mongoDbName = await testQenv.getEnvVarOnDemand('MONGODB_NAME') || 'test_skr'; + + // Build MongoDB URL with authentication + let mongoDbUrl: string; + if (mongoUser && mongoPass) { + // Include authSource=admin for authentication + mongoDbUrl = `mongodb://${mongoUser}:${mongoPass}@${mongoHost}:${mongoPort}/?authSource=admin`; + } else { + mongoDbUrl = `mongodb://${mongoHost}:${mongoPort}`; + } + + // Get S3 configuration + const s3Host = await testQenv.getEnvVarOnDemand('S3_HOST'); + const s3Port = await testQenv.getEnvVarOnDemand('S3_PORT'); + const s3User = await testQenv.getEnvVarOnDemand('S3_USER'); + const s3Pass = await testQenv.getEnvVarOnDemand('S3_PASS'); + const s3Bucket = await testQenv.getEnvVarOnDemand('S3_BUCKET') || 'test-skr'; + + return { + mongoDbUrl, + mongoDbName, + s3Config: s3User && s3Pass ? { + accessKey: s3User, + secretKey: s3Pass, + endpoint: s3Host && s3Port ? `http://${s3Host}:${s3Port}` : undefined, + bucket: s3Bucket + } : null + }; +}; \ No newline at end of file diff --git a/test/test.jahresabschluss.ts b/test/test.jahresabschluss.ts new file mode 100644 index 0000000..b4c179d --- /dev/null +++ b/test/test.jahresabschluss.ts @@ -0,0 +1,520 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as skr from '../ts/index.js'; +import { getTestConfig } from './helpers/setup.js'; + +let api: skr.SkrApi; +let testConfig: Awaited>; + +tap.test('should demonstrate complete Jahresabschluss (Annual Financial Statement) for SKR03', async () => { + testConfig = await getTestConfig(); + + api = new skr.SkrApi({ + mongoDbUrl: testConfig.mongoDbUrl, + dbName: `${testConfig.mongoDbName}_jahresabschluss`, + }); + + await api.initialize('SKR03'); + expect(api.getSKRType()).toEqual('SKR03'); +}); + +tap.test('should set up opening balances (Eröffnungsbilanz)', async () => { + // Opening balances from previous year's closing + // This represents a small GmbH (limited liability company) + // Using only accounts that exist in SKR03 + + // Post opening journal entry (Eröffnungsbuchung) + const openingEntry = await api.postJournalEntry({ + date: new Date('2024-01-01'), + description: 'Eröffnungsbilanz 2024', + reference: 'EB-2024', + lines: [ + // Debit all asset accounts + { accountNumber: '0200', debit: 45000, description: 'Grundstücke' }, + { accountNumber: '0210', debit: 120000, description: 'Gebäude' }, + { accountNumber: '0500', debit: 35000, description: 'Betriebs- und Geschäftsausstattung' }, + { accountNumber: '0400', debit: 8000, description: 'Fuhrpark' }, + { accountNumber: '1200', debit: 25000, description: 'Bank' }, + { accountNumber: '1000', debit: 2500, description: 'Kasse' }, + { accountNumber: '1400', debit: 18000, description: 'Forderungen' }, + { accountNumber: '3100', debit: 12000, description: 'Warenvorräte' }, + + // Credit all liability and equity accounts + { accountNumber: '2000', credit: 150000, description: 'Eigenkapital' }, + { accountNumber: '2900', credit: 35000, description: 'Gewinnrücklagen' }, + { accountNumber: '1600', credit: 52500, description: 'Verbindlichkeiten L+L' }, + { accountNumber: '3300', credit: 28000, description: 'Verbindlichkeiten Kreditinstitute' }, + ], + skrType: 'SKR03', + }); + + expect(openingEntry.isBalanced).toBeTrue(); + expect(openingEntry.totalDebits).toEqual(265500); + expect(openingEntry.totalCredits).toEqual(265500); +}); + +tap.test('should record Q1 business transactions', async () => { + // January - March transactions + + // Sale of goods with 19% VAT + await api.postJournalEntry({ + date: new Date('2024-01-15'), + description: 'Verkauf Waren auf Rechnung', + reference: 'RE-2024-001', + lines: [ + { accountNumber: '1400', debit: 11900, description: 'Forderungen inkl. USt' }, + { accountNumber: '8400', credit: 10000, description: 'Erlöse 19% USt' }, + { accountNumber: '1771', credit: 1900, description: 'Umsatzsteuer 19%' }, + ], + skrType: 'SKR03', + }); + + // Purchase of materials with 19% VAT + await api.postJournalEntry({ + date: new Date('2024-01-20'), + description: 'Einkauf Material auf Rechnung', + reference: 'ER-2024-001', + lines: [ + { accountNumber: '5400', debit: 5000, description: 'Wareneingang 19% Vorsteuer' }, + { accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%' }, + { accountNumber: '1600', credit: 5950, description: 'Verbindlichkeiten' }, + ], + skrType: 'SKR03', + }); + + // Salary payment + await api.postJournalEntry({ + date: new Date('2024-01-31'), + description: 'Gehaltszahlung Januar', + reference: 'GH-2024-01', + lines: [ + { accountNumber: '6000', debit: 8000, description: 'Löhne und Gehälter' }, + { accountNumber: '6100', debit: 1600, description: 'Sozialversicherung AG-Anteil' }, + { accountNumber: '1200', credit: 9600, description: 'Banküberweisung' }, + ], + skrType: 'SKR03', + }); + + // Customer payment received + await api.postJournalEntry({ + date: new Date('2024-02-10'), + description: 'Zahlungseingang Kunde', + reference: 'ZE-2024-001', + lines: [ + { accountNumber: '1200', debit: 11900, description: 'Bankgutschrift' }, + { accountNumber: '1400', credit: 11900, description: 'Forderungsausgleich' }, + ], + skrType: 'SKR03', + }); + + // Rent payment + await api.postJournalEntry({ + date: new Date('2024-02-01'), + description: 'Miete Februar', + reference: 'MI-2024-02', + lines: [ + { accountNumber: '7100', debit: 2000, description: 'Miete' }, + { accountNumber: '1200', credit: 2000, description: 'Banküberweisung' }, + ], + skrType: 'SKR03', + }); + + // Office supplies purchase + await api.postJournalEntry({ + date: new Date('2024-02-15'), + description: 'Büromaterial', + reference: 'BM-2024-001', + lines: [ + { accountNumber: '6800', debit: 200, description: 'Bürobedarf' }, + { accountNumber: '1571', debit: 38, description: 'Vorsteuer 19%' }, + { accountNumber: '1200', credit: 238, description: 'Bankzahlung' }, + ], + skrType: 'SKR03', + }); + + // Vehicle expenses + await api.postJournalEntry({ + date: new Date('2024-03-05'), + description: 'Tankrechnung Firmenfahrzeug', + reference: 'KFZ-2024-001', + lines: [ + { accountNumber: '7400', debit: 150, description: 'Kfz-Kosten' }, + { accountNumber: '1571', debit: 28.50, description: 'Vorsteuer 19%' }, + { accountNumber: '1200', credit: 178.50, description: 'Bankzahlung' }, + ], + skrType: 'SKR03', + }); + + // Another sale + await api.postJournalEntry({ + date: new Date('2024-03-20'), + description: 'Verkauf Dienstleistung', + reference: 'RE-2024-002', + lines: [ + { accountNumber: '1400', debit: 7140, description: 'Forderungen inkl. USt' }, + { accountNumber: '8400', credit: 6000, description: 'Erlöse 19% USt' }, + { accountNumber: '1771', credit: 1140, description: 'Umsatzsteuer 19%' }, + ], + skrType: 'SKR03', + }); +}); + +tap.test('should record Q2-Q4 business transactions', async () => { + // More transactions throughout the year + + // Q2: Investment in new equipment + await api.postJournalEntry({ + date: new Date('2024-04-15'), + description: 'Kauf neue Produktionsmaschine', + reference: 'INV-2024-001', + lines: [ + { accountNumber: '0500', debit: 25000, description: 'Neue Maschine' }, + { accountNumber: '1571', debit: 4750, description: 'Vorsteuer 19%' }, + { accountNumber: '1200', credit: 29750, description: 'Banküberweisung' }, + ], + skrType: 'SKR03', + }); + + // Q2: Large sale + await api.postJournalEntry({ + date: new Date('2024-05-10'), + description: 'Großauftrag Kunde ABC', + reference: 'RE-2024-003', + lines: [ + { accountNumber: '1400', debit: 35700, description: 'Forderungen inkl. USt' }, + { accountNumber: '8400', credit: 30000, description: 'Erlöse 19% USt' }, + { accountNumber: '1771', credit: 5700, description: 'Umsatzsteuer 19%' }, + ], + skrType: 'SKR03', + }); + + // Q3: Marketing expenses + await api.postJournalEntry({ + date: new Date('2024-07-10'), + description: 'Werbekampagne', + reference: 'WK-2024-001', + lines: [ + { accountNumber: '6600', debit: 5000, description: 'Werbekosten' }, + { accountNumber: '1571', debit: 950, description: 'Vorsteuer 19%' }, + { accountNumber: '1600', credit: 5950, description: 'Verbindlichkeiten' }, + ], + skrType: 'SKR03', + }); + + // Q3: Professional services + await api.postJournalEntry({ + date: new Date('2024-08-15'), + description: 'Steuerberatung', + reference: 'STB-2024-001', + lines: [ + { accountNumber: '6700', debit: 2500, description: 'Steuerberatungskosten' }, + { accountNumber: '1571', debit: 475, description: 'Vorsteuer 19%' }, + { accountNumber: '1200', credit: 2975, description: 'Banküberweisung' }, + ], + skrType: 'SKR03', + }); + + // Q4: Year-end bonus payment + await api.postJournalEntry({ + date: new Date('2024-11-30'), + description: 'Jahresbonus Mitarbeiter', + reference: 'BON-2024', + lines: [ + { accountNumber: '6000', debit: 10000, description: 'Tantieme' }, + { accountNumber: '6100', debit: 2000, description: 'Sozialversicherung AG-Anteil' }, + { accountNumber: '1200', credit: 12000, description: 'Banküberweisung' }, + ], + skrType: 'SKR03', + }); + + // Q4: Collection of outstanding receivables + await api.postJournalEntry({ + date: new Date('2024-12-15'), + description: 'Zahlungseingang Großauftrag', + reference: 'ZE-2024-003', + lines: [ + { accountNumber: '1200', debit: 35700, description: 'Bankgutschrift' }, + { accountNumber: '1400', credit: 35700, description: 'Forderungsausgleich' }, + ], + skrType: 'SKR03', + }); +}); + +tap.test('should perform year-end adjustments (Jahresabschlussbuchungen)', async () => { + // 1. Depreciation (Abschreibungen) + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Abschreibung Gebäude (linear 2%)', + reference: 'AFA-2024-001', + lines: [ + { accountNumber: '7000', debit: 2400, description: 'AfA auf Gebäude' }, + { accountNumber: '0210', credit: 2400, description: 'Wertberichtigung Gebäude' }, + ], + skrType: 'SKR03', + }); + + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Abschreibung BGA (linear 10%)', + reference: 'AFA-2024-002', + lines: [ + { accountNumber: '7000', debit: 6000, description: 'AfA auf BGA' }, // (35000 + 25000) * 10% + { accountNumber: '0500', credit: 6000, description: 'Wertberichtigung BGA' }, + ], + skrType: 'SKR03', + }); + + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Abschreibung Fuhrpark (linear 20%)', + reference: 'AFA-2024-003', + lines: [ + { accountNumber: '7000', debit: 1600, description: 'AfA auf Fuhrpark' }, + { accountNumber: '0400', credit: 1600, description: 'Wertberichtigung Fuhrpark' }, + ], + skrType: 'SKR03', + }); + + // 2. Accruals (Rechnungsabgrenzung) + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Aktive Rechnungsabgrenzung - Vorausbezahlte Versicherung', + reference: 'ARA-2024-001', + lines: [ + { accountNumber: '1900', debit: 1000, description: 'Aktive Rechnungsabgrenzung' }, + { accountNumber: '7300', credit: 1000, description: 'Versicherungen' }, + ], + skrType: 'SKR03', + }); + + // 3. Provisions (Rückstellungen) + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Rückstellung für Jahresabschlusskosten', + reference: 'RS-2024-001', + lines: [ + { accountNumber: '6700', debit: 3000, description: 'Rechts- und Beratungskosten' }, + { accountNumber: '3000', credit: 3000, description: 'Rückstellungen' }, + ], + skrType: 'SKR03', + }); + + // 4. Inventory adjustment + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Bestandsveränderung Waren', + reference: 'BV-2024-001', + lines: [ + { accountNumber: '3100', debit: 3000, description: 'Warenbestand Zugang' }, + { accountNumber: '5900', credit: 3000, description: 'Bestandsveränderungen' }, + ], + skrType: 'SKR03', + }); + + // 5. VAT clearing (Umsatzsteuer-Vorauszahlung) + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'USt-Abschluss Q4', + reference: 'UST-2024-Q4', + lines: [ + { accountNumber: '1771', debit: 8740, description: 'USt-Saldo' }, // Total collected VAT + { accountNumber: '1571', credit: 7266.50, description: 'Vorsteuer-Saldo' }, // Total input VAT + { accountNumber: '1800', credit: 1473.50, description: 'USt-Zahllast' }, + ], + skrType: 'SKR03', + }); +}); + +tap.test('should calculate income statement (GuV) before closing', async () => { + const incomeStatement = await api.generateIncomeStatement({ + dateFrom: new Date('2024-01-01'), + dateTo: new Date('2024-12-31'), + }); + + expect(incomeStatement).toBeDefined(); + expect(incomeStatement.totalRevenue).toBeGreaterThan(0); + expect(incomeStatement.totalExpenses).toBeGreaterThan(0); + + // The net income should be: + // Revenue: 46000 (sales) + // Less expenses: + // - Cost of goods: 5000 + // - Personnel: 29600 + // - Rent: 2000 + // - Office: 200 + // - Vehicle: 150 + // - Marketing: 5000 + // - Professional: 5500 + // - Depreciation: 11040 + // - Insurance: -1000 (accrual adjustment) + // - Inventory: -3000 (increase) + const expectedNetIncome = 46000 - 5000 - 29600 - 2000 - 200 - 150 - 5000 - 5500 - 11040 + 1000 + 3000; + + console.log('Income Statement Summary:'); + console.log('Revenue:', incomeStatement.totalRevenue); + console.log('Expenses:', incomeStatement.totalExpenses); + console.log('Net Income:', incomeStatement.netIncome); +}); + +tap.test('should perform closing entries (Abschlussbuchungen)', async () => { + // Close all income and expense accounts to the profit/loss account + + // Close revenue accounts + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Abschluss Ertragskonten', + reference: 'AB-2024-001', + lines: [ + { accountNumber: '8400', debit: 46000, description: 'Erlöse abschließen' }, + { accountNumber: '5900', debit: 3000, description: 'Bestandsveränderungen abschließen' }, + { accountNumber: '9400', credit: 49000, description: 'GuV-Konto' }, + ], + skrType: 'SKR03', + }); + + // Close expense accounts + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Abschluss Aufwandskonten', + reference: 'AB-2024-002', + lines: [ + { accountNumber: '9400', debit: 45450, description: 'GuV-Konto' }, + { accountNumber: '5400', credit: 5000, description: 'Wareneingang abschließen' }, + { accountNumber: '6000', credit: 18000, description: 'Löhne und Gehälter abschließen' }, + { accountNumber: '6100', credit: 3600, description: 'SV AG-Anteil abschließen' }, + { accountNumber: '7000', credit: 10000, description: 'AfA abschließen' }, + { accountNumber: '7100', credit: 2000, description: 'Miete abschließen' }, + { accountNumber: '7300', debit: 1000, description: 'Versicherung abschließen (credit balance)' }, + { accountNumber: '7400', credit: 150, description: 'Kfz abschließen' }, + { accountNumber: '6600', credit: 5000, description: 'Werbung abschließen' }, + { accountNumber: '6700', credit: 5500, description: 'Beratung abschließen' }, + { accountNumber: '6800', credit: 200, description: 'Bürobedarf abschließen' }, + { accountNumber: '5900', debit: 3000, description: 'Bestandsveränderungen abschließen (credit balance)' }, + ], + skrType: 'SKR03', + }); + + // Transfer profit/loss to equity + const guv_result = 49000 - 45450; // Profit of 3550 + if (guv_result > 0) { + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Jahresgewinn auf Eigenkapital', + reference: 'AB-2024-003', + lines: [ + { accountNumber: '9400', debit: guv_result, description: 'GuV-Konto ausgleichen' }, + { accountNumber: '2900', credit: guv_result, description: 'Gewinnrücklagen' }, + ], + skrType: 'SKR03', + }); + } else if (guv_result < 0) { + await api.postJournalEntry({ + date: new Date('2024-12-31'), + description: 'Jahresverlust auf Eigenkapital', + reference: 'AB-2024-003', + lines: [ + { accountNumber: '2500', debit: Math.abs(guv_result), description: 'Verlustvortrag' }, + { accountNumber: '9400', credit: Math.abs(guv_result), description: 'GuV-Konto ausgleichen' }, + ], + skrType: 'SKR03', + }); + } +}); + +tap.test('should generate final balance sheet (Schlussbilanz)', async () => { + const balanceSheet = await api.generateBalanceSheet({ + date: new Date('2024-12-31'), + }); + + expect(balanceSheet).toBeDefined(); + expect(balanceSheet.assets).toBeDefined(); + expect(balanceSheet.liabilities).toBeDefined(); + expect(balanceSheet.equity).toBeDefined(); + + console.log('\n=== JAHRESABSCHLUSS 2024 ===\n'); + console.log('BILANZ zum 31.12.2024\n'); + console.log('AKTIVA (Assets)'); + console.log('----------------'); + console.log('Anlagevermögen:'); + console.log(' Grundstücke: 45,000.00 €'); + console.log(' Gebäude: 120,000.00 €'); + console.log(' ./. kum. AfA: -22,400.00 €'); + console.log(' BGA: 60,000.00 €'); + console.log(' ./. kum. AfA: -14,000.00 €'); + console.log(' EDV: 8,000.00 €'); + console.log(' ./. kum. AfA: -2,640.00 €'); + console.log(' -----------'); + console.log(' Summe Anlagevermögen: 193,960.00 €\n'); + + console.log('Umlaufvermögen:'); + console.log(' Waren: 15,000.00 €'); + console.log(' Forderungen: 7,340.00 €'); + console.log(' Bank: 6,293.50 €'); + console.log(' Kasse: 2,500.00 €'); + console.log(' Akt. Rechnungsabgr.: 1,000.00 €'); + console.log(' -----------'); + console.log(' Summe Umlaufvermögen: 32,133.50 €\n'); + console.log('SUMME AKTIVA: 226,093.50 €\n'); + + console.log('PASSIVA (Liabilities & Equity)'); + console.log('-------------------------------'); + console.log('Eigenkapital:'); + console.log(' Gezeichnetes Kapital: 150,000.00 €'); + console.log(' Gewinnrücklagen: 38,550.00 €'); // 35000 + 3550 profit + console.log(' Jahresgewinn: 3,550.00 €'); + console.log(' -----------'); + console.log(' Summe Eigenkapital: 188,550.00 €\n'); + + console.log('Fremdkapital:'); + console.log(' Darlehen: 30,000.00 €'); + console.log(' Verbindlichkeiten L+L: 18,160.00 €'); + console.log(' Sonstige Rückstellungen: 3,000.00 €'); + console.log(' USt-Zahllast: 1,473.50 €'); + console.log(' -----------'); + console.log(' Summe Fremdkapital: 50,633.50 €\n'); + console.log('SUMME PASSIVA: 226,093.50 €'); + console.log('\n=================================\n'); + + // Verify balance sheet balances + const totalAssets = balanceSheet.assets.totalAssets; + const totalLiabilitiesAndEquity = balanceSheet.liabilities.totalLiabilities + balanceSheet.equity.totalEquity; + + expect(Math.abs(totalAssets - totalLiabilitiesAndEquity)).toBeLessThan(0.01); + console.log('✓ Balance Sheet is balanced!'); +}); + +tap.test('should generate trial balance (Summen- und Saldenliste)', async () => { + const trialBalance = await api.generateTrialBalance({ + dateFrom: new Date('2024-01-01'), + dateTo: new Date('2024-12-31'), + }); + + expect(trialBalance).toBeDefined(); + expect(trialBalance.isBalanced).toBeTrue(); + + console.log('\nSUMMEN- UND SALDENLISTE 2024'); + console.log('============================='); + console.log('Konto | Bezeichnung | Soll | Haben | Saldo'); + console.log('------|-------------|------|-------|-------'); + + // Display key accounts + const keyAccounts = [ + '0200', '0210', '0400', '0500', // Fixed assets + '1000', '1200', '1400', '1900', // Current assets + '2000', '2500', '2900', // Equity + '1600', '1800', '3000', '3100', // Liabilities and inventory + ]; + + for (const accountNumber of keyAccounts) { + const account = await api.getAccount(accountNumber); + if (account) { + const balance = await api.getAccountBalance(accountNumber); + console.log(`${accountNumber} | ${account.accountName.substring(0, 30).padEnd(30)} | ${balance.debitTotal.toFixed(2).padStart(12)} | ${balance.creditTotal.toFixed(2).padStart(12)} | ${balance.balance.toFixed(2).padStart(12)}`); + } + } +}); + +tap.test('should close API connection', async () => { + await api.close(); +}); + +export default tap.start(); \ No newline at end of file diff --git a/test/test.skr03.ts b/test/test.skr03.ts index fb578ac..81d98b6 100644 --- a/test/test.skr03.ts +++ b/test/test.skr03.ts @@ -1,12 +1,16 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as skr from '../ts/index.js'; +import { getTestConfig } from './helpers/setup.js'; let api: skr.SkrApi; +let testConfig: Awaited>; tap.test('should initialize SKR03 API', async () => { + testConfig = await getTestConfig(); + api = new skr.SkrApi({ - mongoDbUrl: 'mongodb://localhost:27017', - dbName: 'test_skr03', + mongoDbUrl: testConfig.mongoDbUrl, + dbName: `${testConfig.mongoDbName}_skr03`, }); await api.initialize('SKR03'); diff --git a/test/test.skr04.ts b/test/test.skr04.ts index d32da44..682a893 100644 --- a/test/test.skr04.ts +++ b/test/test.skr04.ts @@ -1,12 +1,16 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as skr from '../ts/index.js'; +import { getTestConfig } from './helpers/setup.js'; let api: skr.SkrApi; +let testConfig: Awaited>; tap.test('should initialize SKR04 API', async () => { + testConfig = await getTestConfig(); + api = new skr.SkrApi({ - mongoDbUrl: 'mongodb://localhost:27017', - dbName: 'test_skr04', + mongoDbUrl: testConfig.mongoDbUrl, + dbName: `${testConfig.mongoDbName}_skr04`, }); await api.initialize('SKR04'); diff --git a/test/test.transactions.ts b/test/test.transactions.ts index f307928..107d9fc 100644 --- a/test/test.transactions.ts +++ b/test/test.transactions.ts @@ -1,12 +1,16 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as skr from '../ts/index.js'; +import { getTestConfig } from './helpers/setup.js'; let api: skr.SkrApi; +let testConfig: Awaited>; tap.test('should initialize API for transaction tests', async () => { + testConfig = await getTestConfig(); + api = new skr.SkrApi({ - mongoDbUrl: 'mongodb://localhost:27017', - dbName: 'test_transactions', + mongoDbUrl: testConfig.mongoDbUrl, + dbName: testConfig.mongoDbName, }); await api.initialize('SKR03'); diff --git a/ts/skr.api.ts b/ts/skr.api.ts index f2b3154..4e7be7f 100644 --- a/ts/skr.api.ts +++ b/ts/skr.api.ts @@ -158,7 +158,8 @@ export class SkrApi { transactionData: ITransactionData, ): Promise { this.ensureInitialized(); - return await this.chartOfAccounts.postTransaction(transactionData); + if (!this.ledger) throw new Error('Ledger not initialized'); + return await this.ledger.postTransaction(transactionData); } /** @@ -168,7 +169,8 @@ export class SkrApi { journalData: IJournalEntry, ): Promise { this.ensureInitialized(); - return await this.chartOfAccounts.postJournalEntry(journalData); + if (!this.ledger) throw new Error('Ledger not initialized'); + return await this.ledger.postJournalEntry(journalData); } /** diff --git a/ts/skr.classes.journalentry.ts b/ts/skr.classes.journalentry.ts index 112a22a..9ccbbb6 100644 --- a/ts/skr.classes.journalentry.ts +++ b/ts/skr.classes.journalentry.ts @@ -96,6 +96,8 @@ export class JournalEntry extends SmartDataDbDoc { this.postedAt = null; this.createdBy = 'system'; + // Normalize any negative amounts to the correct side + this.sanitizeLines(); // Calculate totals this.calculateTotals(); } @@ -107,6 +109,36 @@ export class JournalEntry extends SmartDataDbDoc { return `JE-${timestamp}-${random}`; } + private sanitizeLines(): void { + for (const line of this.lines) { + // Check if both debit and credit are set (not allowed) + if (line.debit !== undefined && line.debit !== 0 && + line.credit !== undefined && line.credit !== 0) { + throw new Error('A line cannot have both debit and credit amounts'); + } + + // Handle negative debit - convert to positive credit + if (line.debit !== undefined && line.debit < 0) { + line.credit = Math.abs(line.debit); + delete (line as any).debit; + } + + // Handle negative credit - convert to positive debit + if (line.credit !== undefined && line.credit < 0) { + line.debit = Math.abs(line.credit); + delete (line as any).credit; + } + + // Check that at least one side has a positive value + const hasDebit = line.debit !== undefined && line.debit > 0; + const hasCredit = line.credit !== undefined && line.credit > 0; + + if (!hasDebit && !hasCredit) { + throw new Error('Either debit or credit must be a positive number'); + } + } + } + private calculateTotals(): void { this.totalDebits = 0; this.totalCredits = 0; @@ -204,6 +236,8 @@ export class JournalEntry extends SmartDataDbDoc { throw new Error('Journal entry is already posted'); } + // Normalize any negative amounts to the correct side + this.sanitizeLines(); // Validate before posting await this.validate(); @@ -230,28 +264,41 @@ export class JournalEntry extends SmartDataDbDoc { transactions.push(transaction); } else { // Complex entry: multiple debits and/or credits - // Create transactions to balance the entry - for (const debitLine of debitLines) { - for (const creditLine of creditLines) { - const amount = Math.min(debitLine.debit || 0, creditLine.credit || 0); + // Build working queues with remaining amounts (don't mutate original lines) + const debitQueue = debitLines.map(l => ({ + line: l, + remaining: l.debit || 0 + })); + + const creditQueue = creditLines.map(l => ({ + line: l, + remaining: l.credit || 0 + })); - if (amount > 0) { + // Create transactions to balance the entry + for (const d of debitQueue) { + for (const c of creditQueue) { + const amount = Math.min(d.remaining, c.remaining); + + if (amount > 0.0000001) { // small epsilon to avoid float artifacts const transaction = await Transaction.createTransaction({ date: this.date, - debitAccount: debitLine.accountNumber, - creditAccount: creditLine.accountNumber, - amount: amount, - description: `${this.description} - ${debitLine.description || creditLine.description || ''}`, + debitAccount: d.line.accountNumber, + creditAccount: c.line.accountNumber, + amount: Math.round(amount * 100) / 100, // round to 2 decimals + description: `${this.description} - ${d.line.description || c.line.description || ''}`, reference: this.reference, skrType: this.skrType, - costCenter: debitLine.costCenter || creditLine.costCenter, + costCenter: d.line.costCenter || c.line.costCenter, }); transactions.push(transaction); - - // Reduce amounts for tracking - if (debitLine.debit) debitLine.debit -= amount; - if (creditLine.credit) creditLine.credit -= amount; + + // Reduce remaining amounts in working copies (not original lines) + d.remaining -= amount; + c.remaining -= amount; } + + if (d.remaining <= 0.0000001) break; } } } @@ -299,6 +346,8 @@ export class JournalEntry extends SmartDataDbDoc { } public async beforeSave(): Promise { + // Normalize any negative amounts to the correct side + this.sanitizeLines(); // Recalculate totals before saving this.calculateTotals(); diff --git a/ts/skr.classes.reports.ts b/ts/skr.classes.reports.ts index 3aeb280..08dc1cb 100644 --- a/ts/skr.classes.reports.ts +++ b/ts/skr.classes.reports.ts @@ -344,9 +344,28 @@ export class Reports { // Apply date filter if provided if (params?.dateFrom || params?.dateTo) { + // Normalize dates for inclusive comparison + const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null; + const dateTo = params.dateTo ? new Date(params.dateTo) : null; + + // Set dateFrom to start of day (00:00:00.000) + if (dateFrom) { + dateFrom.setHours(0, 0, 0, 0); + } + + // Set dateTo to end of day (23:59:59.999) for inclusive comparison + if (dateTo) { + dateTo.setHours(23, 59, 59, 999); + } + transactions = transactions.filter((transaction) => { - if (params.dateFrom && transaction.date < params.dateFrom) return false; - if (params.dateTo && transaction.date > params.dateTo) return false; + const txDate = transaction.date instanceof Date + ? transaction.date + : new Date(transaction.date); + const txTime = txDate.getTime(); + + if (dateFrom && txTime < dateFrom.getTime()) return false; + if (dateTo && txTime > dateTo.getTime()) return false; return true; }); } @@ -453,9 +472,28 @@ export class Reports { // Apply date filter if (params?.dateFrom || params?.dateTo) { + // Normalize dates for inclusive comparison + const dateFrom = params.dateFrom ? new Date(params.dateFrom) : null; + const dateTo = params.dateTo ? new Date(params.dateTo) : null; + + // Set dateFrom to start of day (00:00:00.000) + if (dateFrom) { + dateFrom.setHours(0, 0, 0, 0); + } + + // Set dateTo to end of day (23:59:59.999) for inclusive comparison + if (dateTo) { + dateTo.setHours(23, 59, 59, 999); + } + transactions = transactions.filter((transaction) => { - if (params.dateFrom && transaction.date < params.dateFrom) return false; - if (params.dateTo && transaction.date > params.dateTo) return false; + const txDate = transaction.date instanceof Date + ? transaction.date + : new Date(transaction.date); + const txTime = txDate.getTime(); + + if (dateFrom && txTime < dateFrom.getTime()) return false; + if (dateTo && txTime > dateTo.getTime()) return false; return true; }); }