From b951ddbd943387a7cdec17232f84b54ce8653c87 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 20 Aug 2026 01:25:57 +0300 Subject: [PATCH 1/3] feat: security tab with derived holding and full execution history Adds Query.securityBusinessHistory and a Security tab on the business page, shown for any business with a businesses_securities row. Shows the position derived from ingested executions - with the as-of caveat, since holdings are not scraped - and every execution across the security's Poalim keys, each linking to the charge behind its cash movement. The charge panel's portfolio table and the new one are now one shared component over one fragment. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/security-business-tab.md | 32 ++++ codegen.ts | 5 + .../client/src/components/business/index.tsx | 23 ++- .../components/business/security-section.tsx | 156 ++++++++++++++++++ .../extended-info/foreign-securities-info.tsx | 98 +---------- .../securities/security-executions-table.tsx | 138 ++++++++++++++++ .../security-position.helper.test.ts | 93 +++++++++++ .../helpers/security-position.helper.ts | 106 ++++++++++++ .../foreign-securities.integration.test.ts | 2 + .../providers/foreign-securities.provider.ts | 84 ++++++++++ .../resolvers/security-businesses.resolver.ts | 55 ++++++ .../typeDefs/security-businesses.graphql.ts | 35 ++++ .../src/modules/foreign-securities/types.ts | 19 +++ 13 files changed, 754 insertions(+), 92 deletions(-) create mode 100644 .changeset/security-business-tab.md create mode 100644 packages/client/src/components/business/security-section.tsx create mode 100644 packages/client/src/components/securities/security-executions-table.tsx create mode 100644 packages/server/src/modules/foreign-securities/helpers/__tests__/security-position.helper.test.ts create mode 100644 packages/server/src/modules/foreign-securities/helpers/security-position.helper.ts diff --git a/.changeset/security-business-tab.md b/.changeset/security-business-tab.md new file mode 100644 index 000000000..fbb1611e9 --- /dev/null +++ b/.changeset/security-business-tab.md @@ -0,0 +1,32 @@ +--- +'@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). + +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..0b9e36eea 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 (
@@ -86,7 +92,7 @@ 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..d816350c3 --- /dev/null +++ b/packages/server/src/modules/foreign-securities/helpers/__tests__/security-position.helper.test.ts @@ -0,0 +1,93 @@ +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(); + }); + + 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..01cb12690 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,6 +1,11 @@ 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'; @@ -21,6 +26,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, + averageCost: position => + position.averageCost == null + ? null + : formatFinancialAmount(position.averageCost, position.currency), + totalBought: position => formatFinancialAmount(position.totalBought, position.currency), + totalSold: position => formatFinancialAmount(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; From e137d2192177f6ee6739a3b889ce4c65b90a070f Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 20 Aug 2026 12:51:57 +0300 Subject: [PATCH 2/3] fix(server): report no position amounts when there is nothing to report Addresses review feedback. A security with no ingested executions has no currency, and formatFinancialAmount falls back to the local one - so the card showed a confident ILS 0 for totals nothing is known about. The resolver now returns null for all three amounts when the currency is null; the client already renders null as an em dash. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/security-business-tab.md | 4 ++++ .../__tests__/security-position.helper.test.ts | 3 +++ .../resolvers/security-businesses.resolver.ts | 16 ++++++++++------ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/.changeset/security-business-tab.md b/.changeset/security-business-tab.md index fbb1611e9..02e0f2628 100644 --- a/.changeset/security-business-tab.md +++ b/.changeset/security-business-tab.md @@ -14,6 +14,10 @@ only way to see what is held was to add the executions up by hand. The business - **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 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 index d816350c3..405d9ad44 100644 --- 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 @@ -24,6 +24,9 @@ describe('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', () => { 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 01cb12690..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 @@ -9,6 +9,10 @@ import { ForeignSecuritiesProvider } from '../providers/foreign-securities.provi 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 }) => { @@ -68,12 +72,12 @@ export const securityBusinessesResolvers: ForeignSecuritiesModule.Resolvers = { SecurityPosition: { id: position => position.id, quantity: position => position.quantity, - averageCost: position => - position.averageCost == null - ? null - : formatFinancialAmount(position.averageCost, position.currency), - totalBought: position => formatFinancialAmount(position.totalBought, position.currency), - totalSold: position => formatFinancialAmount(position.totalSold, position.currency), + // 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, }, From 88091550f9cbc05cd0dc0447d1e0d62d23e6b2ca Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 20 Aug 2026 17:53:44 +0300 Subject: [PATCH 3/3] nit --- packages/client/src/components/business/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/components/business/index.tsx b/packages/client/src/components/business/index.tsx index 0b9e36eea..f0b75453d 100644 --- a/packages/client/src/components/business/index.tsx +++ b/packages/client/src/components/business/index.tsx @@ -92,7 +92,7 @@ export default function Business({ data, refetchBusiness }: Props): ReactElement
- +