UNPKG

@accounter/server

Version:
439 lines (396 loc) 17.4 kB
import DataLoader from 'dataloader'; import { Injectable, Scope } from 'graphql-modules'; import { sql } from '@pgtyped/runtime'; import { Currency } from '../../../shared/enums.js'; import { formatCurrency } from '../../../shared/helpers/amount.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import { BusinessesProvider } from '../../financial-entities/providers/businesses.provider.js'; import { FinancialEntitiesProvider } from '../../financial-entities/providers/financial-entities.provider.js'; import { TaxCategoriesProvider } from '../../financial-entities/providers/tax-categories.provider.js'; import { buildSecurityBusinessName } from '../helpers/security-business-name.helper.js'; import type { IGetAllSecurityBusinessesQuery, IGetSecurityBusinessesByIdentifiersQuery, IGetSecurityBusinessesByIdsQuery, IGetSecurityBusinessesByIsinsQuery, IGetSecurityIdentifiersByBusinessIdsQuery, IInsertSecurityBusinessQuery, IInsertSecurityIdentifierQuery, SecurityBusinessDescriptors, SecurityBusinessRow, SecurityIdentifierRow, SecurityIdentifierType, } from '../types.js'; /** * No owner_id predicate anywhere in this file: both tables are FORCE RLS with a * tenant_isolation policy, so going through TenantAwareDBClient scopes every read and write * to the acting tenant — same contract as the poalim_securities tables. */ const getSecurityBusinessesByIds = sql<IGetSecurityBusinessesByIdsQuery>` SELECT * FROM accounter_schema.businesses_securities WHERE id IN $$ids;`; const getAllSecurityBusinesses = sql<IGetAllSecurityBusinessesQuery>` SELECT * FROM accounter_schema.businesses_securities;`; const getSecurityBusinessesByIsins = sql<IGetSecurityBusinessesByIsinsQuery>` SELECT * FROM accounter_schema.businesses_securities WHERE isin = ANY($isins!);`; /** * An explicit `owner_id` predicate, unlike the rest of this file. * * RLS narrows to the request's *scope*, which can span several businesses, while this lookup has * to answer for one owner: `security_identifiers` is unique on * `(owner_id, identifier_type, identifier_value)`, so the same Poalim key legitimately exists * under two of a tenant's businesses when both trade the security. Without the predicate the * batch would see both rows and keep whichever was written last, silently attaching one * business's trade to the other's security. */ const getSecurityBusinessesByIdentifiers = sql<IGetSecurityBusinessesByIdentifiersQuery>` SELECT si.identifier_value, si.owner_id AS identifier_owner_id, bs.* FROM accounter_schema.security_identifiers si INNER JOIN accounter_schema.businesses_securities bs ON bs.id = si.business_id WHERE si.owner_id = $ownerId! AND si.identifier_type = $identifierType! AND si.identifier_value = ANY($identifierValues!);`; const getSecurityIdentifiersByBusinessIds = sql<IGetSecurityIdentifiersByBusinessIdsQuery>` SELECT * FROM accounter_schema.security_identifiers WHERE business_id IN $$businessIds ORDER BY identifier_type, identifier_value;`; const insertSecurityBusiness = sql<IInsertSecurityBusinessQuery>` INSERT INTO accounter_schema.businesses_securities ( id, owner_id, isin, symbol, eng_name, heb_name, exchange, currency_code, item_type, stock_type, is_etf, is_foreign, issuer_country_code ) VALUES ( $id!, $ownerId!, $isin!, $symbol, $engName, $hebName, $exchange, $currencyCode, $itemType, $stockType, $isEtf, $isForeign, $issuerCountryCode ) RETURNING *;`; const insertSecurityIdentifier = sql<IInsertSecurityIdentifierQuery>` INSERT INTO accounter_schema.security_identifiers ( owner_id, business_id, identifier_type, identifier_value ) VALUES ($ownerId!, $businessId!, $identifierType!, $identifierValue!) ON CONFLICT (owner_id, identifier_type, identifier_value) DO NOTHING;`; /** * `businesses_securities.currency_code` is `accounter_schema.currency`, but the securities * feeds are not: Poalim spells its currencies out in Hebrew (`דולר ארה"ב`), other sources use * ISO codes. `formatCurrency` knows both, and its nullable form returns null for a label it * does not recognize rather than throwing — a security whose currency cannot be resolved is * still worth having, with the column left empty. * * The guard matters: `formatCurrency` reads a *missing* label as ILS, which would stamp every * security that reports no currency as a shekel one. */ function toCurrency(rawCurrency: string | null | undefined): Currency | null { const label = rawCurrency?.trim(); return label ? formatCurrency(label, true) : null; } /** * A source's name for a security, *within an owner*. * * The owner is not optional: `(owner_id, identifier_type, identifier_value)` is what the relation * is unique on, and reads span the request's whole business scope. A key without it cannot * identify one security business. */ export type IdentifierKey = { ownerId: string; type: SecurityIdentifierType; value: string; }; /** Cache/lookup key for the identifier relation, which is unique on all three parts. */ function identifierCacheKey(ownerId: string, type: SecurityIdentifierType, value: string): string { return `${ownerId}:${type}:${value}`; } /** Batch grouping key — one query per (owner, identifier type). */ function identifierGroupKey(ownerId: string, type: SecurityIdentifierType): string { return `${ownerId}:${type}`; } function splitIdentifierGroupKey(group: string): [string, SecurityIdentifierType] { const separator = group.indexOf(':'); return [group.slice(0, separator), group.slice(separator + 1) as SecurityIdentifierType]; } @Injectable({ scope: Scope.Operation, global: true, }) export class SecurityBusinessesProvider { constructor( private db: TenantAwareDBClient, private adminContextProvider: AdminContextProvider, private financialEntitiesProvider: FinancialEntitiesProvider, private businessesProvider: BusinessesProvider, private taxCategoriesProvider: TaxCategoriesProvider, ) {} private async batchSecurityBusinessesByIds(ids: readonly string[]) { const rows = await getSecurityBusinessesByIds.run({ ids: [...new Set(ids)] }, this.db); const byId = new Map(rows.map(row => [row.id, row])); return ids.map(id => byId.get(id) ?? null); } public getSecurityBusinessByIdLoader = new DataLoader((ids: readonly string[]) => this.batchSecurityBusinessesByIds(ids), ); private allSecurityBusinessesPromise: Promise<SecurityBusinessRow[]> | null = null; /** * Every security business of the acting tenant. Cached per request because charge typing and * account resolution ask for it on every transaction they touch. */ public getAllSecurityBusinesses(): Promise<SecurityBusinessRow[]> { this.allSecurityBusinessesPromise ??= getAllSecurityBusinesses .run(undefined, this.db) .then(rows => { rows.map(row => this.getSecurityBusinessByIdLoader.prime(row.id, row)); return rows; }); return this.allSecurityBusinessesPromise; } public async getAllSecurityBusinessIds(): Promise<Set<string>> { const rows = await this.getAllSecurityBusinesses(); return new Set(rows.map(row => row.id)); } public async isSecurityBusiness(businessId: string): Promise<boolean> { return (await this.getAllSecurityBusinessIds()).has(businessId); } private async batchSecurityBusinessesByIdentifiers(keys: readonly IdentifierKey[]) { // One query per (owner, identifier type); in practice a batch carries a single pair. const valuesByOwnerAndType = new Map<string, Set<string>>(); for (const key of keys) { const group = identifierGroupKey(key.ownerId, key.type); const values = valuesByOwnerAndType.get(group); if (values) { values.add(key.value); } else { valuesByOwnerAndType.set(group, new Set([key.value])); } } const found = new Map<string, SecurityBusinessRow>(); await Promise.all( [...valuesByOwnerAndType].map(async ([group, values]) => { const [ownerId, identifierType] = splitIdentifierGroupKey(group); const rows = await getSecurityBusinessesByIdentifiers.run( { ownerId, identifierType, identifierValues: [...values] }, this.db, ); for (const { identifier_value, identifier_owner_id, ...securityBusiness } of rows) { found.set( identifierCacheKey(identifier_owner_id, identifierType, identifier_value), securityBusiness, ); } }), ); return keys.map(key => found.get(identifierCacheKey(key.ownerId, key.type, key.value)) ?? null); } public getSecurityBusinessByIdentifierLoader = new DataLoader( (keys: readonly IdentifierKey[]) => this.batchSecurityBusinessesByIdentifiers(keys), { cacheKeyFn: key => identifierCacheKey(key.ownerId, key.type, key.value) }, ); private async batchIdentifiersByBusinessIds(businessIds: readonly string[]) { const rows = await getSecurityIdentifiersByBusinessIds.run( { businessIds: [...new Set(businessIds)] }, this.db, ); const byBusinessId = new Map<string, SecurityIdentifierRow[]>(); for (const row of rows) { const group = byBusinessId.get(row.business_id); if (group) { group.push(row); } else { byBusinessId.set(row.business_id, [row]); } } return businessIds.map(businessId => byBusinessId.get(businessId) ?? []); } public getIdentifiersByBusinessIdLoader = new DataLoader((businessIds: readonly string[]) => this.batchIdentifiersByBusinessIds(businessIds), ); /** The security businesses already created for these ISINs, keyed by ISIN. */ public async getSecurityBusinessesByIsins( isins: readonly string[], ): Promise<Map<string, SecurityBusinessRow>> { if (isins.length === 0) { return new Map(); } const rows = await getSecurityBusinessesByIsins.run({ isins: [...new Set(isins)] }, this.db); rows.map(row => this.getSecurityBusinessByIdLoader.prime(row.id, row)); return new Map(rows.map(row => [row.isin, row])); } private async getSecurityBusinessByIsin(isin: string): Promise<SecurityBusinessRow | null> { return (await this.getSecurityBusinessesByIsins([isin])).get(isin) ?? null; } /** * The business a security is represented by, creating it on first sight. * * Idempotent by ISIN — see `createSecurityBusiness` for how a race is settled. */ public async ensureSecurityBusiness( descriptors: SecurityBusinessDescriptors, ): Promise<SecurityBusinessRow> { const existing = await this.getSecurityBusinessByIsin(descriptors.isin); return existing ?? this.createSecurityBusiness(descriptors); } /** * The same for a batch, in one lookup: an ingest introduces a whole portfolio at once, and * asking per ISIN whether it exists costs a round trip for every security every scrape. * Only the ISINs with no business yet are created. */ public async ensureSecurityBusinesses( descriptorsList: readonly SecurityBusinessDescriptors[], ): Promise<Map<string, SecurityBusinessRow>> { const byIsin = new Map(descriptorsList.map(descriptors => [descriptors.isin, descriptors])); if (byIsin.size === 0) { return new Map(); } const securityBusinesses = await this.getSecurityBusinessesByIsins([...byIsin.keys()]); for (const [isin, descriptors] of byIsin) { if (!securityBusinesses.has(isin)) { securityBusinesses.set(isin, await this.createSecurityBusiness(descriptors)); } } return securityBusinesses; } /** * Creates the business behind a security, assuming the caller has established there is none. * * Concurrent ingests race on the (owner_id, isin) unique index, and the loser rolls its whole * transaction back — financial entity and business included — then re-reads the winner's row, * so a race can't leave a half-built business behind. That fallback is also what makes the * caller's "there is none" only have to be true at the time it looked. * * Sort code, IRS code, country and tax category are inherited from the tenant's general * foreign-securities business, so a security behaves like it everywhere those fields drive * reporting. */ private async createSecurityBusiness( descriptors: SecurityBusinessDescriptors, ): Promise<SecurityBusinessRow> { const adminContext = await this.adminContextProvider.getVerifiedAdminContext(); const { ownerId } = adminContext; const generalBusinessId = adminContext.foreignSecurities.foreignSecuritiesBusinessId; const [generalBusiness, generalTaxCategory] = await Promise.all([ generalBusinessId ? this.businessesProvider.getBusinessByIdLoader.load(generalBusinessId) : null, generalBusinessId ? this.taxCategoriesProvider.taxCategoryByBusinessIDsLoader.load(generalBusinessId) : null, ]); try { const created = await this.db.transaction(async client => { const [financialEntity] = await this.financialEntitiesProvider.insertFinancialEntity( { ownerId, name: buildSecurityBusinessName(descriptors), sortCode: generalBusiness?.sort_code ?? null, type: 'business', irsCode: generalBusiness?.irs_code ?? null, isActive: true, }, client, ); if (!financialEntity) { throw new Error( `Failed to create financial entity for security ISIN="${descriptors.isin}"`, ); } const business = await this.businessesProvider.insertBusiness( { id: financialEntity.id, ownerId, // businesses.country is NOT NULL and FK'd to countries.code, so it can never be // passed as null; ISR is the column's own default. country: generalBusiness?.country ?? 'ISR', hebrewName: descriptors.hebName?.trim() || null, // A security has no contact details, VAT number or document expectations of its // own; the columns are spelled out because the insert takes a full tuple. address: null, city: null, zipCode: null, email: null, website: null, phoneNumber: null, governmentId: null, exemptDealer: false, optionalVat: false, isReceiptEnough: false, isDocumentsOptional: false, pcn874RecordTypeOverride: null, // No phrases: a security must never win a description-based suggestion. suggestions: null, }, client, ); if (!business) { throw new Error(`Failed to create business for security ISIN="${descriptors.isin}"`); } // Last on purpose: the unique index on (owner_id, isin) is what arbitrates the race, // and everything above it is rolled back when this insert loses. const [securityBusiness] = await insertSecurityBusiness.run( { id: financialEntity.id, ownerId, isin: descriptors.isin, symbol: descriptors.symbol?.trim() || null, engName: descriptors.engName?.trim() || null, hebName: descriptors.hebName?.trim() || null, exchange: descriptors.exchange?.trim() || null, currencyCode: toCurrency(descriptors.currencyCode), itemType: descriptors.itemType ?? null, stockType: descriptors.stockType ?? null, isEtf: descriptors.isEtf ?? null, isForeign: descriptors.isForeign ?? null, issuerCountryCode: descriptors.issuerCountryCode ?? null, }, client, ); if (!securityBusiness) { throw new Error(`Failed to create security record for ISIN="${descriptors.isin}"`); } if (generalTaxCategory) { await this.taxCategoriesProvider.insertBusinessTaxCategory({ businessId: financialEntity.id, ownerId, taxCategoryId: generalTaxCategory.id, }); } return securityBusiness; }); this.clearCache(); return created; } catch (error) { const winner = await this.getSecurityBusinessByIsin(descriptors.isin); if (winner) { return winner; } throw error; } } public async linkIdentifier( businessId: string, identifierType: SecurityIdentifierType, identifierValue: string, ): Promise<void> { const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext(); await insertSecurityIdentifier.run( { ownerId, businessId, identifierType, identifierValue }, this.db, ); this.getSecurityBusinessByIdentifierLoader.clear({ ownerId, type: identifierType, value: identifierValue, }); this.getIdentifiersByBusinessIdLoader.clear(businessId); } public clearCache() { this.allSecurityBusinessesPromise = null; this.getSecurityBusinessByIdLoader.clearAll(); this.getSecurityBusinessByIdentifierLoader.clearAll(); this.getIdentifiersByBusinessIdLoader.clearAll(); } }