UNPKG

@accounter/server

Version:
539 lines (482 loc) 20.7 kB
import { Pool } from 'pg'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { connectTestDb, closeTestDb } from '../../../../__tests__/helpers/db-connection.js'; import { runMigrationsIfNeeded } from '../../../../__tests__/helpers/db-migrations.js'; import type { IsracardTransactionInput, PoalimForeignTransactionInput, PoalimIlsTransactionInput, PoalimSecurityInput, PoalimSecurityTransactionInput, PoalimSwiftTransactionInput, } from '../../../../__generated__/types.js'; import { Currency } from '../../../../shared/enums.js'; import type { AuthContextProvider } from '../../../auth/providers/auth-context.provider.js'; import { DBProvider } from '../../../app-providers/db.provider.js'; import { TenantAwareDBClient } from '../../../app-providers/tenant-db-client.js'; import { PoalimScraperIngestionProvider } from '../poalim-scraper-ingestion.provider.js'; let pool: Pool; let provider: PoalimScraperIngestionProvider; const TRIGGER_TABLES = [ 'poalim_ils_account_transactions', 'poalim_foreign_account_transactions', 'poalim_swift_account_transactions', ]; // poalim_securities and poalim_securities_transactions carry a real owner_id FK, so the tenant this suite acts as // must exist in accounter_schema.businesses. Seeded in beforeAll, removed in afterAll. const TEST_OWNER_ID = '00000000-0000-0000-0000-0000000005ec'; function createMockAuthContextProvider(): AuthContextProvider { return { getAuthContext: () => Promise.resolve({ authType: 'apiKey' as const, token: 'test-token', tenant: { businessId: TEST_OWNER_ID }, user: { userId: 'api-key:test', auth0UserId: null, email: '', roleId: 'admin', permissions: [], emailVerified: true, permissionsVersion: 0, }, }), } as unknown as AuthContextProvider; } beforeAll(async () => { pool = await connectTestDb(); await runMigrationsIfNeeded(pool); const dbProvider = new DBProvider(pool); const authContextProvider = createMockAuthContextProvider(); const dbClient = new TenantAwareDBClient(dbProvider, authContextProvider); provider = new PoalimScraperIngestionProvider(dbClient, authContextProvider); // poalim_securities.owner_id is a real FK, so the acting tenant must exist. await pool.query( `INSERT INTO accounter_schema.financial_entities (id, name) VALUES ($1, 'poalim-ingestion-test-owner') ON CONFLICT (id) DO NOTHING`, [TEST_OWNER_ID], ); await pool.query( `INSERT INTO accounter_schema.businesses (id, owner_id) VALUES ($1, $1) ON CONFLICT (id) DO NOTHING`, [TEST_OWNER_ID], ); // Disable triggers so inserts don't cascade into charges/transactions, // which require financial_accounts and owner_id to be set up. for (const table of TRIGGER_TABLES) { await pool.query(`ALTER TABLE accounter_schema.${table} DISABLE TRIGGER ALL`); } }); afterAll(async () => { for (const table of TRIGGER_TABLES) { await pool.query(`ALTER TABLE accounter_schema.${table} ENABLE TRIGGER ALL`); } // Drop the seeded tenant; poalim_securities* rows cascade with it. await pool.query('DELETE FROM accounter_schema.businesses WHERE id = $1', [TEST_OWNER_ID]); await pool.query('DELETE FROM accounter_schema.financial_entities WHERE id = $1', [ TEST_OWNER_ID, ]); // 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. }); // ── Helpers ──────────────────────────────────────────────────────────────────── async function truncate(table: string) { // Use DELETE instead of TRUNCATE CASCADE to avoid acquiring ACCESS EXCLUSIVE locks on // tables with FK references (e.g. transactions_raw_list), which deadlocks with // concurrently-running integration tests. Triggers are disabled in beforeAll so // no dependent rows exist in other tables. await pool.query(`DELETE FROM accounter_schema.${table}`); } /** * The poalim_securities* tables are also written by the foreign-securities suite, which runs * concurrently — an unqualified DELETE there wipes its fixtures mid-test (and vice versa). * Everything this suite inserts belongs to TEST_OWNER_ID, so scoping the cleanup is enough. */ async function truncateOwned(table: string) { await pool.query(`DELETE FROM accounter_schema.${table} WHERE owner_id = $1`, [TEST_OWNER_ID]); } // ── Poalim ILS ──────────────────────────────────────────────────────────────── describe('uploadPoalimIlsTransactions', () => { beforeEach(() => truncate('poalim_ils_account_transactions')); const baseTx: PoalimIlsTransactionInput = { eventDate: '2024-01-15', formattedEventDate: '15/01/2024', serialNumber: 1, activityTypeCode: 1, activityDescription: 'Test txn', textCode: 0, referenceNumber: '1001', referenceCatenatedNumber: 0, valueDate: '2024-01-15', formattedValueDate: '15/01/2024', eventAmount: 500.00, eventActivityTypeCode: 1, currentBalance: 10000.00, internalLinkCode: 0, originalEventCreateDate: 0, transactionType: 'NORMAL', dataGroupCode: 0, expandedEventDate: '20240115', executingBranchNumber: 600, eventId: '1', differentDateIndication: 'N', tableNumber: 0, recordNumber: 0, contraBankNumber: 0, contraBranchNumber: 0, contraAccountNumber: 0, contraAccountTypeCode: 0, marketingOfferContext: 0 as unknown as boolean, commentExistenceSwitch: 0 as unknown as boolean, fieldDescDisplaySwitch: 0 as unknown as boolean, bankNumber: 12, branchNumber: 600, accountNumber: 100000, }; it('inserts new transactions', async () => { const result = await provider.uploadPoalimIlsTransactions([baseTx]); expect(result.inserted).toBe(1); expect(result.skipped).toBe(0); expect(result.insertedIds).toHaveLength(1); }); it('skips duplicate transactions (ON CONFLICT)', async () => { await provider.uploadPoalimIlsTransactions([baseTx]); const result = await provider.uploadPoalimIlsTransactions([baseTx]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(1); expect(result.insertedIds).toHaveLength(0); }); it('returns correct counts for mixed new and duplicate', async () => { await provider.uploadPoalimIlsTransactions([baseTx]); const newTx: PoalimIlsTransactionInput = { ...baseTx, serialNumber: 2, referenceNumber: '1002' }; const result = await provider.uploadPoalimIlsTransactions([baseTx, newTx]); expect(result.inserted).toBe(1); expect(result.skipped).toBe(1); }); it('returns empty result for empty input', async () => { const result = await provider.uploadPoalimIlsTransactions([]); expect(result).toEqual({ inserted: 0, skipped: 0, insertedIds: [], changedTransactions: [], insertedTransactions: [] }); }); }); // ── Poalim Foreign ──────────────────────────────────────────────────────────── describe('uploadPoalimForeignTransactions', () => { beforeEach(() => truncate('poalim_foreign_account_transactions')); const baseTx: PoalimForeignTransactionInput = { executingDate: '2024-01-15', formattedExecutingDate: '15/01/2024', valueDate: '2024-01-15', formattedValueDate: '15/01/2024', originalSystemId: 0, activityDescription: 'FX transfer', eventAmount: 1000.00, currency: Currency.Usd, currentBalance: 5000.00, referenceCatenatedNumber: 0, referenceNumber: '7001', currencyRate: 3.71, rateFixingCode: 0, contraCurrencyCode: 0, eventActivityTypeCode: 1, transactionType: 'NORMAL', rateFixingShortDescription: '', currencyLongDescription: 'US Dollar', activityTypeCode: 1, eventNumber: 1, validityDate: '2024-01-15', commentExistenceSwitch: 0 as unknown as boolean, contraBankNumber: 0, contraBranchNumber: 0, contraAccountNumber: 0, originalEventKey: 0 as unknown as boolean, dataGroupCode: 0 as unknown as boolean, rateFixingDescription: '', currencySwiftCode: 'USD', bankNumber: 12, branchNumber: 600, accountNumber: 100000, }; it('inserts new foreign transactions', async () => { const result = await provider.uploadPoalimForeignTransactions([baseTx]); expect(result.inserted).toBe(1); expect(result.skipped).toBe(0); }); it('skips duplicate foreign transactions', async () => { await provider.uploadPoalimForeignTransactions([baseTx]); const result = await provider.uploadPoalimForeignTransactions([baseTx]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(1); }); }); // ── Poalim Swift ────────────────────────────────────────────────────────────── describe('uploadPoalimSwiftTransactions', () => { beforeEach(() => truncate('poalim_swift_account_transactions')); const baseSwift: PoalimSwiftTransactionInput = { transferCatenatedId: 'SWIFT-001', accountNumber: 100000, branchNumber: 600, bankNumber: 12, startDate: '20240115', amount: 5000.00, currencyCodeCatenatedKey: 'USD', }; it('inserts new swift transactions', async () => { const result = await provider.uploadPoalimSwiftTransactions([baseSwift]); expect(result.inserted).toBe(1); expect(result.skipped).toBe(0); }); it('skips duplicate swift transactions by transferCatenatedId', async () => { await provider.uploadPoalimSwiftTransactions([baseSwift]); const result = await provider.uploadPoalimSwiftTransactions([baseSwift]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(1); }); }); // ── Poalim Securities ───────────────────────────────────────────────────────── describe('uploadPoalimSecurities', () => { beforeEach(() => truncateOwned('poalim_securities')); // Synthetic values only — never lifted from a real bank capture. const baseSecurity: PoalimSecurityInput = { bankNumber: 12, branchNumber: 615, accountNumber: 100000, asOfDate: '2024-01-15T10:00:00.1886720+02:00', securityKey: '1234567', engName: 'Example Corp', hebName: 'אקזמפל', itemType: 'Equity', isEtf: false, isForeign: true, currencyCode: 'USD', exchange: 'NYQ', equityType: 1, allowedOrderDirection: 'BuyAndSell', equitySubType: 1, engSymbol: 'EXMP', hebSymbol: 'EXMP', symbol: 'EXMP', expirationDate: null, stockType: 'Equity', creationEquityNum: null, contractType: null, }; it('inserts new securities', async () => { const result = await provider.uploadPoalimSecurities([baseSecurity]); expect(result.inserted).toBe(1); expect(result.skipped).toBe(0); expect(result.insertedIds).toHaveLength(1); expect(result.changedTransactions).toEqual([]); }); it('stamps owner_id from the auth context', async () => { await provider.uploadPoalimSecurities([baseSecurity]); const { rows } = await pool.query( 'SELECT owner_id FROM accounter_schema.poalim_securities WHERE security_key = $1', [baseSecurity.securityKey], ); expect(rows).toHaveLength(1); expect(rows[0].owner_id).toBe(TEST_OWNER_ID); }); it('is idempotent — re-uploading the same securities inserts nothing', async () => { await provider.uploadPoalimSecurities([baseSecurity]); const result = await provider.uploadPoalimSecurities([baseSecurity]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(1); expect(result.insertedIds).toHaveLength(0); expect(result.changedTransactions).toEqual([]); }); it('reports changed attributes on an existing security', async () => { await provider.uploadPoalimSecurities([baseSecurity]); const result = await provider.uploadPoalimSecurities([ { ...baseSecurity, stockType: 'ETF', isEtf: true }, ]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(1); expect(result.changedTransactions).toHaveLength(1); expect(result.changedTransactions[0].changedFields).toEqual( expect.arrayContaining([ { field: 'stock_type', oldValue: 'Equity', newValue: 'ETF' }, { field: 'is_etf', oldValue: 'false', newValue: 'true' }, ]), ); }); it('does not report a change when only asOfDate moves', async () => { await provider.uploadPoalimSecurities([baseSecurity]); const result = await provider.uploadPoalimSecurities([ { ...baseSecurity, asOfDate: '2024-06-30T10:00:00.1886720+03:00' }, ]); expect(result.changedTransactions).toEqual([]); }); it('treats the same security key on a different account as a distinct row', async () => { await provider.uploadPoalimSecurities([baseSecurity]); const result = await provider.uploadPoalimSecurities([ { ...baseSecurity, accountNumber: 200000 }, ]); expect(result.inserted).toBe(1); }); it('returns correct counts for mixed new and duplicate', async () => { await provider.uploadPoalimSecurities([baseSecurity]); const result = await provider.uploadPoalimSecurities([ baseSecurity, { ...baseSecurity, securityKey: '7654321', engName: 'Other Corp' }, ]); expect(result.inserted).toBe(1); expect(result.skipped).toBe(1); }); it('returns empty result for empty input', async () => { const result = await provider.uploadPoalimSecurities([]); expect(result).toEqual({ inserted: 0, skipped: 0, insertedIds: [], changedTransactions: [], insertedTransactions: [], }); }); }); // ── Poalim Securities Transactions ──────────────────────────────────────────── describe('uploadPoalimSecuritiesTransactions', () => { beforeEach(() => truncateOwned('poalim_securities_transactions')); // Synthetic values only — never lifted from a real bank capture. Timestamps keep // the bank's .NET round-trip shape (7 fractional digits, Israel offset, and the // offset-less 0001-01-01 sentinel) so the fixtures exercise the real input format. const baseTransaction: PoalimSecurityTransactionInput = { bankNumber: 12, branchNumber: 615, accountNumber: 100000, security: '1234567', tradeDate: '2024-01-15T00:00:00.0000000+02:00', valueDate: '2024-01-16T00:00:00.0000000+02:00', settlementDate: '2024-01-17T00:00:00.0000000+02:00', tradeType: 'קניה', transactionType: 'קניה', nv: 10, tradePrice: 100, netValueTradeCurrency: -1000, paymentType: null, paymentDate: null, exDate: null, cancelDate: null, isin: 'US0000000001', symbol: 'EXMP', engName: 'EXAMPLE CORP', hebName: 'אקזמפל', securityGroup: 'מניות ניע"ז', tradeCurrency: 'דולר ארה"ב', settlementCurrency: 'דולר ארה"ב', tradeGrossValueTradeCurrency: 1000, tradeGrossValueNis: 3700, netValueNis: -3.7, netValueSettlementCurrency: -1000, tradeCommissionValueNis: 3.7, israeTaxValue: 0, foreignTaxValueSettlementCurrency: 0, capitalTaxValueSettlementCurrency: 0, isCancelTransaction: 'לא', isJumbo: false, executionDate: '0001-01-01T00:00:00.0000000', lastTranactionDate: '2024-01-15T00:00:00.0000000+02:00', peymentPecentage: 0, tradeCurrnecyRate: 1, fundPlusAccumulatedInerestValue: 1000, }; // A dividend payment on the same security and trade date: the corporate-action // fields are what separate it from the trade above. const dividend: PoalimSecurityTransactionInput = { ...baseTransaction, tradeType: 'דבידנד תשלום', transactionType: 'תשלומים ואירועי חברה', paymentType: 'דיבידנד', paymentDate: '2024-02-05T00:00:00.0000000+02:00', exDate: '2024-01-30T00:00:00.0000000+02:00', nv: 0, tradePrice: 0, netValueTradeCurrency: 22.5, }; it('inserts new executions', async () => { const result = await provider.uploadPoalimSecuritiesTransactions([baseTransaction, dividend]); expect(result.inserted).toBe(2); expect(result.skipped).toBe(0); expect(result.insertedIds).toHaveLength(2); expect(result.changedTransactions).toEqual([]); }); it('stamps owner_id from the auth context', async () => { await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); const { rows } = await pool.query( 'SELECT owner_id FROM accounter_schema.poalim_securities_transactions WHERE security = $1', [baseTransaction.security], ); expect(rows).toHaveLength(1); expect(rows[0].owner_id).toBe(TEST_OWNER_ID); }); it('is idempotent — re-uploading the same executions inserts nothing', async () => { await provider.uploadPoalimSecuritiesTransactions([baseTransaction, dividend]); const result = await provider.uploadPoalimSecuritiesTransactions([baseTransaction, dividend]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(2); expect(result.insertedIds).toHaveLength(0); expect(result.changedTransactions).toEqual([]); }); // The corporate-action dates are part of the dedup key and are null on a trade, // so the unique index has to treat those nulls as equal. it('deduplicates rows whose key includes null corporate-action dates', async () => { await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); const result = await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(1); }); it('treats two same-day executions on one security as distinct rows', async () => { await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); const result = await provider.uploadPoalimSecuritiesTransactions([ { ...baseTransaction, nv: 5, tradePrice: 101, netValueTradeCurrency: -505 }, ]); expect(result.inserted).toBe(1); }); it('treats the same execution on a different account as a distinct row', async () => { await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); const result = await provider.uploadPoalimSecuritiesTransactions([ { ...baseTransaction, accountNumber: 200000 }, ]); expect(result.inserted).toBe(1); }); it('reports restated values on an existing execution', async () => { await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); const result = await provider.uploadPoalimSecuritiesTransactions([ { ...baseTransaction, tradeGrossValueNis: 3750, isCancelTransaction: 'כן' }, ]); expect(result.inserted).toBe(0); expect(result.skipped).toBe(1); expect(result.changedTransactions).toHaveLength(1); expect(result.changedTransactions[0].changedFields).toEqual( expect.arrayContaining([ { field: 'trade_gross_value_nis', oldValue: '3700', newValue: '3750' }, { field: 'is_cancel_transaction', oldValue: 'לא', newValue: 'כן' }, ]), ); }); it('summarises inserted rows with the trade date, type and account', async () => { const result = await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); expect(result.insertedTransactions[0]).toMatchObject({ date: '2024-01-15', description: 'קניה — EXAMPLE CORP', account: '615-100000', }); }); it('returns correct counts for mixed new and duplicate', async () => { await provider.uploadPoalimSecuritiesTransactions([baseTransaction]); const result = await provider.uploadPoalimSecuritiesTransactions([baseTransaction, dividend]); expect(result.inserted).toBe(1); expect(result.skipped).toBe(1); }); it('returns empty result for empty input', async () => { const result = await provider.uploadPoalimSecuritiesTransactions([]); expect(result).toEqual({ inserted: 0, skipped: 0, insertedIds: [], changedTransactions: [], insertedTransactions: [], }); }); });