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 => (
+
+);
+
+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;