UNPKG

@accounter/server

Version:
201 lines 9.18 kB
import { __decorate, __metadata } from "tslib"; import DataLoader from 'dataloader'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import { FinancialAccountsProvider } from '../../financial-accounts/providers/financial-accounts.provider.js'; import { FinancialBankAccountsProvider } from '../../financial-accounts/providers/financial-bank-accounts.provider.js'; import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js'; import { DEFAULT_DATE_WINDOW_DAYS, matchSecurityExecutions, } from '../helpers/match-security-executions.helper.js'; import { extractSecurityKeys } from '../helpers/security-key.helper.js'; /** * No owner_id predicate: accounter_schema.poalim_securities is FORCE RLS with a * tenant_isolation policy, so going through TenantAwareDBClient scopes this to the * acting tenant. The dedup key includes branch/account, so one tenant can hold the * same security in several accounts — DISTINCT ON keeps the freshest scrape. */ const getSecuritiesByKeys = sql ` SELECT DISTINCT ON (security_key) id, security_key, eng_name, heb_name, symbol, eng_symbol, heb_symbol, item_type, stock_type, exchange, currency_code, is_etf, is_foreign, as_of_date FROM accounter_schema.poalim_securities WHERE security_key = ANY($securityKeys!) ORDER BY security_key, as_of_date DESC;`; /** * A coarse prefilter, not the match itself: the ANY(...) predicates form a cross-product over * the charge's keys and accounts, and the date range spans every transaction on the charge. * `matchSecurityExecutions` does the exact per-row pairing in memory — same shape as * `fetchPoalimSecuritiesTransactionsByKeys` in the ingestion provider. * * Tenant scoping is RLS again (FORCE ROW LEVEL SECURITY + tenant_isolation), hence no * owner_id predicate. */ const getSecurityExecutions = sql ` SELECT id, security, bank_number, branch_number, account_number, trade_date, value_date, settlement_date, payment_date, trade_type, transaction_type, nv, trade_price, trade_gross_value_trade_currency, net_value_trade_currency, net_value_settlement_currency, net_value_nis, trade_currency, trade_commission_value_trade_currency, management_fees_value_trade_currency, israe_tax_value, nominal_profit_loss_nis, real_profit_loss_nis, payment_type, symbol, isin FROM accounter_schema.poalim_securities_transactions WHERE security = ANY($securities!) AND bank_number = ANY($bankNumbers!) AND branch_number = ANY($branchNumbers!) AND account_number = ANY($accountNumbers!) AND ( trade_date BETWEEN $fromDate! AND $toDate! OR value_date BETWEEN $fromDate! AND $toDate! OR settlement_date BETWEEN $fromDate! AND $toDate! OR payment_date BETWEEN $fromDate! AND $toDate! );`; const MS_PER_DAY = 86_400_000; let ForeignSecuritiesProvider = class ForeignSecuritiesProvider { db; transactionsProvider; financialAccountsProvider; financialBankAccountsProvider; constructor(db, transactionsProvider, financialAccountsProvider, financialBankAccountsProvider) { this.db = db; this.transactionsProvider = transactionsProvider; this.financialAccountsProvider = financialAccountsProvider; this.financialBankAccountsProvider = financialBankAccountsProvider; } async batchSecuritiesByKeys(securityKeys) { const securities = await getSecuritiesByKeys.run({ securityKeys: [...securityKeys] }, this.db); // DISTINCT ON in the query guarantees one row per key, so a plain Map is enough. const securityByKey = new Map(securities.map(security => [security.security_key, security])); return securityKeys.map(key => securityByKey.get(key) ?? null); } securityByKeyLoader = new DataLoader((keys) => this.batchSecuritiesByKeys(keys)); /** * The Poalim identity (bank/branch/account) of each account the given transactions touch. * `financial_accounts.account_number` is text while the poalim_* tables store it as an * integer, so non-numeric account numbers (and non-bank accounts, which have no * financial_bank_accounts row) simply drop out — they can never match an execution. */ async getAccountTuples(accountIds) { const tuples = new Map(); await Promise.all(accountIds.map(async (accountId) => { const [account, bankAccount] = await Promise.all([ this.financialAccountsProvider.getFinancialAccountByAccountIDLoader.load(accountId), this.financialBankAccountsProvider.getFinancialBankAccountByIdLoader.load(accountId), ]); if (!account || !bankAccount) { return; } const accountNumber = Number(account.account_number); if (!Number.isInteger(accountNumber)) { return; } tuples.set(accountId, { bankNumber: bankAccount.bank_number, branchNumber: bankAccount.branch_number, accountNumber, }); })); return tuples; } /** * Ingested portfolio executions matched to the charge's transactions, grouped by security * key. Returns an empty map when the charge touches no resolvable Poalim account, so a * charge whose accounts predate the bank-account backfill degrades to "no activity" rather * than erroring. */ async getMatchedExecutions(transactions, securityKeys) { const accountTuples = await this.getAccountTuples([ ...new Set(transactions.map(transaction => transaction.account_id)), ]); if (accountTuples.size === 0) { return new Map(); } const dates = transactions.flatMap(transaction => [transaction.event_date, transaction.debit_date_override ?? transaction.debit_date] .filter((date) => date != null) .map(date => date.getTime())); if (dates.length === 0) { return new Map(); } const tuples = [...accountTuples.values()]; const candidates = await getSecurityExecutions.run({ securities: [...securityKeys], bankNumbers: [...new Set(tuples.map(tuple => tuple.bankNumber))], branchNumbers: [...new Set(tuples.map(tuple => tuple.branchNumber))], accountNumbers: [...new Set(tuples.map(tuple => tuple.accountNumber))], fromDate: new Date(Math.min(...dates) - DEFAULT_DATE_WINDOW_DAYS * MS_PER_DAY), toDate: new Date(Math.max(...dates) + DEFAULT_DATE_WINDOW_DAYS * MS_PER_DAY), }, this.db); return matchSecurityExecutions(transactions, candidates, accountTuples); } /** * The securities a charge's transactions reference, keyed off the security key each * description carries. Keys with no ingested row are still returned, with a null * `details`, so a stale or missing scrape is visible instead of silently dropping data. */ async getChargeSecurities(chargeId) { const transactions = await this.transactionsProvider.transactionsByChargeIDLoader.load(chargeId); const transactionIdsByKey = new Map(); for (const transaction of transactions) { for (const key of extractSecurityKeys(transaction.source_description)) { const transactionIds = transactionIdsByKey.get(key); if (transactionIds) { transactionIds.push(transaction.id); } else { transactionIdsByKey.set(key, [transaction.id]); } } } if (transactionIdsByKey.size === 0) { return []; } const keys = [...transactionIdsByKey.keys()].sort(); const [details, executionsByKey] = await Promise.all([ this.securityByKeyLoader.loadMany(keys), this.getMatchedExecutions(transactions, keys), ]); return keys.map((key, index) => { const detail = details[index]; return { id: `${chargeId}-${key}`, securityKey: key, // loadMany surfaces a rejected key as an Error rather than throwing; treat it // the same as "not ingested" so one bad key can't blank the whole section. details: detail instanceof Error ? null : detail, transactionIds: transactionIdsByKey.get(key) ?? [], executions: executionsByKey.get(key) ?? [], }; }); } }; ForeignSecuritiesProvider = __decorate([ Injectable({ scope: Scope.Operation, global: true, }), __metadata("design:paramtypes", [TenantAwareDBClient, TransactionsProvider, FinancialAccountsProvider, FinancialBankAccountsProvider]) ], ForeignSecuritiesProvider); export { ForeignSecuritiesProvider }; //# sourceMappingURL=foreign-securities.provider.js.map