@accounter/server
Version:
Accounter GraphQL server
293 lines • 18.7 kB
JavaScript
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