diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx
index 5245becb5df..b56947979bd 100644
--- a/packages/desktop-client/src/components/FinancesApp.tsx
+++ b/packages/desktop-client/src/components/FinancesApp.tsx
@@ -24,6 +24,7 @@ import { theme } from '../style';
import { getIsOutdated, getLatestVersion } from '../util/versions';
import { UserAccessPage } from './admin/UserAccess/UserAccessPage';
+import { BankSync } from './banksync';
import { BankSyncStatus } from './BankSyncStatus';
import { View } from './common/View';
import { GlobalKeys } from './GlobalKeys';
@@ -248,6 +249,7 @@ export function FinancesApp() {
} />
} />
+ } />
} />
;
+ case 'synced-account-edit':
+ return ;
+
case 'account-menu':
return (
{
+ if (!ts) return 'Unknown';
+
+ const parsed = new Date(parseInt(ts, 10));
+ return `${format(parsed, dateFormat)} ${format(parsed, 'HH:mm:ss')}`;
+};
+
+type AccountRowProps = {
+ account: AccountEntity;
+ hovered: boolean;
+ onHover: (id: AccountEntity['id'] | null) => void;
+ onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
+};
+
+export const AccountRow = memo(
+ ({ account, hovered, onHover, onAction }: AccountRowProps) => {
+ const backgroundFocus = hovered;
+
+ const dateFormat = useDateFormat() || 'MM/dd/yyyy';
+
+ const lastSync = tsToString(account.last_sync, dateFormat);
+
+ return (
+ onHover && onHover(account.id)}
+ onMouseLeave={() => onHover && onHover(null)}
+ >
+
+ {account.name}
+ |
+
+
+ {account.bankName}
+ |
+
+
+ {account.account_sync_source ? lastSync : ''}
+ |
+
+ {account.account_sync_source ? (
+
+
+ |
+ ) : (
+
+
+ |
+ )}
+
+ );
+ },
+);
+
+AccountRow.displayName = 'AccountRow';
diff --git a/packages/desktop-client/src/components/banksync/AccountsHeader.tsx b/packages/desktop-client/src/components/banksync/AccountsHeader.tsx
new file mode 100644
index 00000000000..c5000046705
--- /dev/null
+++ b/packages/desktop-client/src/components/banksync/AccountsHeader.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Cell, TableHeader } from '../table';
+
+type AccountsHeaderProps = {
+ unlinked: boolean;
+};
+
+export function AccountsHeader({ unlinked }: AccountsHeaderProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ |
+ {!unlinked && (
+ <>
+ |
+ |
+ |
+ >
+ )}
+
+ );
+}
diff --git a/packages/desktop-client/src/components/banksync/AccountsList.tsx b/packages/desktop-client/src/components/banksync/AccountsList.tsx
new file mode 100644
index 00000000000..d1930bef993
--- /dev/null
+++ b/packages/desktop-client/src/components/banksync/AccountsList.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import { type AccountEntity } from 'loot-core/src/types/models';
+
+import { View } from '../common/View';
+
+import { AccountRow } from './AccountRow';
+
+type AccountsListProps = {
+ accounts: AccountEntity[];
+ hoveredAccount?: string | null;
+ onHover: (id: AccountEntity['id'] | null) => void;
+ onAction: (account: AccountEntity, action: 'link' | 'edit') => void;
+};
+
+export function AccountsList({
+ accounts,
+ hoveredAccount,
+ onHover,
+ onAction,
+}: AccountsListProps) {
+ if (accounts.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {accounts.map(account => {
+ const hovered = hoveredAccount === account.id;
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/desktop-client/src/components/banksync/EditSyncAccount.tsx b/packages/desktop-client/src/components/banksync/EditSyncAccount.tsx
new file mode 100644
index 00000000000..32426028998
--- /dev/null
+++ b/packages/desktop-client/src/components/banksync/EditSyncAccount.tsx
@@ -0,0 +1,240 @@
+import React, { useMemo, useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+
+import { useTransactions } from 'loot-core/client/data-hooks/transactions';
+import {
+ defaultMappings,
+ type Mappings,
+ mappingsFromString,
+ mappingsToString,
+} from 'loot-core/server/util/custom-sync-mapping';
+import { q } from 'loot-core/src/shared/query';
+import {
+ type TransactionEntity,
+ type AccountEntity,
+} from 'loot-core/src/types/models';
+
+import { useSyncedPref } from '../../hooks/useSyncedPref';
+import { Button } from '../common/Button2';
+import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
+import { Stack } from '../common/Stack';
+import { Text } from '../common/Text';
+import { CheckboxOption } from '../modals/ImportTransactionsModal/CheckboxOption';
+
+import { FieldMapping } from './FieldMapping';
+
+export type TransactionDirection = 'payment' | 'deposit';
+
+type MappableActualFields = 'date' | 'payee' | 'notes';
+
+export type MappableField = {
+ actualField: MappableActualFields;
+ syncFields: string[];
+};
+export type MappableFieldWithExample = {
+ actualField: MappableActualFields;
+ syncFields: {
+ field: string;
+ example: string;
+ }[];
+};
+
+const mappableFields: MappableField[] = [
+ {
+ actualField: 'date',
+ syncFields: [
+ 'date',
+ 'bookingDate',
+ 'valueDate',
+ 'postedDate',
+ 'transactedDate',
+ ],
+ },
+ {
+ actualField: 'payee',
+ syncFields: [
+ 'payeeName',
+ 'creditorName',
+ 'debtorName',
+ 'remittanceInformationUnstructured',
+ 'remittanceInformationUnstructuredArrayString',
+ 'remittanceInformationStructured',
+ 'remittanceInformationStructuredArrayString',
+ 'additionalInformation',
+ ],
+ },
+ {
+ actualField: 'notes',
+ syncFields: [
+ 'notes',
+ 'remittanceInformationUnstructured',
+ 'remittanceInformationUnstructuredArrayString',
+ 'remittanceInformationStructured',
+ 'remittanceInformationStructuredArrayString',
+ 'additionalInformation',
+ ],
+ },
+];
+
+const getFields = (transaction: TransactionEntity) =>
+ mappableFields.map(field => ({
+ actualField: field.actualField,
+ syncFields: field.syncFields
+ .filter(syncField => transaction[syncField as keyof TransactionEntity])
+ .map(syncField => ({
+ field: syncField,
+ example: transaction[syncField as keyof TransactionEntity],
+ })),
+ }));
+
+export type EditSyncAccountProps = {
+ account: AccountEntity;
+};
+
+export function EditSyncAccount({ account }: EditSyncAccountProps) {
+ const { t } = useTranslation();
+
+ const [savedMappings = mappingsToString(defaultMappings), setSavedMappings] =
+ useSyncedPref(`custom-sync-mappings-${account.id}`);
+ const [savedImportNotes = true, setSavedImportNotes] = useSyncedPref(
+ `sync-import-notes-${account.id}`,
+ );
+ const [savedImportPending = true, setSavedImportPending] = useSyncedPref(
+ `sync-import-pending-${account.id}`,
+ );
+
+ const [transactionDirection, setTransactionDirection] =
+ useState('payment');
+ const [importPending, setImportPending] = useState(
+ String(savedImportPending) === 'true',
+ );
+ const [importNotes, setImportNotes] = useState(
+ String(savedImportNotes) === 'true',
+ );
+ const [mappings, setMappings] = useState(
+ mappingsFromString(savedMappings),
+ );
+
+ const transactionQuery = useMemo(
+ () =>
+ q('transactions')
+ .filter({
+ account: account.id,
+ amount: transactionDirection === 'payment' ? { $lte: 0 } : { $gt: 0 },
+ raw_synced_data: { $ne: null },
+ })
+ .options({ splits: 'none' })
+ .select('*'),
+ [account.id, transactionDirection],
+ );
+
+ const { transactions } = useTransactions({
+ query: transactionQuery,
+ });
+
+ const exampleTransaction = useMemo(() => {
+ const data = transactions?.[0]?.raw_synced_data;
+ if (!data) return undefined;
+ try {
+ return JSON.parse(data);
+ } catch (error) {
+ console.error('Failed to parse transaction data:', error);
+ return undefined;
+ }
+ }, [transactions]);
+
+ const onSave = async (close: () => void) => {
+ const mappingsStr = mappingsToString(mappings);
+ setSavedMappings(mappingsStr);
+ setSavedImportPending(String(importPending));
+ setSavedImportNotes(String(importNotes));
+ close();
+ };
+
+ const setMapping = (field: string, value: string) => {
+ setMappings(prev => {
+ const updated = new Map(prev);
+ updated?.get(transactionDirection)?.set(field, value);
+ return updated;
+ });
+ };
+
+ const fields = exampleTransaction ? getFields(exampleTransaction) : [];
+ const mapping = mappings.get(transactionDirection);
+
+ return (
+
+ {({ state: { close } }) => (
+ <>
+ }
+ />
+
+
+ Field mapping
+
+
+ {fields.length > 0 ? (
+
+ ) : (
+
+
+ No transactions found with mappable fields, accounts must have
+ been synced at least once for this function to be available.
+
+
+ )}
+
+
+ Options
+
+
+ setImportPending(!importPending)}
+ >
+ Import pending transactions
+
+
+ setImportNotes(!importNotes)}
+ >
+ Import transaction notes
+
+
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/packages/desktop-client/src/components/banksync/FieldMapping.tsx b/packages/desktop-client/src/components/banksync/FieldMapping.tsx
new file mode 100644
index 00000000000..4c73963e6a2
--- /dev/null
+++ b/packages/desktop-client/src/components/banksync/FieldMapping.tsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { SvgRightArrow2 } from '../../icons/v0';
+import { SvgEquals } from '../../icons/v1';
+import { theme } from '../../style';
+import { Select } from '../common/Select';
+import { Text } from '../common/Text';
+import { Row, Cell, TableHeader } from '../table';
+
+import {
+ type MappableFieldWithExample,
+ type TransactionDirection,
+} from './EditSyncAccount';
+
+const useTransactionDirectionOptions = () => {
+ const { t } = useTranslation();
+
+ const transactionDirectionOptions = [
+ {
+ value: 'payment',
+ label: t('Payment'),
+ },
+ {
+ value: 'deposit',
+ label: t('Deposit'),
+ },
+ ];
+
+ return { transactionDirectionOptions };
+};
+
+type FieldMappingProps = {
+ transactionDirection: TransactionDirection;
+ setTransactionDirection: (newValue: TransactionDirection) => void;
+ fields: MappableFieldWithExample[];
+ mapping: Map;
+ setMapping: (field: string, value: string) => void;
+};
+
+export function FieldMapping({
+ transactionDirection,
+ setTransactionDirection,
+ fields,
+ mapping,
+ setMapping,
+}: FieldMappingProps) {
+ const { t } = useTranslation();
+
+ const { transactionDirectionOptions } = useTransactionDirectionOptions();
+
+ return (
+ <>
+