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
28 changes: 28 additions & 0 deletions .changeset/security-counterparty-picker.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const baseUserContext: UserInfo = {
defaultCryptoConversionFiatCurrency: 'USD',
ledgerLock: null,
financialAccountsBusinessesIds: [],
foreignSecuritiesBusinessId: null,
locality: 'IL',
memberships: [],
activeReadScope: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ export function ChargeExtendedInfo({
{transactionsAreReady && (
<ChargeTransactionsTable
transactionsProps={charge}
chargeType={chargeType}
onChange={onExtendedChange}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,13 +17,25 @@ import { TransactionsTable } from '../transactions-table/index.js';

type Props = {
transactionsProps: FragmentType<typeof ChargeTableTransactionsFieldsFragmentDoc>;
chargeType?: ChargeType;
onChange: () => void;
};
Comment thread
gilgardosh marked this conversation as resolved.

export const ChargeTransactionsTable = ({ transactionsProps, onChange }: Props): ReactElement => {
export const ChargeTransactionsTable = ({
transactionsProps,
chargeType,
onChange,
}: Props): ReactElement => {
const { transactions } = getFragmentData(
ChargeTableTransactionsFieldsFragmentDoc,
transactionsProps,
);
return <TransactionsTable transactionsProps={transactions} onChange={onChange} enableEdit />;
return (
<TransactionsTable
transactionsProps={transactions}
onChange={onChange}
chargeType={chargeType}
enableEdit
/>
);
};
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
gilgardosh marked this conversation as resolved.

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<string | null>(suggestedId ?? null);

Expand Down Expand Up @@ -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
}
>
<CheckIcon className="size-4" />
</Button>
Expand Down
7 changes: 7 additions & 0 deletions packages/client/src/components/transactions-table/columns.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<TableFeaturesConfig, TransactionsTableRowType>[] = [
Expand Down
7 changes: 6 additions & 1 deletion packages/client/src/components/transactions-table/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ 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';

type Props = {
transactionsProps: FragmentType<typeof TransactionForTransactionsTableFieldsFragmentDoc>[];
enableEdit?: boolean;
enableChargeLink?: boolean;
/** The charge these transactions belong to, when rendered inside one. */
chargeType?: ChargeType;
onChange?: () => void;
};

Expand All @@ -26,6 +29,7 @@ export const TransactionsTable = ({
onChange,
enableEdit,
enableChargeLink,
chargeType,
}: Props): ReactElement => {
const [editTransactionId, setEditTransactionId] = useState<string | undefined>(undefined);
const [sorting, setSorting] = useState<SortingState>([]);
Expand All @@ -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;
Expand Down
79 changes: 79 additions & 0 deletions packages/client/src/hooks/use-get-security-businesses.ts
Original file line number Diff line number Diff line change
@@ -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<AllSecurityBusinessesQuery['allSecurityBusinesses']>;

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,
};
};
4 changes: 4 additions & 0 deletions packages/client/src/providers/user-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { UserContextDocument, type UserContextQuery } from '../gql/graphql.js';
defaultCryptoConversionFiatCurrency
ledgerLock
financialAccountsBusinessesIds
foreignSecuritiesBusinessId
locality
}
}
Expand Down Expand Up @@ -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;
};
}
Expand All @@ -66,6 +69,7 @@ function toUserInfoContext(
defaultCryptoConversionFiatCurrency: userContext.defaultCryptoConversionFiatCurrency ?? '',
ledgerLock: userContext.ledgerLock,
financialAccountsBusinessesIds: userContext.financialAccountsBusinessesIds ?? [],
foreignSecuritiesBusinessId: userContext.foreignSecuritiesBusinessId ?? null,
locality: userContext.locality ?? '',
};
}
Expand Down
Loading