UNPKG

@accounter/server

Version:
293 lines 18.7 kB
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { TestDatabase } from '../../../__tests__/helpers/db-setup.js'; import { insertFixture } from '../../../__tests__/helpers/fixture-loader.js'; import { expenseScenarioB } from '../../../__tests__/fixtures/expenses/expense-scenario-b.js'; import { makeUUID } from '../../../demo-fixtures/helpers/deterministic-uuid.js'; import { qualifyTable } from '../../../__tests__/helpers/test-db-config.js'; import { buildAdminContextFromDb } from '../../../__tests__/helpers/admin-context-builder.js'; import { mockExchangeRate } from '../../../__tests__/helpers/exchange-mock.js'; import { env } from '../../../environment.js'; import { ledgerGenerationByCharge } from '../helpers/ledger-by-charge-type.helper.js'; import { assertForeignExpenseScenario, assertAuditTrail } from './helpers/ledger-assertions.js'; import { createLedgerTestContext } from '../../../test-utils/ledger-injector.js'; import { Currency } from '../../../shared/enums.js'; /** * Ledger Integration Test for Expense Scenario B * * Tests ledger generation for a foreign currency (USD) invoice expense scenario. * This validates the complete flow: fixture insertion → exchange rate mocking → * ledger generation → record validation with currency conversion. * * Scenario: * - Admin business purchases from US Vendor LLC * - Transaction: -200 USD (expense) * - Document: Invoice for 200 USD * - Exchange rate: 3.5 ILS/USD (mocked, deterministic) * - Expected ledger: 2 balanced records with 700 ILS total (200 × 3.5) */ describe('Ledger Generation - Expense Scenario B (Foreign Currency)', () => { let db; beforeAll(async () => { db = new TestDatabase(); await db.connect(); await db.ensureLatestSchema(); await db.seedAdmin(); }); afterAll(async () => { // Do NOT close db here — the shared pool is managed by vitest-global-setup teardown. // Closing it here destroys the pool for all other concurrently-running integration test suites. }); // Ensure ledger and related rows do not leak between tests afterEach(async () => { const client = await db.getPool().connect(); const errors = []; const run = async (label, fn) => { try { await fn(); } catch (e) { errors.push(`${label}: ${e}`); } }; try { const chargeId = makeUUID('charge', 'charge-consulting-services'); const transactionId = makeUUID('transaction', 'transaction-consulting-payment'); const rawEtherscanId = makeUUID('raw-transaction', `etherscan-${transactionId}`); const adminId = makeUUID('business', 'admin-business-usd'); // Set context to allow deletion of protected rows (session-level so it persists through cleanup) await client.query("SELECT set_config('app.current_business_id', $1, false)", [adminId]); await run('ledger_records', () => client.query(`DELETE FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1`, [chargeId])); await run('documents', () => client.query(`DELETE FROM ${qualifyTable('documents')} WHERE charge_id = $1`, [chargeId])); // Delete transactions first so transactions_raw_list orphan can then be cleaned up await run('transactions', () => client.query(`DELETE FROM ${qualifyTable('transactions')} WHERE charge_id = $1`, [chargeId])); // Clean up transactions_raw_list orphan inserted by fixture-loader await run('transactions_raw_list', () => client.query(`DELETE FROM ${qualifyTable('transactions_raw_list')} WHERE etherscan_id = $1`, [rawEtherscanId])); await run('charges', () => client.query(`DELETE FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId])); // Clean up fixture-specific entities to prevent count inflation await run('financial_accounts_tax_categories', () => client.query(`DELETE FROM ${qualifyTable('financial_accounts_tax_categories')} WHERE tax_category_id IN ($1, $2)`, [makeUUID('tax-category', 'expense-consulting'), makeUUID('tax-category', 'usd-account-tax-category')])); await run('financial_accounts', () => client.query(`DELETE FROM ${qualifyTable('financial_accounts')} WHERE account_number = $1`, ['USD-ACCOUNT-001'])); await run('tax_categories', () => client.query(`DELETE FROM ${qualifyTable('tax_categories')} WHERE id IN ($1, $2)`, [makeUUID('tax-category', 'expense-consulting'), makeUUID('tax-category', 'usd-account-tax-category')])); await run('businesses', () => client.query(`DELETE FROM ${qualifyTable('businesses')} WHERE id IN ($1, $2)`, [makeUUID('business', 'admin-business-usd'), makeUUID('business', 'supplier-us-vendor-llc')])); await run('financial_entities', () => client.query(`DELETE FROM ${qualifyTable('financial_entities')} WHERE id IN ($1, $2, $3, $4)`, [ makeUUID('business', 'admin-business-usd'), makeUUID('business', 'supplier-us-vendor-llc'), makeUUID('tax-category', 'expense-consulting'), makeUUID('tax-category', 'usd-account-tax-category'), ])); } finally { client.release(); } if (errors.length > 0) { throw new Error(`afterEach cleanup failures:\n${errors.join('\n')}`); } }); it('should generate balanced ledger records for USD invoice expense with mocked exchange rate', async () => { // Insert fixture within an explicit transaction and COMMIT to persist rows. const insertClient = await db.getPool().connect(); try { await insertClient.query('BEGIN'); // Build AdminContext from database const adminContext = await buildAdminContextFromDb(insertClient); // Explicitly set context on the insertion client to match the owner of the charge we are about to insert // The charge owner is 'admin-business-usd' per fixture definition const fixtureOwnerId = makeUUID('business', 'admin-business-usd'); await insertClient.query("SELECT set_config('app.current_business_id', $1, false)", [fixtureOwnerId]); await insertFixture(insertClient, expenseScenarioB, adminContext.ownerId); // Reset RLS context to charge owner for verification // (insertFixture might leave it set to the last inserted entity's owner) await insertClient.query("SELECT set_config('app.current_business_id', $1, true)", [fixtureOwnerId]); const chargeId = makeUUID('charge', 'charge-consulting-services'); // Verify inserts within the same transaction const chargeResult = await insertClient.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); expect(chargeResult.rows).toHaveLength(1); const transactionsResult = await insertClient.query(`SELECT * FROM ${qualifyTable('transactions')} WHERE charge_id = $1`, [chargeId]); expect(transactionsResult.rows).toHaveLength(1); expect(transactionsResult.rows[0].amount).toBe('-200.00'); expect(transactionsResult.rows[0].currency).toBe(Currency.Usd); const documentsResult = await insertClient.query(`SELECT * FROM ${qualifyTable('documents')} WHERE charge_id = $1`, [chargeId]); expect(documentsResult.rows).toHaveLength(1); expect(documentsResult.rows[0].type).toBe('INVOICE'); expect(Number(documentsResult.rows[0].total_amount)).toBe(200.0); expect(documentsResult.rows[0].currency_code).toBe(Currency.Usd); await insertClient.query('COMMIT'); } catch (e) { try { await insertClient.query('ROLLBACK'); } catch { } throw e; } finally { insertClient.release(); } // Re-open a new client (outside prior transaction) for verification & generation const client = await db.getPool().connect(); try { const adminContext = await buildAdminContextFromDb(client); const adminId = makeUUID('business', 'admin-business-usd'); await client.query("SELECT set_config('app.current_business_id', $1, false)", [adminId]); const chargeId = makeUUID('charge', 'charge-consulting-services'); const chargeResult = await client.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); const charge = chargeResult.rows[0]; // Create context with mocked exchange rate: 3.5 ILS per USD const context = createLedgerTestContext({ pool: db.getPool(), env, moduleId: 'ledger', businessId: adminContext.ownerId, mockExchangeRates: mockExchangeRate(Currency.Usd, Currency.Ils, 3.5), }); const result = await ledgerGenerationByCharge(charge, { insertLedgerRecordsIfNotExists: true }, context, {}); // Verify result structure and success expect(result).toBeDefined(); expect(result).not.toBeNull(); // Type guard: ensure result is LedgerRecordsProto, not CommonError if (!result || 'message' in result) { throw new Error(`Ledger generation failed: ${result ? result.message : 'null result'}`); } expect(result.errors).toEqual([]); expect(result.balance?.isBalanced).toBe(true); expect(result.records.length).toBeGreaterThanOrEqual(2); // At least debit + credit // Query ledger records from database const ledgerResult = await client.query(`SELECT * FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1 ORDER BY created_at ASC`, [charge.id]); const ledgerExpectation = expenseScenarioB.expectations?.ledger?.[0]; // Semantic foreign currency assertions assertForeignExpenseScenario(ledgerResult.rows, { chargeId: charge.id, ownerId: charge.owner_id, // Use actual charge owner id expenseEntity: makeUUID('tax-category', 'expense-consulting'), bankEntity: makeUUID('tax-category', 'usd-account-tax-category'), expectedCurrency: Currency.Usd, expectedForeignAmount: ledgerExpectation?.foreignAmount || 200.0, expectedRate: ledgerExpectation?.exchangeRate || 3.5, expectedLocalTotal: ledgerExpectation?.totalDebitLocal || 1400.0, }); // Retain record count sanity check (algorithm may add extra legs) expect(ledgerResult.rows.length).toBeGreaterThanOrEqual(ledgerExpectation?.recordCount || 2); // Audit trail assertions assertAuditTrail(ledgerResult.rows); console.log('✅ Ledger generation semantic foreign currency assertions passed for Scenario B'); } finally { client.release(); } }); it('should be idempotent on repeated foreign ledger generation (no duplicate records)', async () => { const client = await db.getPool().connect(); try { const chargeId = makeUUID('charge', 'charge-consulting-services'); const adminId = makeUUID('business', 'admin-business-usd'); await client.query("SELECT set_config('app.current_business_id', $1, false)", [adminId]); // Ensure fixture inserted if absent const existingCharge = await client.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); const adminContext = await buildAdminContextFromDb(client); if (existingCharge.rows.length === 0) { await client.query('BEGIN'); await insertFixture(client, expenseScenarioB, adminContext.ownerId); await client.query('COMMIT'); } const chargeResult = await client.query(`SELECT * FROM ${qualifyTable('charges')} WHERE id = $1`, [chargeId]); const charge = chargeResult.rows[0]; const context = createLedgerTestContext({ pool: db.getPool(), env, moduleId: 'ledger', businessId: adminContext.ownerId, mockExchangeRates: mockExchangeRate(Currency.Usd, Currency.Ils, 3.5), }); const first = await ledgerGenerationByCharge(charge, { insertLedgerRecordsIfNotExists: true }, context, {}); if (!first || 'message' in first) { throw new Error('First foreign generation failed'); } const firstRecords = await client.query(`SELECT id FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1 ORDER BY id ASC`, [charge.id]); const firstIds = firstRecords.rows.map(r => r.id); const second = await ledgerGenerationByCharge(charge, { insertLedgerRecordsIfNotExists: false }, context, {}); if (!second || 'message' in second) { throw new Error('Second foreign generation failed'); } const secondRecords = await client.query(`SELECT id FROM ${qualifyTable('ledger_records')} WHERE charge_id = $1 ORDER BY id ASC`, [charge.id]); const secondIds = secondRecords.rows.map(r => r.id); expect(secondIds.length).toBe(firstIds.length); expect(secondIds).toEqual(firstIds); } finally { client.release(); } }); it('should validate fixture structure matches expectations', () => { // Verify fixture has expected structure expect(expenseScenarioB.businesses?.businesses).toHaveLength(2); // Admin + US vendor expect(expenseScenarioB.taxCategories?.taxCategories).toHaveLength(2); // Consulting + USD account expect(expenseScenarioB.accounts?.accounts).toHaveLength(1); // USD account expect(expenseScenarioB.accountTaxCategories?.mappings).toHaveLength(1); // USD account mapping expect(expenseScenarioB.charges?.charges).toHaveLength(1); expect(expenseScenarioB.transactions?.transactions).toHaveLength(1); expect(expenseScenarioB.documents?.documents).toHaveLength(1); // Verify USD account configuration const usdAccount = expenseScenarioB.accounts?.accounts[0]; expect(usdAccount?.accountNumber).toBe('USD-ACCOUNT-001'); expect(usdAccount?.type).toBe('BANK_ACCOUNT'); // Verify account tax category mapping const accountMapping = expenseScenarioB.accountTaxCategories?.mappings[0]; expect(accountMapping?.accountNumber).toBe('USD-ACCOUNT-001'); expect(accountMapping?.currency).toBe(Currency.Usd); expect(accountMapping?.taxCategoryId).toBe(makeUUID('tax-category', 'usd-account-tax-category')); // Verify expectations are defined expect(expenseScenarioB.expectations).toBeDefined(); expect(expenseScenarioB.expectations?.ledger).toHaveLength(1); const ledgerExpectation = expenseScenarioB.expectations?.ledger?.[0]; expect(ledgerExpectation?.chargeId).toBe(makeUUID('charge', 'charge-consulting-services')); expect(ledgerExpectation?.recordCount).toBe(2); expect(ledgerExpectation?.balanced).toBe(true); expect(ledgerExpectation?.totalDebitLocal).toBe(1400.0); expect(ledgerExpectation?.totalCreditLocal).toBe(1400.0); expect(ledgerExpectation?.foreignCurrency).toBe('USD'); expect(ledgerExpectation?.foreignAmount).toBe(200.0); expect(ledgerExpectation?.exchangeRate).toBe(3.5); expect(ledgerExpectation?.debitEntities).toHaveLength(2); expect(ledgerExpectation?.creditEntities).toHaveLength(2); }); it('should create deterministic UUIDs for reproducible tests', () => { // Verify all IDs are deterministic and match expected values const adminBusinessId = makeUUID('business', 'admin-business-usd'); const supplierId = makeUUID('business', 'supplier-us-vendor-llc'); const chargeId = makeUUID('charge', 'charge-consulting-services'); const transactionId = makeUUID('transaction', 'transaction-consulting-payment'); const documentId = makeUUID('document', 'document-consulting-invoice'); expect(adminBusinessId).toBe(expenseScenarioB.businesses?.businesses[0]?.id); expect(supplierId).toBe(expenseScenarioB.businesses?.businesses[1]?.id); expect(chargeId).toBe(expenseScenarioB.charges?.charges[0]?.id); expect(transactionId).toBe(expenseScenarioB.transactions?.transactions[0]?.id); expect(documentId).toBe(expenseScenarioB.documents?.documents[0]?.id); }); it('should use INVOICE document type for foreign vendors', () => { // Verify document type is INVOICE (not RECEIPT) for US vendor const document = expenseScenarioB.documents?.documents[0]; expect(document?.type).toBe('INVOICE'); expect(document?.serial_number).toBe('INV-US-2024-001'); expect(document?.currency_code).toBe(Currency.Usd); // US invoices should not have Israeli VAT expect(document?.vat_amount).toBeNull(); }); it('should validate exchange rate conversion within tolerance', async () => { // This is a unit-style test of the fixture expectations themselves const ledgerExpectation = expenseScenarioB.expectations?.ledger?.[0]; expect(ledgerExpectation).toBeDefined(); expect(ledgerExpectation?.foreignCurrency).toBe('USD'); expect(ledgerExpectation?.foreignAmount).toBe(200.0); expect(ledgerExpectation?.exchangeRate).toBe(3.5); // Validate conversion math: 200 USD × 3.5 = 700 ILS per entry, 1400 ILS total const calculatedLocalPerEntry = (ledgerExpectation?.foreignAmount ?? 0) * (ledgerExpectation?.exchangeRate ?? 0); expect(calculatedLocalPerEntry).toBe(700.0); const expectedTotal = calculatedLocalPerEntry * 2; // Document + transaction expect(ledgerExpectation?.totalDebitLocal).toBe(expectedTotal); expect(ledgerExpectation?.totalCreditLocal).toBe(expectedTotal); // Verify balance expect(ledgerExpectation?.balanced).toBe(true); expect(ledgerExpectation?.totalDebitLocal).toBe(ledgerExpectation?.totalCreditLocal); }); }); //# sourceMappingURL=ledger-scenario-b.integration.test.js.map