UNPKG

@accounter/server

Version:
209 lines (199 loc) 9.52 kB
import { GraphQLError } from 'graphql'; import { Currency } from '../../../shared/enums.js'; import { formatFinancialAmount } from '../../../shared/helpers/amount.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { ChargesProvider } from '../../charges/providers/charges.provider.js'; import { BusinessesProvider } from '../../financial-entities/providers/businesses.provider.js'; import { tradeTypeToRaw, transactionTypeToRaw, } from '../helpers/security-execution-enums.helper.js'; import { calculateSecurityPosition, isOpenPosition } from '../helpers/security-position.helper.js'; import { ForeignSecuritiesProvider } from '../providers/foreign-securities.provider.js'; import { SecurityBusinessesProvider } from '../providers/security-businesses.provider.js'; import type { ForeignSecuritiesModule } from '../types.js'; /** * Falls back to the schema default, which graphql-js applies for an omitted argument but not for * an explicit `null`. */ const DEFAULT_SECURITY_EXECUTIONS_LIMIT = 100; /** An amount the executions imply, or null when they imply nothing. */ const positionAmount = (value: number | null, currency: string | null) => value == null || currency == null ? null : formatFinancialAmount(value, currency); export const securityBusinessesResolvers: ForeignSecuritiesModule.Resolvers = { Query: { allSecurityBusinesses: async (_, __, { injector }) => { const securityBusinesses = await injector .get(SecurityBusinessesProvider) .getAllSecurityBusinesses(); const businesses = await injector .get(BusinessesProvider) .getBusinessByIdLoader.loadMany(securityBusinesses.map(security => security.id)); // A security row whose business vanished is a broken FK, not a reason to fail the query. return businesses.filter( (business): business is Exclude<(typeof businesses)[number], Error | undefined | null> => business != null && !(business instanceof Error), ); }, securityHoldings: async (_, { includeClosed }, { injector }) => { const securityBusinesses = await injector .get(SecurityBusinessesProvider) .getAllSecurityBusinesses(); const executionsByBusinessId = await injector .get(ForeignSecuritiesProvider) .getExecutionsBySecurityBusiness(); return ( securityBusinesses .map(securityBusiness => ({ id: securityBusiness.id, security: securityBusiness, position: { id: securityBusiness.id, ...calculateSecurityPosition(executionsByBusinessId.get(securityBusiness.id) ?? []), }, })) // Closed positions are the bulk of a long-lived portfolio, so the screen asks for them // explicitly. A security with nothing ingested against it reads as closed too. .filter(holding => includeClosed || isOpenPosition(holding.position)) // Ordered here so an unsorted render is stable across requests. .sort((a, b) => (a.security.eng_name ?? a.security.isin).localeCompare( b.security.eng_name ?? b.security.isin, ), ) ); }, securityBusinessHistory: async (_, { businessId }, { injector }) => { const securityBusiness = await injector .get(SecurityBusinessesProvider) .getSecurityBusinessByIdLoader.load(businessId); if (!securityBusiness) { throw new GraphQLError(`Business ID="${businessId}" is not a security`); } const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext(); const { executions, transactionByExecutionId } = await injector .get(ForeignSecuritiesProvider) .getSecurityBusinessHistory(businessId, ownerId); return { id: businessId, security: securityBusiness, position: { id: businessId, ...calculateSecurityPosition(executions) }, executions: executions.map(execution => ({ id: execution.id, execution, securityBusinessId: businessId, transaction: transactionByExecutionId.get(execution.id) ?? null, })), }; }, securityExecutions: async (_, { filters, page, limit, includeCharges }, { injector }) => { const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext(); return injector.get(ForeignSecuritiesProvider).getSecurityExecutionsPage({ ownerId, page: page ?? 0, limit: limit ?? DEFAULT_SECURITY_EXECUTIONS_LIMIT, includeCharges: includeCharges ?? false, filters: { securityBusinessIds: filters?.securityBusinessIds, isins: filters?.isins, symbols: filters?.symbols, fromTradeDate: filters?.fromTradeDate, toTradeDate: filters?.toTradeDate, // Translated here rather than in the provider so the bank's Hebrew stays confined to // the enum helper — the provider filters on labels and never learns what they say. rawTradeTypes: filters?.tradeTypes?.map(tradeType => tradeTypeToRaw[tradeType]), rawTransactionTypes: filters?.transactionTypes?.map( transactionType => transactionTypeToRaw[transactionType], ), }, }); }, }, PaginatedSecurityExecutions: { nodes: executionsPage => executionsPage.nodes, pageInfo: executionsPage => ({ totalRecords: executionsPage.totalRecords, totalPages: Math.ceil(executionsPage.totalRecords / executionsPage.pageSize), currentPage: executionsPage.currentPage, pageSize: executionsPage.pageSize, }), }, SecurityHistoryExecution: { id: historyExecution => historyExecution.id, execution: historyExecution => historyExecution.execution, securityBusiness: async (historyExecution, _, { injector }) => { const securityBusiness = await injector .get(SecurityBusinessesProvider) .getSecurityBusinessByIdLoader.load(historyExecution.securityBusinessId); if (!securityBusiness) { throw new GraphQLError( `Business ID="${historyExecution.securityBusinessId}" is not a security`, ); } return securityBusiness; }, // Transaction concrete types are mapped to their id (see codegen.ts mappers); Charge is // mapped to its row, so it has to be loaded. transaction: historyExecution => historyExecution.transaction?.id ?? null, charge: async (historyExecution, _, { injector }) => { const chargeId = historyExecution.transaction?.charge_id; if (!chargeId) { return null; } return (await injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId)) ?? null; }, }, SecurityPosition: { id: position => position.id, quantity: position => position.quantity, // A security with no ingested executions has no currency to report an amount in, and // `formatFinancialAmount` would fall back to the local one — turning "nothing is known" // into a confident ILS 0. Null is the honest answer; the client renders it as an em dash. averageCost: position => positionAmount(position.averageCost, position.currency), totalBought: position => positionAmount(position.totalBought, position.currency), totalSold: position => positionAmount(position.totalSold, position.currency), historyStartDate: position => position.historyStartDate, lastExecutionDate: position => position.lastExecutionDate, }, LtdFinancialEntity: { securityInfo: async (business, _, { injector }) => (await injector .get(SecurityBusinessesProvider) .getSecurityBusinessByIdLoader.load(business.id)) ?? null, }, SecurityBusiness: { id: securityBusiness => securityBusiness.id, ownerId: securityBusiness => securityBusiness.owner_id, business: async (securityBusiness, _, { injector }) => { const business = await injector .get(BusinessesProvider) .getBusinessByIdLoader.load(securityBusiness.id); if (!business) { throw new GraphQLError(`Business ID="${securityBusiness.id}" not found`); } return business; }, isin: securityBusiness => securityBusiness.isin, identifiers: async (securityBusiness, _, { injector }) => injector .get(SecurityBusinessesProvider) .getIdentifiersByBusinessIdLoader.load(securityBusiness.id), symbol: securityBusiness => securityBusiness.symbol, engName: securityBusiness => securityBusiness.eng_name, hebName: securityBusiness => securityBusiness.heb_name, exchange: securityBusiness => securityBusiness.exchange, // The column is accounter_schema.currency, whose values are the GraphQL enum's — the same // cast the financial-accounts resolvers use for their currency columns. currencyCode: securityBusiness => securityBusiness.currency_code as Currency | null, itemType: securityBusiness => securityBusiness.item_type, stockType: securityBusiness => securityBusiness.stock_type, isEtf: securityBusiness => securityBusiness.is_etf, isForeign: securityBusiness => securityBusiness.is_foreign, issuerCountryCode: securityBusiness => securityBusiness.issuer_country_code, }, SecurityIdentifier: { id: identifier => identifier.id, type: identifier => identifier.identifier_type, value: identifier => identifier.identifier_value, }, };