diff --git a/.changeset/security-business-tab.md b/.changeset/security-business-tab.md new file mode 100644 index 000000000..02e0f2628 --- /dev/null +++ b/.changeset/security-business-tab.md @@ -0,0 +1,36 @@ +--- +'@accounter/server': minor +'@accounter/client': minor +--- + +Give each security a page: its holding and its full execution history. + +A security business had no view of its own — its trades were visible one charge at a time, and the +only way to see what is held was to add the executions up by hand. The business page now grows a +**Security** tab, shown for any business carrying a `businesses_securities` row, with: + +- **a position summary** — units held, weighted average cost per unit bought, totals bought and sold, + alongside the security's ISIN, symbol, exchange, currency, type and ETF/foreign badges; +- **the full execution history** across every Poalim key the security is known by, oldest first, each + row linking to the charge behind its cash movement (and reading as `—` when no movement matched). + +A security with no ingested executions reports null amounts rather than zeroes: there is no +currency to state them in, and `formatFinancialAmount` would fall back to the local one and turn +"nothing is known" into a confident ILS 0. The card renders those as an em dash. + +The position is **derived, and says so**: holdings are not scraped, so the card states the date the +ingested history starts from and that anything held before it is not counted. Corporate actions that +change the unit count without an execution row are invisible for the same reason. Cash-only actions +(dividends, interest) leave the count alone; buys, distributions and transfers in add; sales, +redemptions and transfers out subtract. + +Schema: `Query.securityBusinessHistory(businessId: UUID!)` returning `SecurityBusinessHistory` +(`SecurityPosition` + `SecurityHistoryExecution`, an execution with the transaction and charge behind +it). The pairing is the same one the charge view shows, read from the other end — the security +business's own transactions matched against its executions by +`matchExecutionsToTransactions`. The tab runs its own query, like the Charges/Transactions/Ledger +tabs, so a business page does not pay for execution history it never shows. + +The charge panel's "Portfolio activity" table and the new one are now the same component +(`components/securities/security-executions-table.tsx`) over one fragment, so the two always read +alike; the charge panel gains nothing else. diff --git a/codegen.ts b/codegen.ts index 9e0fe4a32..d22319e8b 100644 --- a/codegen.ts +++ b/codegen.ts @@ -156,6 +156,11 @@ const config: CodegenConfig = { SalaryCharge: '../modules/charges/types.js#IGetChargesByIdsResult', Security: '../modules/foreign-securities/types.js#SecurityRow', SecurityBusiness: '../modules/foreign-securities/types.js#SecurityBusinessRow', + SecurityBusinessHistory: + '../modules/foreign-securities/types.js#SecurityBusinessHistoryProto', + SecurityHistoryExecution: + '../modules/foreign-securities/types.js#SecurityHistoryExecutionProto', + SecurityPosition: '../modules/foreign-securities/types.js#SecurityPositionWithIdProto', SecurityExecution: '../modules/foreign-securities/types.js#SecurityExecutionRow', SecurityIdentifier: '../modules/foreign-securities/types.js#SecurityIdentifierRow', Shaam6111Report: '../modules/reports/types.js#Shaam6111ReportProto', diff --git a/packages/client/src/components/business/index.tsx b/packages/client/src/components/business/index.tsx index 3d35a1dfe..f0b75453d 100644 --- a/packages/client/src/components/business/index.tsx +++ b/packages/client/src/components/business/index.tsx @@ -2,6 +2,7 @@ import { useContext, useEffect, type ReactElement } from 'react'; import { ArrowLeftRight, Building2, + CandlestickChart, ChartLine, DollarSign, FileCheck, @@ -31,6 +32,7 @@ import { ConfigurationsSection } from './configurations-section.js'; import { ContactInfoSection } from './contact-info-section.js'; import { DocumentsSection } from './documents-section.js'; import { LedgerSection } from './ledger-section.js'; +import { SecuritySection } from './security-section.js'; import { TransactionsSection } from './transactions-section.js'; // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen @@ -44,6 +46,9 @@ import { TransactionsSection } from './transactions-section.js'; adminInfo { id } + securityInfo { + id + } } ...ClientIntegrationsSection ...BusinessHeader @@ -79,6 +84,7 @@ export default function Business({ data, refetchBusiness }: Props): ReactElement const isClient = 'clientInfo' in business && !!business.clientInfo; const isAdmin = 'adminInfo' in business && !!business.adminInfo; + const isSecurity = 'securityInfo' in business && !!business.securityInfo; return (
@@ -136,6 +142,15 @@ export default function Business({ data, refetchBusiness }: Props): ReactElement Balance + {isSecurity && ( + + + Security + + )} {isClient && ( <> + {isSecurity && ( + + + + )} + {isClient && ( <> diff --git a/packages/client/src/components/business/security-section.tsx b/packages/client/src/components/business/security-section.tsx new file mode 100644 index 000000000..da9546fe2 --- /dev/null +++ b/packages/client/src/components/business/security-section.tsx @@ -0,0 +1,156 @@ +import type { ReactElement } from 'react'; +import { useQuery } from 'urql'; +import { Badge } from '@/components/ui/badge.js'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.js'; +import { BusinessSecuritySectionDocument } from '@/gql/graphql.js'; +import { + formatSecurityDate, + formatSecurityNumber, + SecurityExecutionsTable, +} from '../securities/security-executions-table.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen +/* GraphQL */ ` + query BusinessSecuritySection($businessId: UUID!) { + securityBusinessHistory(businessId: $businessId) { + id + security { + id + isin + symbol + engName + hebName + exchange + currencyCode + itemType + stockType + isEtf + isForeign + identifiers { + id + type + value + } + } + position { + id + quantity + averageCost { + formatted + } + totalBought { + formatted + } + totalSold { + formatted + } + historyStartDate + lastExecutionDate + } + executions { + id + charge { + id + } + execution { + id + ...SecurityExecutionFields + } + } + } + } +`; + +interface Props { + businessId: string; +} + +const Stat = ({ label, value }: { label: string; value: string }): ReactElement => ( +
+
{label}
+
{value}
+
+); + +export function SecuritySection({ businessId }: Props): ReactElement { + const [{ data, fetching }] = useQuery({ + query: BusinessSecuritySectionDocument, + variables: { businessId }, + }); + + if (fetching) { + return
Loading security history...
; + } + + const history = data?.securityBusinessHistory; + if (!history) { + return
No security details found for this business
; + } + + const { security, position, executions } = history; + const poalimKeys = security.identifiers + .filter(identifier => identifier.type === 'POALIM_SECURITY_KEY') + .map(identifier => identifier.value); + + return ( +
+ + +
+ + {security.engName ?? security.isin} + {security.symbol && ({security.symbol})} + + {security.hebName && {security.hebName}} +
+ {security.isin} + {security.exchange && {security.exchange}} + {security.currencyCode && {security.currencyCode}} + {security.itemType && {security.itemType}} + {security.stockType && {security.stockType}} + {security.isEtf && ETF} + {security.isForeign && Foreign} +
+
+
+ +
+ {/* Quantities can be fractional for ETFs and mutual funds. */} + + + + +
+
+ {position.historyStartDate + ? `Derived from ingested trades since ${formatSecurityDate(position.historyStartDate)}` + + (position.lastExecutionDate + ? `, last one ${formatSecurityDate(position.lastExecutionDate)}` + : '') + + '. Holdings are not scraped, so anything held before that date is not counted here.' + : 'No executions ingested for this security yet.'} + {poalimKeys.length > 0 && ` · Poalim key ${poalimKeys.join(', ')}`} +
+
+
+ + + + Execution history + Every ingested action in this security, oldest first + + +
+ ({ + execution: row.execution, + chargeId: row.charge?.id ?? null, + }))} + /> +
+
+
+
+ ); +} diff --git a/packages/client/src/components/charges/extended-info/foreign-securities-info.tsx b/packages/client/src/components/charges/extended-info/foreign-securities-info.tsx index cfb5b426f..27a1340d8 100644 --- a/packages/client/src/components/charges/extended-info/foreign-securities-info.tsx +++ b/packages/client/src/components/charges/extended-info/foreign-securities-info.tsx @@ -5,7 +5,7 @@ import { type ForeignSecuritiesChargeInfoFragment, } from '../../../gql/graphql.js'; import { getFragmentData, type FragmentType } from '../../../gql/index.js'; -import { formatStringifyAmount } from '../../../helpers/numbers.js'; +import { SecurityExecutionsTable } from '../../securities/security-executions-table.js'; import { Account, Amount, @@ -45,26 +45,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. } executions { id - tradeDate - valueDate - settlementDate - tradeType - transactionType - paymentType - quantity - tradePrice - netValue { - formatted - } - tradeCommission { - formatted - } - managementFees { - formatted - } - israelTaxValue { - formatted - } + ...SecurityExecutionFields } } } @@ -132,7 +113,11 @@ const SecuritySection = ({ security }: { security: ChargeSecurity }): ReactEleme
)} - + {security.executions.length > 0 && ( + + ({ execution }))} /> + + )} ); }; @@ -213,72 +198,3 @@ const SecurityTransactionsTable = ({ ) : null; }; - -const formatNumber = (value: number | null | undefined, digits = 2): string => - value == null ? '' : formatStringifyAmount(value, digits); - -const formatDate = (value: string | Date | null | undefined): string => - value ? new Date(value).toLocaleDateString() : ''; - -/** The bank's enum values read better as words than as SCREAMING_SNAKE_CASE. */ -const humanizeEnum = (value: string): string => - value - .toLowerCase() - .split('_') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - -const SecurityExecutionsTable = ({ - executions, -}: { - executions: ChargeSecurity['executions']; -}): ReactNode => - executions.length ? ( - - - - - Trade Date - Value Date - Type - Quantity - Price - Net Value - Commission - Tax - - - - {executions.map(execution => ( - - {formatDate(execution.tradeDate)} - - {formatDate(execution.valueDate ?? execution.settlementDate)} - - -
- {humanizeEnum(execution.tradeType)} - {execution.transactionType !== execution.tradeType && ( - {humanizeEnum(execution.transactionType)} - )} - {execution.paymentType && ( - {humanizeEnum(execution.paymentType)} - )} -
-
- {/* Quantities can be fractional for ETFs and mutual funds. */} - {formatNumber(execution.quantity, 4)} - {formatNumber(execution.tradePrice, 4)} - {execution.netValue?.formatted} - - {(execution.tradeCommission ?? execution.managementFees)?.formatted} - - - {execution.israelTaxValue?.formatted} - -
- ))} -
-
-
- ) : null; diff --git a/packages/client/src/components/securities/security-executions-table.tsx b/packages/client/src/components/securities/security-executions-table.tsx new file mode 100644 index 000000000..c600fdbc7 --- /dev/null +++ b/packages/client/src/components/securities/security-executions-table.tsx @@ -0,0 +1,138 @@ +import type { ReactElement } from 'react'; +import { + SecurityExecutionFieldsFragmentDoc, + type SecurityExecutionFieldsFragment, +} from '../../gql/graphql.js'; +import { getFragmentData, type FragmentType } from '../../gql/index.js'; +import { formatStringifyAmount } from '../../helpers/numbers.js'; +import { ChargeNavigateButton } from '../common/buttons/charge-navigate-button.js'; +import { Badge } from '../ui/badge.js'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen +/* GraphQL */ ` + fragment SecurityExecutionFields on SecurityExecution { + id + tradeDate + valueDate + settlementDate + tradeType + transactionType + paymentType + quantity + tradePrice + netValue { + formatted + } + tradeCommission { + formatted + } + managementFees { + formatted + } + israelTaxValue { + formatted + } + } +`; + +export const formatSecurityNumber = (value: number | null | undefined, digits = 2): string => + value == null ? '' : formatStringifyAmount(value, digits); + +export const formatSecurityDate = (value: string | Date | null | undefined): string => + value ? new Date(value).toLocaleDateString() : ''; + +/** The bank's enum values read better as words than as SCREAMING_SNAKE_CASE. */ +export const humanizeSecurityEnum = (value: string): string => + value + .toLowerCase() + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + +export type SecurityExecutionRow = { + execution: FragmentType; + /** The charge behind this execution's cash movement, when one was matched. */ + chargeId?: string | null; +}; + +interface Props { + rows: readonly SecurityExecutionRow[]; + /** Adds the column linking each execution to its charge. */ + withChargeLink?: boolean; +} + +/** + * One row per executed action, shared by a charge's securities panel and a security's own + * page so the two always read the same. + */ +export function SecurityExecutionsTable({ rows, withChargeLink }: Props): ReactElement { + return ( + + + + Trade Date + Value Date + Type + Quantity + Price + Net Value + Commission + Tax + {withChargeLink && Charge} + + + + {rows.map(row => { + const execution: SecurityExecutionFieldsFragment = getFragmentData( + SecurityExecutionFieldsFragmentDoc, + row.execution, + ); + return ( + + + {formatSecurityDate(execution.tradeDate)} + + + {formatSecurityDate(execution.valueDate ?? execution.settlementDate)} + + +
+ {humanizeSecurityEnum(execution.tradeType)} + {execution.transactionType !== execution.tradeType && ( + + {humanizeSecurityEnum(execution.transactionType)} + + )} + {execution.paymentType && ( + {humanizeSecurityEnum(execution.paymentType)} + )} +
+
+ {/* Quantities can be fractional for ETFs and mutual funds. */} + {formatSecurityNumber(execution.quantity, 4)} + {formatSecurityNumber(execution.tradePrice, 4)} + {execution.netValue?.formatted} + + {(execution.tradeCommission ?? execution.managementFees)?.formatted} + + + {execution.israelTaxValue?.formatted} + + {withChargeLink && ( + + {row.chargeId ? ( + + ) : ( + // No cash movement was matched — the trade has no charge to open. + + )} + + )} +
+ ); + })} +
+
+ ); +} diff --git a/packages/server/src/modules/foreign-securities/helpers/__tests__/security-position.helper.test.ts b/packages/server/src/modules/foreign-securities/helpers/__tests__/security-position.helper.test.ts new file mode 100644 index 000000000..405d9ad44 --- /dev/null +++ b/packages/server/src/modules/foreign-securities/helpers/__tests__/security-position.helper.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { + calculateSecurityPosition, + type PositionExecution, +} from '../security-position.helper.js'; + +const date = (value: string) => new Date(`${value}T00:00:00`); + +function execution(overrides: Partial = {}): PositionExecution { + return { + trade_date: date('2024-03-10'), + trade_type: 'קניה', + nv: '10', + net_value_trade_currency: '-1000.00', + trade_currency: 'דולר ארה"ב', + ...overrides, + }; +} + +describe('calculateSecurityPosition', () => { + it('is empty with no executions', () => { + const position = calculateSecurityPosition([]); + + expect(position.quantity).toBe(0); + expect(position.averageCost).toBeNull(); + expect(position.historyStartDate).toBeNull(); + // No currency means no amount can be reported: the resolver returns null rather than + // letting formatFinancialAmount fall back to the local currency and claim ILS 0. + expect(position.currency).toBeNull(); + }); + + it('adds bought units and subtracts sold ones', () => { + const position = calculateSecurityPosition([ + execution({ nv: '10' }), + execution({ nv: '4', trade_type: 'מכירה', net_value_trade_currency: '480.00' }), + ]); + + expect(position.quantity).toBe(6); + }); + + it('averages what was paid per unit bought', () => { + const position = calculateSecurityPosition([ + execution({ nv: '10', net_value_trade_currency: '-1000.00' }), + execution({ nv: '10', net_value_trade_currency: '-1400.00' }), + ]); + + expect(position.averageCost).toBe(120); + expect(position.totalBought).toBe(2400); + }); + + it('leaves the position untouched for cash-only actions', () => { + const position = calculateSecurityPosition([ + execution({ nv: '10' }), + execution({ nv: '0', trade_type: 'דבידנד תשלום', net_value_trade_currency: '22.50' }), + execution({ nv: '0', trade_type: 'ריבית תשלום', net_value_trade_currency: '5.00' }), + ]); + + expect(position.quantity).toBe(10); + expect(position.totalSold).toBe(0); + }); + + it('follows deposit transfers in and out', () => { + const position = calculateSecurityPosition([ + execution({ nv: '10', trade_type: 'העברה לזכות הפקדון' }), + execution({ nv: '3', trade_type: 'העברה לחובת הפקדון' }), + execution({ nv: '2', trade_type: 'הטבה חלוקת מניות' }), + ]); + + expect(position.quantity).toBe(9); + }); + + it('counts a redemption as leaving the holding, and as proceeds', () => { + const position = calculateSecurityPosition([ + execution({ nv: '10' }), + execution({ nv: '10', trade_type: 'פדיון', net_value_trade_currency: '1050.00' }), + ]); + + expect(position.quantity).toBe(0); + expect(position.totalSold).toBe(1050); + }); + + it('reports the span the derivation is based on, whatever order rows arrive in', () => { + const position = calculateSecurityPosition([ + execution({ trade_date: date('2024-06-01') }), + execution({ trade_date: date('2023-02-15') }), + execution({ trade_date: date('2024-01-20') }), + ]); + + expect(position.historyStartDate).toBe('2023-02-15'); + expect(position.lastExecutionDate).toBe('2024-06-01'); + }); + + it('carries the currency the executions are quoted in', () => { + expect(calculateSecurityPosition([execution()]).currency).toBe('דולר ארה"ב'); + }); +}); diff --git a/packages/server/src/modules/foreign-securities/helpers/security-position.helper.ts b/packages/server/src/modules/foreign-securities/helpers/security-position.helper.ts new file mode 100644 index 000000000..3eeb59137 --- /dev/null +++ b/packages/server/src/modules/foreign-securities/helpers/security-position.helper.ts @@ -0,0 +1,106 @@ +import { SecurityTradeType } from '../../../shared/enums.js'; +import { dateToTimelessDateString } from '../../../shared/helpers/misc.js'; +import type { TimelessDateString } from '../../../shared/types/index.js'; +import { toSecurityTradeType } from './security-execution-enums.helper.js'; + +/** + * How each kind of execution moves the holding. Cash-only actions — a dividend or an interest + * payment — leave the position untouched, which is why this is not the same map as the + * matcher's cash direction. + */ +const QUANTITY_DIRECTION: Record = { + [SecurityTradeType.Buy]: 1, + [SecurityTradeType.Sell]: -1, + [SecurityTradeType.Redemption]: -1, + [SecurityTradeType.StockDistribution]: 1, + [SecurityTradeType.TransferIn]: 1, + [SecurityTradeType.TransferOut]: -1, + [SecurityTradeType.TransferInTwoSided]: 1, + [SecurityTradeType.TransferOutTwoSided]: -1, + [SecurityTradeType.DividendPayment]: 0, + [SecurityTradeType.InterestPayment]: 0, +}; + +export type PositionExecution = { + trade_date: Date; + trade_type: string; + nv: string | null; + net_value_trade_currency: string | null; + trade_currency: string | null; +}; + +export type SecurityPositionProto = { + /** Units held, derived from the ingested executions alone. */ + quantity: number; + /** Weighted average price paid per unit bought, in the trade currency. Null with no buys. */ + averageCost: number | null; + totalBought: number; + totalSold: number; + /** The currency the amounts above are in — the trade currency the executions report. */ + currency: string | null; + /** + * The earliest ingested execution. The position is only as complete as history from this + * day on, which is what the UI has to say out loud: holdings are not ingested, so anything + * bought before the first scraped execution is invisible here. + */ + historyStartDate: TimelessDateString | null; + lastExecutionDate: TimelessDateString | null; +}; + +const toNumber = (value: string | null): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +/** + * The holding a security's ingested executions add up to, plus what was paid for it. + * + * Derived, not reported: the bank's own balances are not ingested. Corporate actions that + * change the unit count without an execution row (a split, say) are therefore invisible, and a + * history that starts mid-life starts from zero — hence `historyStartDate`. + */ +export function calculateSecurityPosition( + executions: readonly PositionExecution[], +): SecurityPositionProto { + let quantity = 0; + let boughtQuantity = 0; + let totalBought = 0; + let totalSold = 0; + let currency: string | null = null; + let historyStart: Date | null = null; + let lastExecution: Date | null = null; + + for (const execution of executions) { + const tradeType = toSecurityTradeType(execution.trade_type); + const units = toNumber(execution.nv); + const netValue = Math.abs(toNumber(execution.net_value_trade_currency)); + + quantity += QUANTITY_DIRECTION[tradeType] * units; + + if (tradeType === SecurityTradeType.Buy) { + boughtQuantity += units; + totalBought += netValue; + } + if (tradeType === SecurityTradeType.Sell || tradeType === SecurityTradeType.Redemption) { + totalSold += netValue; + } + + currency ??= execution.trade_currency; + if (!historyStart || execution.trade_date < historyStart) { + historyStart = execution.trade_date; + } + if (!lastExecution || execution.trade_date > lastExecution) { + lastExecution = execution.trade_date; + } + } + + return { + quantity, + averageCost: boughtQuantity > 0 ? totalBought / boughtQuantity : null, + totalBought, + totalSold, + currency, + historyStartDate: historyStart ? dateToTimelessDateString(historyStart) : null, + lastExecutionDate: lastExecution ? dateToTimelessDateString(lastExecution) : null, + }; +} diff --git a/packages/server/src/modules/foreign-securities/providers/__tests__/foreign-securities.integration.test.ts b/packages/server/src/modules/foreign-securities/providers/__tests__/foreign-securities.integration.test.ts index 9adb7c77d..abb328de4 100644 --- a/packages/server/src/modules/foreign-securities/providers/__tests__/foreign-securities.integration.test.ts +++ b/packages/server/src/modules/foreign-securities/providers/__tests__/foreign-securities.integration.test.ts @@ -122,6 +122,8 @@ function createProvider( createStubTransactionsProvider(transactions), createStubFinancialAccountsProvider(accountNumber), createStubFinancialBankAccountsProvider(), + // Only the security-history path uses it, which these cases do not take. + {} as never, ); } diff --git a/packages/server/src/modules/foreign-securities/providers/foreign-securities.provider.ts b/packages/server/src/modules/foreign-securities/providers/foreign-securities.provider.ts index 5e064f22d..2064fe08d 100644 --- a/packages/server/src/modules/foreign-securities/providers/foreign-securities.provider.ts +++ b/packages/server/src/modules/foreign-securities/providers/foreign-securities.provider.ts @@ -6,6 +6,7 @@ import { FinancialAccountsProvider } from '../../financial-accounts/providers/fi import { FinancialBankAccountsProvider } from '../../financial-accounts/providers/financial-bank-accounts.provider.js'; import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js'; import { + matchExecutionsToTransactions, matchSecurityExecutions, type AccountTuple, type MatchableTransaction, @@ -14,10 +15,12 @@ import { extractSecurityKeys } from '../helpers/security-key.helper.js'; import type { ChargeSecurityProto, IGetSecuritiesByKeysQuery, + IGetSecurityExecutionsByKeysQuery, IGetSecurityExecutionsQuery, SecurityExecutionRow, SecurityRow, } from '../types.js'; +import { SecurityBusinessesProvider } from './security-businesses.provider.js'; /** * No owner_id predicate: accounter_schema.poalim_securities is FORCE RLS with a @@ -77,6 +80,46 @@ const getSecurityExecutions = sql` AND account_number = ANY($accountNumbers!) AND value_date = ANY($valueDates!);`; +/** + * Every ingested execution of the given securities, unbounded by charge or date — the whole + * life of an instrument, which is what its business page shows. RLS scopes it to the tenant. + */ +const getSecurityExecutionsByKeys = 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, + settlement_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!) + ORDER BY trade_date, id;`; + +/** What the reverse match needs off a transaction, charge included so a row can link out. */ +type MatchedTransaction = MatchableTransaction & { charge_id: string }; + @Injectable({ scope: Scope.Operation, global: true, @@ -87,6 +130,7 @@ export class ForeignSecuritiesProvider { private transactionsProvider: TransactionsProvider, private financialAccountsProvider: FinancialAccountsProvider, private financialBankAccountsProvider: FinancialBankAccountsProvider, + private securityBusinessesProvider: SecurityBusinessesProvider, ) {} private async batchSecuritiesByKeys(securityKeys: readonly string[]) { @@ -178,6 +222,46 @@ export class ForeignSecuritiesProvider { return matchSecurityExecutions(transactions, candidates, accountTuples); } + /** + * The whole ingested life of one security business: every execution of every Poalim key it + * is known by, each carrying the cash movement (and so the charge) behind it. + * + * The candidate transactions are the security business's own — which is what the counterparty + * now is for a resolved trade — so this reads the same pairing the charge view shows, from + * the other end. + */ + public async getSecurityBusinessHistory(businessId: string, ownerId: string) { + const identifiers = + await this.securityBusinessesProvider.getIdentifiersByBusinessIdLoader.load(businessId); + const securityKeys = identifiers + .filter(identifier => identifier.identifier_type === 'POALIM_SECURITY_KEY') + .map(identifier => identifier.identifier_value); + + if (securityKeys.length === 0) { + return { executions: [], transactionByExecutionId: new Map() }; + } + + const [executions, transactions] = await Promise.all([ + getSecurityExecutionsByKeys.run({ securities: securityKeys }, this.db), + this.transactionsProvider.getTransactionsByFilters({ + businessIDs: [businessId], + ownerIDs: [ownerId], + }), + ]); + + const accountTuples = await this.getAccountTuples([ + ...new Set(transactions.map(transaction => transaction.account_id).filter(Boolean)), + ] as string[]); + + const transactionByExecutionId = matchExecutionsToTransactions( + transactions as unknown as MatchedTransaction[], + executions, + accountTuples, + ); + + return { executions, transactionByExecutionId }; + } + /** * 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 diff --git a/packages/server/src/modules/foreign-securities/resolvers/security-businesses.resolver.ts b/packages/server/src/modules/foreign-securities/resolvers/security-businesses.resolver.ts index 723003594..3242420da 100644 --- a/packages/server/src/modules/foreign-securities/resolvers/security-businesses.resolver.ts +++ b/packages/server/src/modules/foreign-securities/resolvers/security-businesses.resolver.ts @@ -1,9 +1,18 @@ 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 { calculateSecurityPosition } 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'; +/** 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 }) => { @@ -21,6 +30,56 @@ export const securityBusinessesResolvers: ForeignSecuritiesModule.Resolvers = { business != null && !(business instanceof Error), ); }, + 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, + transaction: transactionByExecutionId.get(execution.id) ?? null, + })), + }; + }, + }, + SecurityHistoryExecution: { + id: historyExecution => historyExecution.id, + execution: historyExecution => historyExecution.execution, + // 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 }) => diff --git a/packages/server/src/modules/foreign-securities/typeDefs/security-businesses.graphql.ts b/packages/server/src/modules/foreign-securities/typeDefs/security-businesses.graphql.ts index dff2c2bdf..20af01e9f 100644 --- a/packages/server/src/modules/foreign-securities/typeDefs/security-businesses.graphql.ts +++ b/packages/server/src/modules/foreign-securities/typeDefs/security-businesses.graphql.ts @@ -4,6 +4,41 @@ export default gql` extend type Query { " Every business that stands for a traded security, for pickers scoped to securities " allSecurityBusinesses: [LtdFinancialEntity!]! @requiresAuth + " The full ingested life of one security: its holding and every execution " + securityBusinessHistory(businessId: UUID!): SecurityBusinessHistory! @requiresAuth + } + + " Everything a security business page shows " + type SecurityBusinessHistory { + id: UUID! + security: SecurityBusiness! + position: SecurityPosition! + " Every ingested execution, oldest first " + executions: [SecurityHistoryExecution!]! + } + + " What the ingested executions add up to. Derived rather than reported — the bank's own holdings are not ingested, so the numbers are only as complete as the execution history behind them (see historyStartDate) " + type SecurityPosition { + " The security this position belongs to " + id: UUID! + " Units held, from the executions alone " + quantity: Float! + " Weighted average paid per unit bought; null when nothing was bought " + averageCost: FinancialAmount + totalBought: FinancialAmount + totalSold: FinancialAmount + " The earliest ingested execution — anything before it is invisible here " + historyStartDate: TimelessDate + lastExecutionDate: TimelessDate + } + + " An execution with the cash movement behind it " + type SecurityHistoryExecution { + id: UUID! + execution: SecurityExecution! + " The charge the matched cash movement belongs to; null when no movement was matched " + charge: Charge + transaction: Transaction } extend type LtdFinancialEntity { diff --git a/packages/server/src/modules/foreign-securities/types.ts b/packages/server/src/modules/foreign-securities/types.ts index a15a3d72a..50033f9e2 100644 --- a/packages/server/src/modules/foreign-securities/types.ts +++ b/packages/server/src/modules/foreign-securities/types.ts @@ -7,6 +7,7 @@ import type { IGetSecurityIdentifiersByBusinessIdsResult, security_identifier_type, } from './__generated__/security-businesses.types.js'; +import type { SecurityPositionProto } from './helpers/security-position.helper.js'; export type * from './__generated__/types.js'; export type * from './__generated__/foreign-securities.types.js'; @@ -58,6 +59,24 @@ export type SecurityRow = IGetSecuritiesByKeysResult; */ export type SecurityExecutionRow = IGetSecurityExecutionsResult; +/** The derived position, carrying the security it belongs to so clients can cache it. */ +export type SecurityPositionWithIdProto = SecurityPositionProto & { id: string }; + +/** An execution paired with the cash movement behind it, for a security's own page. */ +export type SecurityHistoryExecutionProto = { + id: string; + execution: SecurityExecutionRow; + /** The matched transaction, carrying the charge it belongs to. Null when nothing matched. */ + transaction: { id: string; charge_id: string } | null; +}; + +export type SecurityBusinessHistoryProto = { + id: string; + security: SecurityBusinessRow; + position: SecurityPositionWithIdProto; + executions: SecurityHistoryExecutionProto[]; +}; + export type ChargeSecurityProto = { /** Scoped to the charge so the client cache keeps a key's entries distinct per charge. */ id: string;