UNPKG

@accounter/server

Version:
454 lines (422 loc) 18 kB
/** * Fixture insertion logic with ordered insertion and rollback support * * Inserts fixture data into the database in the correct order to satisfy * foreign key constraints, with savepoint-based rollback for detailed error messages. * * @see packages/server/src/__tests__/helpers/fixture-types.ts for fixture structure * @see packages/server/src/__tests__/helpers/fixture-validation.ts for validation logic */ import type { Client, PoolClient } from 'pg'; import type { Fixture } from './fixture-types.js'; import { assertValidFixture } from './fixture-validation.js'; import { qualifyTable } from './test-db-config.js'; import { makeUUID, makeUUIDLegacy } from '../../demo-fixtures/helpers/deterministic-uuid.js'; import { UUID_REGEX } from '../../shared/constants.js'; import { ensureBusinessForEntity, ensureFinancialEntity, ensureTaxCategoryForEntity } from './seed-helpers.js'; /** * Custom error for fixture insertion failures * * Provides context about which section of the fixture failed to insert * and includes the underlying database error. */ export class FixtureInsertionError extends Error { constructor( public readonly section: string, public readonly originalError: Error, public readonly context?: Record<string, unknown>, ) { super( `Failed to insert fixture section "${section}": ${originalError.message}` + (context ? `\nContext: ${JSON.stringify(context, null, 2)}` : ''), ); this.name = 'FixtureInsertionError'; Error.captureStackTrace?.(this, FixtureInsertionError); } toJSON() { return { name: this.name, message: this.message, section: this.section, originalError: { name: this.originalError.name, message: this.originalError.message, }, context: this.context, }; } } /** * ID mapping result from fixture insertion * * Maps logical IDs from the fixture to actual database-generated IDs. * Currently, factories generate UUIDs directly, so this is mostly a pass-through, * but the infrastructure is in place for future auto-generated IDs. */ export type FixtureIdMapping = Map<string, string>; /** * Insert a complete fixture into the database * * Performs ordered insertion of all fixture entities with savepoint-based * error handling for clear diagnostics. Insertion order ensures foreign key * constraints are satisfied: * * 1. Businesses (financial entities) * 2. Tax categories (financial entities) * 3. Financial accounts (references businesses) * 4. Charges (references businesses and tax categories) * 5. Transactions (references charges, accounts, businesses) * 6. Documents (references charges and businesses) * * Each section is wrapped in a SAVEPOINT so that partial failures can be * rolled back to a known state with clear error context. * * **Transaction Handling:** * - Must be called within an existing transaction (client has active BEGIN) * - Creates savepoints for each section (SAVEPOINT section_name) * - On section error, rolls back to savepoint (ROLLBACK TO SAVEPOINT section_name) * - Does NOT commit or rollback the outer transaction * * **Validation:** * - Validates fixture before any insertion (assertValidFixture) * - Checks referential integrity (charge IDs, business IDs, etc.) * - Validates required fields * * @param client - PostgreSQL client (PoolClient or standalone Client) within an active transaction * @param fixture - Complete fixture to insert * @returns Promise resolving to ID mapping (fixture ID → database ID) * @throws {Error} If fixture validation fails (via assertValidFixture) * @throws {FixtureInsertionError} If any insertion section fails * * @remarks * Type Safety: Accepts both PoolClient and Client to support both test transactions * (pool.connect()) and standalone connections (new pg.Client()) used in seed scripts. * * @example * ```typescript * import { withTestTransaction } from './test-transaction.js'; * import { insertFixture } from './fixture-loader.js'; * import { expenseScenarioA } from '../fixtures/expenses/expense-scenario-a.js'; * * it('should insert expense scenario', () => * withTestTransaction(pool, async (client) => { * const idMap = await insertFixture(client, expenseScenarioA); * * // Verify insertion * const result = await client.query( * 'SELECT * FROM accounter_schema.charges WHERE id = $1', * [expenseScenarioA.charges!.charges[0].id!] * ); * expect(result.rows).toHaveLength(1); * }) * ); * ``` */ export async function insertFixture( client: PoolClient | Client, fixture: Fixture, adminBusinessId?: string, ): Promise<FixtureIdMapping> { // Validate fixture before insertion assertValidFixture(fixture); // Set RLS context if possible to allow insertion into protected tables (e.g. charges) const contextBusinessId = adminBusinessId || fixture.businesses?.businesses?.[0]?.id; if (contextBusinessId) { try { await client.query( `SELECT set_config('app.current_business_id', $1, true), set_config('app.current_user_id', $1, true), set_config('app.auth_type', 'jwt', true)`, [contextBusinessId], ); } catch (error) { // Ignore error if function doesn't exist (e.g. in some mock setups) // or if we can't set config for some reason. // The insertion might still succeed if RLS isn't fully active or checks are loose. console.warn('Failed to set RLS context in insertFixture:', error); } } const idMapping: FixtureIdMapping = new Map(); // Helper to execute a section with savepoint protection async function executeSavepointSection<T>( sectionName: string, fn: () => Promise<T>, ): Promise<T> { // Savepoint names must be valid PostgreSQL identifiers (no spaces, start with letter) const savepointName = `sp_${sectionName.replace(/[^a-zA-Z0-9_]/g, '_')}`; try { await client.query(`SAVEPOINT ${savepointName}`); const result = await fn(); await client.query(`RELEASE SAVEPOINT ${savepointName}`); return result; } catch (error) { await client.query(`ROLLBACK TO SAVEPOINT ${savepointName}`); throw new FixtureInsertionError( sectionName, error instanceof Error ? error : new Error(String(error)), { savepointName }, ); } } // 1. Insert businesses (financial entities of type 'business') if (fixture.businesses?.businesses && fixture.businesses.businesses.length > 0) { await executeSavepointSection('businesses', async () => { for (const business of fixture.businesses!.businesses) { // Insert financial entity first (type='business') // Field mapping: business.name used for display (required); hebrewName is legacy/optional const {id: entityId} = await ensureFinancialEntity(client, { id: business.id, name: business.name || business.id!, type: 'business', ownerId: adminBusinessId, }); const {id: businessId, ...options } = business; // Insert business details - note: vat_number column maps to governmentId field await ensureBusinessForEntity(client, entityId, {...options, ownerId: adminBusinessId ?? options.ownerId}); idMapping.set(business.id!, entityId); } }); } // 2. Insert tax categories (financial entities of type 'tax_category') if (fixture.taxCategories?.taxCategories && fixture.taxCategories.taxCategories.length > 0) { await executeSavepointSection('tax_categories', async () => { for (const taxCategory of fixture.taxCategories!.taxCategories) { // Insert financial entity first const {id: entityId} = await ensureFinancialEntity(client, { id: taxCategory.id, name: taxCategory.name || taxCategory.id!, type: 'tax_category', ownerId: adminBusinessId, }); // Insert tax category details await ensureTaxCategoryForEntity(client, entityId, { name: taxCategory.name, hashavshevetName: taxCategory.hashavshevetName, taxExcluded: taxCategory.taxExcluded, ownerId: adminBusinessId ?? taxCategory.ownerId, }); idMapping.set(taxCategory.id!, entityId); } }); } // 3. Insert financial accounts if (fixture.accounts?.accounts && fixture.accounts.accounts.length > 0) { await executeSavepointSection('financial_accounts', async () => { for (const account of fixture.accounts!.accounts) { const result = await client.query( `INSERT INTO ${qualifyTable('financial_accounts')} ( account_number, account_name, private_business, owner, type, owner_id ) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, account_number`, [ account.accountNumber, account.name, account.privateBusiness, account.ownerId, account.type, account.ownerId, ], ); // Store both the account_number and the auto-generated UUID id // Transactions reference the id field, not account_number if (result.rows.length > 0) { // Store account_number mapping for reference idMapping.set(account.accountNumber!, result.rows[0].account_number); // Also store the UUID id for transaction references if (account.accountNumber) { idMapping.set(`${account.accountNumber}_id`, result.rows[0].id); } } } }); } // 3b. Insert financial account tax category mappings if (fixture.accountTaxCategories?.mappings && fixture.accountTaxCategories.mappings.length > 0) { await executeSavepointSection('account_tax_categories', async () => { for (const mapping of fixture.accountTaxCategories!.mappings) { // Prefer the UUID cached from step 3 (financial_accounts insert) to avoid // non-deterministic SELECT when duplicate account_number rows exist in DB. const cachedAccountId = idMapping.get(`${mapping.accountNumber}_id`); let accountId: string; if (cachedAccountId) { accountId = cachedAccountId; } else { // Fallback: query DB, using LIMIT 1 for determinism const accountResult = await client.query( `SELECT id FROM ${qualifyTable('financial_accounts')} WHERE account_number = $1 LIMIT 1`, [mapping.accountNumber], ); if (accountResult.rows.length === 0) { throw new Error(`Financial account with account_number '${mapping.accountNumber}' not found`); } accountId = accountResult.rows[0].id; } await client.query( `INSERT INTO ${qualifyTable('financial_accounts_tax_categories')} ( financial_account_id, currency, tax_category_id, owner_id ) VALUES ($1, $2, $3, $4) ON CONFLICT (financial_account_id, currency) DO UPDATE SET tax_category_id = EXCLUDED.tax_category_id, owner_id = EXCLUDED.owner_id`, [accountId, mapping.currency, mapping.taxCategoryId, mapping.ownerId], ); } }); } // 4. Insert charges if (fixture.charges?.charges && fixture.charges.charges.length > 0) { await executeSavepointSection('charges', async () => { for (const charge of fixture.charges!.charges) { // Set RLS context for each charge to ensure policy compliance if (charge.owner_id) { try { await client.query("SELECT set_config('app.current_business_id', $1, true)", [charge.owner_id]); } catch (e) { // Ignore if function assumes string UUIDs but we have something else, or permission issues console.warn('Failed to set RLS context for charge in fixture-loader:', e); } } await client.query( `INSERT INTO ${qualifyTable('charges')} ( id, owner_id, type, accountant_status, user_description, tax_category_id, optional_vat, documents_optional_flag, is_property, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) ON CONFLICT (id) DO NOTHING`, [ charge.id, charge.owner_id, charge.type ?? null, charge.accountant_status ?? 'PENDING', charge.user_description, charge.tax_category_id ?? null, charge.optional_vat ?? false, charge.documents_optional_flag ?? false, charge.is_property ?? false, ], ); idMapping.set(charge.id!, charge.id!); } }); } // 5. Insert transactions if (fixture.transactions?.transactions && fixture.transactions.transactions.length > 0) { await executeSavepointSection('transactions', async () => { for (const transaction of fixture.transactions!.transactions) { // Set RLS context for each transaction if (transaction.business_id) { try { await client.query("SELECT set_config('app.current_business_id', $1, true)", [transaction.business_id]); } catch(e) { console.warn('Failed to set RLS context for transaction in fixture-loader:', e); } } // If account_id looks like an account_number (not a UUID), look up the actual UUID. // Prefer the UUID cached from step 3 (financial_accounts insert) to avoid // non-deterministic SELECT when duplicate account_number rows exist in DB. let accountId = transaction.account_id; if (accountId && !accountId.match(UUID_REGEX)) { const cachedAccountId = idMapping.get(`${accountId}_id`); if (cachedAccountId) { accountId = cachedAccountId; } else { // Fallback: query DB, using LIMIT 1 for determinism const accountResult = await client.query( `SELECT id FROM ${qualifyTable('financial_accounts')} WHERE account_number = $1 LIMIT 1`, [accountId], ); if (accountResult.rows.length === 0) { throw new Error(`Financial account with account_number '${accountId}' not found`); } accountId = accountResult.rows[0].id; } } // Insert directly into transactions_raw_list with etherscan_id to satisfy check constraint // This avoids triggering any creditcard/bank transaction handlers which would auto-create charges // etherscan_id is used because it has no INSERT trigger and is a simple UUID reference const dummyEtherscanId = transaction.id ? makeUUID('raw-transaction', `etherscan-${transaction.id}`) : makeUUIDLegacy(); const rawListResult = await client.query( `INSERT INTO ${qualifyTable('transactions_raw_list')} (etherscan_id) VALUES ($1) RETURNING id`, [dummyEtherscanId], ); // Always use the newly created raw list ID as source_id const sourceId = rawListResult.rows[0].id; await client.query( `INSERT INTO ${qualifyTable('transactions')} ( id, account_id, charge_id, source_id, source_description, currency, event_date, debit_date, amount, current_balance, business_id, is_fee, source_reference, source_origin, origin_key, currency_rate, owner_id, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), NOW()) ON CONFLICT (id) DO NOTHING`, [ transaction.id, accountId, transaction.charge_id, sourceId, transaction.source_description, transaction.currency, transaction.event_date, transaction.debit_date, transaction.amount, transaction.current_balance ?? '0', transaction.business_id, transaction.is_fee ?? false, 'TEST-REF', // source_reference (NOT NULL) 'TEST', // source_origin (NOT NULL) dummyEtherscanId, // origin_key (NOT NULL) - must match etherscan_id in raw list transaction.currency_rate ?? 1, transaction.owner_id, ], ); idMapping.set(transaction.id!, transaction.id!); } }); } // 6. Insert documents if (fixture.documents?.documents && fixture.documents.documents.length > 0) { await executeSavepointSection('documents', async () => { for (const document of fixture.documents!.documents) { await client.query( `INSERT INTO ${qualifyTable('documents')} ( id, image_url, file_url, type, serial_number, date, total_amount, currency_code, vat_amount, charge_id, debtor_id, creditor_id, vat_report_date_override, no_vat_amount, allocation_number, exchange_rate_override, file_hash, owner_id ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) ON CONFLICT (id) DO NOTHING`, [ document.id, document.image_url, document.file_url, document.type, document.serial_number, document.date, document.total_amount, document.currency_code, document.vat_amount, document.charge_id, document.debtor_id, document.creditor_id, document.vat_report_date_override, document.no_vat_amount, document.allocation_number, document.exchange_rate_override, document.file_hash, document.owner_id, ], ); idMapping.set(document.id!, document.id!); } }); } return idMapping; }