From 85ebfa16f0304503df64f6554ba51eb9b80b0012 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 20 Aug 2026 01:29:42 +0300 Subject: [PATCH 1/2] feat(client): scope the securities trade counterparty picker to securities The main transaction of a foreign-securities charge settles against the security it traded, so its counterparty picker now lists security businesses plus the general foreign-securities business, each labelled with its ISIN, instead of the whole business directory. The fee row keeps the full list. chargeType is threaded from charge-extended-info down to the counterparty cell the same way enableEdit is; other TransactionsTable callers pass none and are unaffected. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/security-counterparty-picker.md | 26 +++++++ .../components/__tests__/user-menu.test.tsx | 1 + .../charges/charge-extended-info.tsx | 1 + .../charges/charge-transactions-table.tsx | 16 ++++- .../transactions-table/cells/counterparty.tsx | 43 +++++++++++- .../components/transactions-table/columns.tsx | 6 ++ .../components/transactions-table/index.tsx | 6 +- .../src/hooks/use-get-security-businesses.ts | 68 +++++++++++++++++++ .../client/src/providers/user-provider.tsx | 4 ++ 9 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 .changeset/security-counterparty-picker.md create mode 100644 packages/client/src/hooks/use-get-security-businesses.ts diff --git a/.changeset/security-counterparty-picker.md b/.changeset/security-counterparty-picker.md new file mode 100644 index 0000000000..651eab3716 --- /dev/null +++ b/.changeset/security-counterparty-picker.md @@ -0,0 +1,26 @@ +--- +'@accounter/client': minor +--- + +Offer securities, and only securities, as the counterparty of a foreign-securities trade. + +The main transaction of a securities charge settles against the security it traded, so the picker +that appears when it has no counterparty yet now lists the tenant's security businesses plus the +general foreign-securities business — the fallback for a trade whose security cannot be told — rather +than the whole business directory, where the right answer is a needle in a haystack and a wrong one +is one click away. Each option carries its ISIN, which is what tells two share classes of one issuer +apart. + +The fee row is the bank's and keeps the full list, matching what the suggestion resolver does on the +server. + +The rule is decided client-side from what the charge already knows, so `chargeType` is threaded from +`charge-extended-info` through `ChargeTransactionsTable` and `TransactionsTable` onto the row, the +same way `enableEdit` and `enableChargeLink` are. The other `TransactionsTable` callers pass no +charge type and are unaffected. + +`UserContext.foreignSecuritiesBusinessId` is read through the user provider for that fallback option, +and `useGetSecurityBusinesses` is the securities-scoped counterpart of `useGetAdminBusinesses`. + +The suggestion itself needs no client change: the cell already pre-seeds the select from +`missingInfoSuggestions`, which now resolves the security named in the trade's description. diff --git a/packages/client/src/components/__tests__/user-menu.test.tsx b/packages/client/src/components/__tests__/user-menu.test.tsx index 979e26baa6..976808e4d9 100644 --- a/packages/client/src/components/__tests__/user-menu.test.tsx +++ b/packages/client/src/components/__tests__/user-menu.test.tsx @@ -80,6 +80,7 @@ const baseUserContext: UserInfo = { defaultCryptoConversionFiatCurrency: 'USD', ledgerLock: null, financialAccountsBusinessesIds: [], + foreignSecuritiesBusinessId: null, locality: 'IL', memberships: [], activeReadScope: [], diff --git a/packages/client/src/components/charges/charge-extended-info.tsx b/packages/client/src/components/charges/charge-extended-info.tsx index 66360dd0a7..2e85dd44d2 100644 --- a/packages/client/src/components/charges/charge-extended-info.tsx +++ b/packages/client/src/components/charges/charge-extended-info.tsx @@ -421,6 +421,7 @@ export function ChargeExtendedInfo({ {transactionsAreReady && ( )} diff --git a/packages/client/src/components/charges/charge-transactions-table.tsx b/packages/client/src/components/charges/charge-transactions-table.tsx index 0f0c52763b..d3fd57e19d 100644 --- a/packages/client/src/components/charges/charge-transactions-table.tsx +++ b/packages/client/src/components/charges/charge-transactions-table.tsx @@ -16,13 +16,25 @@ import { TransactionsTable } from '../transactions-table/index.js'; type Props = { transactionsProps: FragmentType; + chargeType?: string; onChange: () => void; }; -export const ChargeTransactionsTable = ({ transactionsProps, onChange }: Props): ReactElement => { +export const ChargeTransactionsTable = ({ + transactionsProps, + chargeType, + onChange, +}: Props): ReactElement => { const { transactions } = getFragmentData( ChargeTableTransactionsFieldsFragmentDoc, transactionsProps, ); - return ; + return ( + + ); }; diff --git a/packages/client/src/components/transactions-table/cells/counterparty.tsx b/packages/client/src/components/transactions-table/cells/counterparty.tsx index a3dd47c2f0..00316718a9 100644 --- a/packages/client/src/components/transactions-table/cells/counterparty.tsx +++ b/packages/client/src/components/transactions-table/cells/counterparty.tsx @@ -1,9 +1,11 @@ -import { useCallback, useState, type ReactElement } from 'react'; +import { useCallback, useContext, useMemo, useState, type ReactElement } from 'react'; import { CheckIcon } from 'lucide-react'; import { Link } from 'react-router-dom'; import { ROUTES } from '@/router/routes.js'; import { useGetBusinesses } from '../../../hooks/use-get-businesses.js'; +import { useGetSecurityBusinesses } from '../../../hooks/use-get-security-businesses.js'; import { useUpdateTransaction } from '../../../hooks/use-update-transaction.js'; +import { UserContext } from '../../../providers/user-provider.js'; import { SelectWithSearch, Tooltip } from '../../common/index.js'; import { InsertBusiness } from '../../common/modals/insert-business.js'; import { SimilarTransactionsModal } from '../../common/modals/similar-transactions-modal.js'; @@ -23,8 +25,17 @@ export function Counterparty({ transaction, onChange }: Props): ReactElement { id: transactionId, sourceDescription, enableEdit, + isFee, + chargeType, } = transaction; + /** + * A foreign-securities trade settles against the security it traded, so offering the whole + * business directory there is noise at best and a mis-assignment at worst. The fee row is the + * bank's and keeps the full list. + */ + const isSecurityTrade = chargeType === 'ForeignSecuritiesCharge' && !isFee; + const hasSuggestion = !!missingInfoSuggestions?.business && enableEdit; const suggestedName = hasSuggestion ? missingInfoSuggestions?.business?.name : 'Missing'; const suggestedId = hasSuggestion ? missingInfoSuggestions?.business?.id : null; @@ -55,7 +66,28 @@ export function Counterparty({ transaction, onChange }: Props): ReactElement { [updateBusiness, onChange], ); - const { selectableBusinesses: selectOptions, fetching: businessesLoading } = useGetBusinesses(); + const { selectableBusinesses, fetching: businessesLoading } = useGetBusinesses(); + const { selectableSecurityBusinesses, fetching: securitiesLoading } = useGetSecurityBusinesses(); + const { userContext } = useContext(UserContext); + const foreignSecuritiesBusinessId = userContext?.context.foreignSecuritiesBusinessId ?? null; + + const selectOptions = useMemo(() => { + if (!isSecurityTrade) { + return selectableBusinesses; + } + // Plus the general foreign-securities business, for a trade whose security cannot be told. + const generalOption = selectableBusinesses.find( + option => option.value === foreignSecuritiesBusinessId, + ); + return generalOption + ? [...selectableSecurityBusinesses, generalOption] + : selectableSecurityBusinesses; + }, [ + isSecurityTrade, + selectableBusinesses, + selectableSecurityBusinesses, + foreignSecuritiesBusinessId, + ]); const [selectedBusinessId, setSelectedBusinessId] = useState(suggestedId ?? null); @@ -90,7 +122,12 @@ export function Counterparty({ transaction, onChange }: Props): ReactElement { variant="outline" size="icon" onClick={() => selectedBusinessId && updateBusiness(selectedBusinessId)} - disabled={fetching || businessesLoading || !selectedBusinessId} + disabled={ + fetching || + businessesLoading || + (isSecurityTrade && securitiesLoading) || + !selectedBusinessId + } > diff --git a/packages/client/src/components/transactions-table/columns.tsx b/packages/client/src/components/transactions-table/columns.tsx index 9dd7c0f82b..9eb7c72fc2 100644 --- a/packages/client/src/components/transactions-table/columns.tsx +++ b/packages/client/src/components/transactions-table/columns.tsx @@ -55,6 +55,12 @@ export type TransactionsTableRowType = TransactionForTransactionsTableFieldsFrag editTransaction: (id: string) => void; enableEdit?: boolean; enableChargeLink?: boolean; + /** + * The type of the charge these transactions belong to, when the table is rendered inside + * one. Lets a cell narrow its behaviour to a charge kind — the counterparty picker offers + * securities only, on a foreign-securities trade. + */ + chargeType?: string; }; export const columns: ColumnDef[] = [ diff --git a/packages/client/src/components/transactions-table/index.tsx b/packages/client/src/components/transactions-table/index.tsx index 1a45e7e7ce..dc88029a4d 100644 --- a/packages/client/src/components/transactions-table/index.tsx +++ b/packages/client/src/components/transactions-table/index.tsx @@ -18,6 +18,8 @@ type Props = { transactionsProps: FragmentType[]; enableEdit?: boolean; enableChargeLink?: boolean; + /** The charge these transactions belong to, when rendered inside one. */ + chargeType?: string; onChange?: () => void; }; @@ -26,6 +28,7 @@ export const TransactionsTable = ({ onChange, enableEdit, enableChargeLink, + chargeType, }: Props): ReactElement => { const [editTransactionId, setEditTransactionId] = useState(undefined); const [sorting, setSorting] = useState([]); @@ -44,9 +47,10 @@ export const TransactionsTable = ({ onUpdate: onChange || (() => {}), enableEdit, enableChargeLink, + chargeType, }; }); - }, [transactions, enableEdit, enableChargeLink, onChange]); + }, [transactions, enableEdit, enableChargeLink, chargeType, onChange]); const tableColumns = useMemo(() => { return enableEdit || enableChargeLink ? [...columns, actionsColumn] : columns; diff --git a/packages/client/src/hooks/use-get-security-businesses.ts b/packages/client/src/hooks/use-get-security-businesses.ts new file mode 100644 index 0000000000..225aab3281 --- /dev/null +++ b/packages/client/src/hooks/use-get-security-businesses.ts @@ -0,0 +1,68 @@ +import { useEffect, useMemo } from 'react'; +import { toast } from 'sonner'; +import { useQuery } from 'urql'; +import { AllSecurityBusinessesDocument, type AllSecurityBusinessesQuery } from '../gql/graphql.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen +/* GraphQL */ ` + query AllSecurityBusinesses { + allSecurityBusinesses { + id + name + securityInfo { + id + isin + symbol + } + } + } +`; + +export type SecurityBusinesses = NonNullable; + +type UseGetSecurityBusinesses = { + fetching: boolean; + refresh: () => void; + securityBusinesses: SecurityBusinesses; + selectableSecurityBusinesses: Array<{ value: string; label: string }>; +}; + +/** + * The businesses that stand for a traded security, for pickers that should offer only those — + * the counterparty of a foreign-securities trade, above all. + */ +export const useGetSecurityBusinesses = (): UseGetSecurityBusinesses => { + const [{ data, fetching, error }, fetch] = useQuery({ + query: AllSecurityBusinessesDocument, + }); + + useEffect(() => { + if (error) { + console.error(`Error fetching security businesses: ${error}`); + toast.error('Error', { + description: 'Unable to fetch security businesses', + }); + } + }, [error]); + + const securityBusinesses = useMemo(() => { + return data?.allSecurityBusinesses?.slice().sort((a, b) => a.name.localeCompare(b.name)) ?? []; + }, [data]); + + const selectableSecurityBusinesses = useMemo(() => { + return securityBusinesses.map(business => ({ + value: business.id, + // The ISIN is what makes two share classes of one issuer tellable apart. + label: business.securityInfo?.isin + ? `${business.name} · ${business.securityInfo.isin}` + : business.name, + })); + }, [securityBusinesses]); + + return { + fetching, + refresh: () => fetch(), + securityBusinesses, + selectableSecurityBusinesses, + }; +}; diff --git a/packages/client/src/providers/user-provider.tsx b/packages/client/src/providers/user-provider.tsx index 45ca127a08..6ca6b2bc17 100644 --- a/packages/client/src/providers/user-provider.tsx +++ b/packages/client/src/providers/user-provider.tsx @@ -19,6 +19,7 @@ import { UserContextDocument, type UserContextQuery } from '../gql/graphql.js'; defaultCryptoConversionFiatCurrency ledgerLock financialAccountsBusinessesIds + foreignSecuritiesBusinessId locality } } @@ -46,6 +47,8 @@ export interface UserInfo extends User { defaultCryptoConversionFiatCurrency: string; ledgerLock?: string | null; financialAccountsBusinessesIds: string[]; + /** The general foreign-securities business — the fallback counterparty for a trade. */ + foreignSecuritiesBusinessId: string | null; locality: string; }; } @@ -66,6 +69,7 @@ function toUserInfoContext( defaultCryptoConversionFiatCurrency: userContext.defaultCryptoConversionFiatCurrency ?? '', ledgerLock: userContext.ledgerLock, financialAccountsBusinessesIds: userContext.financialAccountsBusinessesIds ?? [], + foreignSecuritiesBusinessId: userContext.foreignSecuritiesBusinessId ?? null, locality: userContext.locality ?? '', }; } From 67fdbf8ad827792515f309b6cc9a75073c5f16f3 Mon Sep 17 00:00:00 2001 From: Gil Gardosh Date: Thu, 20 Aug 2026 12:55:28 +0300 Subject: [PATCH 2/2] fix(client): pause the securities query and type chargeType properly Addresses review feedback. useGetSecurityBusinesses now takes a pause flag, and the counterparty cell passes pause: !isSecurityTrade - the hook is called from a table cell, so a plain transactions table was running the AllSecurityBusinesses query once per row for a list it never shows. chargeType is the shared ChargeType union instead of string across the three layers that thread it, since it is compared against typename literals. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/security-counterparty-picker.md | 10 ++++++---- .../charges/charge-transactions-table.tsx | 3 ++- .../transactions-table/cells/counterparty.tsx | 4 +++- .../src/components/transactions-table/columns.tsx | 3 ++- .../src/components/transactions-table/index.tsx | 3 ++- .../client/src/hooks/use-get-security-businesses.ts | 13 ++++++++++++- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/.changeset/security-counterparty-picker.md b/.changeset/security-counterparty-picker.md index 651eab3716..1dac4b11f0 100644 --- a/.changeset/security-counterparty-picker.md +++ b/.changeset/security-counterparty-picker.md @@ -14,10 +14,12 @@ apart. The fee row is the bank's and keeps the full list, matching what the suggestion resolver does on the server. -The rule is decided client-side from what the charge already knows, so `chargeType` is threaded from -`charge-extended-info` through `ChargeTransactionsTable` and `TransactionsTable` onto the row, the -same way `enableEdit` and `enableChargeLink` are. The other `TransactionsTable` callers pass no -charge type and are unaffected. +The rule is decided client-side from what the charge already knows, so `chargeType` — typed as the +shared `ChargeType` union rather than a bare string, since it is compared against typename literals — +is threaded from `charge-extended-info` through `ChargeTransactionsTable` and `TransactionsTable` +onto the row, the same way `enableEdit` and `enableChargeLink` are. The other `TransactionsTable` +callers pass no charge type and are unaffected: `useGetSecurityBusinesses` takes a `pause` flag, so a +plain transactions table does not run the securities query once per row for a list it never shows. `UserContext.foreignSecuritiesBusinessId` is read through the user provider for that fallback option, and `useGetSecurityBusinesses` is the securities-scoped counterpart of `useGetAdminBusinesses`. diff --git a/packages/client/src/components/charges/charge-transactions-table.tsx b/packages/client/src/components/charges/charge-transactions-table.tsx index d3fd57e19d..a2fc660490 100644 --- a/packages/client/src/components/charges/charge-transactions-table.tsx +++ b/packages/client/src/components/charges/charge-transactions-table.tsx @@ -1,6 +1,7 @@ import { type ReactElement } from 'react'; import { ChargeTableTransactionsFieldsFragmentDoc } from '../../gql/graphql.js'; import { getFragmentData, type FragmentType } from '../../gql/index.js'; +import type { ChargeType } from '../../helpers/charges.js'; import { TransactionsTable } from '../transactions-table/index.js'; // eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen @@ -16,7 +17,7 @@ import { TransactionsTable } from '../transactions-table/index.js'; type Props = { transactionsProps: FragmentType; - chargeType?: string; + chargeType?: ChargeType; onChange: () => void; }; diff --git a/packages/client/src/components/transactions-table/cells/counterparty.tsx b/packages/client/src/components/transactions-table/cells/counterparty.tsx index 00316718a9..8177714472 100644 --- a/packages/client/src/components/transactions-table/cells/counterparty.tsx +++ b/packages/client/src/components/transactions-table/cells/counterparty.tsx @@ -67,7 +67,9 @@ export function Counterparty({ transaction, onChange }: Props): ReactElement { ); const { selectableBusinesses, fetching: businessesLoading } = useGetBusinesses(); - const { selectableSecurityBusinesses, fetching: securitiesLoading } = useGetSecurityBusinesses(); + const { selectableSecurityBusinesses, fetching: securitiesLoading } = useGetSecurityBusinesses({ + pause: !isSecurityTrade, + }); const { userContext } = useContext(UserContext); const foreignSecuritiesBusinessId = userContext?.context.foreignSecuritiesBusinessId ?? null; diff --git a/packages/client/src/components/transactions-table/columns.tsx b/packages/client/src/components/transactions-table/columns.tsx index 9eb7c72fc2..ba4eb8a0e4 100644 --- a/packages/client/src/components/transactions-table/columns.tsx +++ b/packages/client/src/components/transactions-table/columns.tsx @@ -1,5 +1,6 @@ import { ChevronDown, ChevronUp } from 'lucide-react'; import type { ColumnDef } from '@tanstack/react-table'; +import type { ChargeType } from '@/helpers/charges.js'; import type { TableFeaturesConfig } from '@/lib/table-features.js'; import type { TransactionForTransactionsTableFieldsFragment } from '../../gql/graphql.js'; import { ChargeNavigateButton, EditMiniButton, InsertMiscExpenseModal } from '../common/index.js'; @@ -60,7 +61,7 @@ export type TransactionsTableRowType = TransactionForTransactionsTableFieldsFrag * one. Lets a cell narrow its behaviour to a charge kind — the counterparty picker offers * securities only, on a foreign-securities trade. */ - chargeType?: string; + chargeType?: ChargeType; }; export const columns: ColumnDef[] = [ diff --git a/packages/client/src/components/transactions-table/index.tsx b/packages/client/src/components/transactions-table/index.tsx index dc88029a4d..1d40cff1f6 100644 --- a/packages/client/src/components/transactions-table/index.tsx +++ b/packages/client/src/components/transactions-table/index.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/table.js'; import { TransactionForTransactionsTableFieldsFragmentDoc } from '@/gql/graphql.js'; import { getFragmentData, type FragmentType } from '@/gql/index.js'; +import type { ChargeType } from '@/helpers/charges.js'; import { tableFeaturesConfig } from '@/lib/table-features.js'; import { actionsColumn, columns, type TransactionsTableRowType } from './columns.js'; @@ -19,7 +20,7 @@ type Props = { enableEdit?: boolean; enableChargeLink?: boolean; /** The charge these transactions belong to, when rendered inside one. */ - chargeType?: string; + chargeType?: ChargeType; onChange?: () => void; }; diff --git a/packages/client/src/hooks/use-get-security-businesses.ts b/packages/client/src/hooks/use-get-security-businesses.ts index 225aab3281..4b426d7724 100644 --- a/packages/client/src/hooks/use-get-security-businesses.ts +++ b/packages/client/src/hooks/use-get-security-businesses.ts @@ -27,13 +27,24 @@ type UseGetSecurityBusinesses = { selectableSecurityBusinesses: Array<{ value: string; label: string }>; }; +type UseGetSecurityBusinessesOptions = { + /** Skips the query entirely — for callers that only need the list in some rows. */ + pause?: boolean; +}; + /** * The businesses that stand for a traded security, for pickers that should offer only those — * the counterparty of a foreign-securities trade, above all. + * + * Pausable because its caller is a table cell: a plain transactions table would otherwise run + * this query once per row for a list it never shows. */ -export const useGetSecurityBusinesses = (): UseGetSecurityBusinesses => { +export const useGetSecurityBusinesses = ({ + pause, +}: UseGetSecurityBusinessesOptions = {}): UseGetSecurityBusinesses => { const [{ data, fetching, error }, fetch] = useQuery({ query: AllSecurityBusinessesDocument, + pause, }); useEffect(() => {