diff --git a/.changeset/security-counterparty-picker.md b/.changeset/security-counterparty-picker.md
new file mode 100644
index 0000000000..1dac4b11f0
--- /dev/null
+++ b/.changeset/security-counterparty-picker.md
@@ -0,0 +1,28 @@
+---
+'@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` — 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`.
+
+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..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,13 +17,25 @@ import { TransactionsTable } from '../transactions-table/index.js';
type Props = {
transactionsProps: FragmentType;
+ chargeType?: ChargeType;
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..8177714472 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,30 @@ 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({
+ pause: !isSecurityTrade,
+ });
+ 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 +124,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..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';
@@ -55,6 +56,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?: 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 1a45e7e7ce..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';
@@ -18,6 +19,8 @@ type Props = {
transactionsProps: FragmentType[];
enableEdit?: boolean;
enableChargeLink?: boolean;
+ /** The charge these transactions belong to, when rendered inside one. */
+ chargeType?: ChargeType;
onChange?: () => void;
};
@@ -26,6 +29,7 @@ export const TransactionsTable = ({
onChange,
enableEdit,
enableChargeLink,
+ chargeType,
}: Props): ReactElement => {
const [editTransactionId, setEditTransactionId] = useState(undefined);
const [sorting, setSorting] = useState([]);
@@ -44,9 +48,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..4b426d7724
--- /dev/null
+++ b/packages/client/src/hooks/use-get-security-businesses.ts
@@ -0,0 +1,79 @@
+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 }>;
+};
+
+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 = ({
+ pause,
+}: UseGetSecurityBusinessesOptions = {}): UseGetSecurityBusinesses => {
+ const [{ data, fetching, error }, fetch] = useQuery({
+ query: AllSecurityBusinessesDocument,
+ pause,
+ });
+
+ 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 ?? '',
};
}