Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/security-business-tab.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 21 additions & 0 deletions packages/client/src/components/business/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useContext, useEffect, type ReactElement } from 'react';
import {
ArrowLeftRight,
Building2,
CandlestickChart,
ChartLine,
DollarSign,
FileCheck,
Expand Down Expand Up @@ -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
Expand All @@ -44,6 +46,9 @@ import { TransactionsSection } from './transactions-section.js';
adminInfo {
id
}
securityInfo {
id
}
}
...ClientIntegrationsSection
...BusinessHeader
Expand Down Expand Up @@ -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 (
<div className="min-h-screen bg-background">
Expand Down Expand Up @@ -136,6 +142,15 @@ export default function Business({ data, refetchBusiness }: Props): ReactElement
<ChartLine className="h-4 w-4" />
<span className="hidden sm:inline">Balance</span>
</TabsTrigger>
{isSecurity && (
<TabsTrigger
value="security"
className="flex items-center gap-2 data-[state=active]:bg-background"
>
<CandlestickChart className="h-4 w-4" />
<span className="hidden sm:inline">Security</span>
</TabsTrigger>
)}
{isClient && (
<>
<TabsTrigger
Expand Down Expand Up @@ -217,6 +232,12 @@ export default function Business({ data, refetchBusiness }: Props): ReactElement
<BalanceSection businessId={business.id} />
</TabsContent>

{isSecurity && (
<TabsContent value="security" className="mt-0">
<SecuritySection businessId={business.id} />
</TabsContent>
)}

{isClient && (
<>
<TabsContent value="contracts" className="mt-0">
Expand Down
156 changes: 156 additions & 0 deletions packages/client/src/components/business/security-section.tsx
Original file line number Diff line number Diff line change
@@ -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 => (
<div className="flex flex-col gap-1">
<div className="text-xs uppercase tracking-wide text-gray-500">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
);

export function SecuritySection({ businessId }: Props): ReactElement {
const [{ data, fetching }] = useQuery({
query: BusinessSecuritySectionDocument,
variables: { businessId },
});

if (fetching) {
return <div>Loading security history...</div>;
}

const history = data?.securityBusinessHistory;
if (!history) {
return <div>No security details found for this business</div>;
}

const { security, position, executions } = history;
const poalimKeys = security.identifiers
.filter(identifier => identifier.type === 'POALIM_SECURITY_KEY')
.map(identifier => identifier.value);

return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<div className="flex flex-col gap-2">
<CardTitle>
{security.engName ?? security.isin}
{security.symbol && <span className="text-gray-500"> ({security.symbol})</span>}
</CardTitle>
{security.hebName && <CardDescription>{security.hebName}</CardDescription>}
<div className="flex flex-row flex-wrap items-center gap-2">
<Badge variant="secondary">{security.isin}</Badge>
{security.exchange && <Badge variant="secondary">{security.exchange}</Badge>}
{security.currencyCode && <Badge variant="secondary">{security.currencyCode}</Badge>}
{security.itemType && <Badge variant="outline">{security.itemType}</Badge>}
{security.stockType && <Badge variant="outline">{security.stockType}</Badge>}
{security.isEtf && <Badge variant="outline">ETF</Badge>}
{security.isForeign && <Badge variant="outline">Foreign</Badge>}
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Quantities can be fractional for ETFs and mutual funds. */}
<Stat label="Current hold" value={formatSecurityNumber(position.quantity, 4)} />
<Stat label="Average cost" value={position.averageCost?.formatted ?? '—'} />
<Stat label="Total bought" value={position.totalBought?.formatted ?? '—'} />
<Stat label="Total sold" value={position.totalSold?.formatted ?? '—'} />
</div>
<div className="text-xs text-gray-400">
{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(', ')}`}
</div>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Execution history</CardTitle>
<CardDescription>Every ingested action in this security, oldest first</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<SecurityExecutionsTable
withChargeLink
rows={executions.map(row => ({
execution: row.execution,
chargeId: row.charge?.id ?? null,
}))}
/>
</div>
</CardContent>
</Card>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -132,7 +113,11 @@ const SecuritySection = ({ security }: { security: ChargeSecurity }): ReactEleme
</div>
)}
<SecurityTransactionsTable transactions={security.transactions} />
<SecurityExecutionsTable executions={security.executions} />
{security.executions.length > 0 && (
<TableSection title="Portfolio activity">
<SecurityExecutionsTable rows={security.executions.map(execution => ({ execution }))} />
</TableSection>
)}
</div>
);
};
Expand Down Expand Up @@ -213,72 +198,3 @@ const SecurityTransactionsTable = ({
</TableSection>
) : 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 ? (
<TableSection title="Portfolio activity">
<Table>
<TableHeader>
<TableRow>
<TableHead>Trade Date</TableHead>
<TableHead>Value Date</TableHead>
<TableHead>Type</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
<TableHead>Net Value</TableHead>
<TableHead>Commission</TableHead>
<TableHead>Tax</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executions.map(execution => (
<TableRow key={execution.id}>
<TableCell className="whitespace-nowrap">{formatDate(execution.tradeDate)}</TableCell>
<TableCell className="whitespace-nowrap">
{formatDate(execution.valueDate ?? execution.settlementDate)}
</TableCell>
<TableCell>
<div className="flex flex-row flex-wrap items-center gap-1">
<Badge variant="secondary">{humanizeEnum(execution.tradeType)}</Badge>
{execution.transactionType !== execution.tradeType && (
<Badge variant="outline">{humanizeEnum(execution.transactionType)}</Badge>
)}
{execution.paymentType && (
<Badge variant="outline">{humanizeEnum(execution.paymentType)}</Badge>
)}
</div>
</TableCell>
{/* Quantities can be fractional for ETFs and mutual funds. */}
<TableCell>{formatNumber(execution.quantity, 4)}</TableCell>
<TableCell>{formatNumber(execution.tradePrice, 4)}</TableCell>
<TableCell className="whitespace-nowrap">{execution.netValue?.formatted}</TableCell>
<TableCell className="whitespace-nowrap">
{(execution.tradeCommission ?? execution.managementFees)?.formatted}
</TableCell>
<TableCell className="whitespace-nowrap">
{execution.israelTaxValue?.formatted}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableSection>
) : null;
Loading
Loading