mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
Move redux state to react-query - account states
This commit is contained in:
@@ -2,24 +2,10 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import type { SyncResponseWithErrors } from 'loot-core/server/accounts/app';
|
||||
import { groupById } from 'loot-core/shared/util';
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { resetApp } from '@desktop-client/app/appSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { markPayeesDirty } from '@desktop-client/payees/payeesSlice';
|
||||
import { createAppAsyncThunk } from '@desktop-client/redux';
|
||||
import type { AppDispatch } from '@desktop-client/redux/store';
|
||||
import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
const sliceName = 'account';
|
||||
|
||||
@@ -29,20 +15,12 @@ type AccountState = {
|
||||
};
|
||||
accountsSyncing: Array<AccountEntity['id']>;
|
||||
updatedAccounts: Array<AccountEntity['id']>;
|
||||
accounts: AccountEntity[];
|
||||
isAccountsLoading: boolean;
|
||||
isAccountsLoaded: boolean;
|
||||
isAccountsDirty: boolean;
|
||||
};
|
||||
|
||||
const initialState: AccountState = {
|
||||
failedAccounts: {},
|
||||
accountsSyncing: [],
|
||||
updatedAccounts: [],
|
||||
accounts: [],
|
||||
isAccountsLoading: false,
|
||||
isAccountsLoaded: false,
|
||||
isAccountsDirty: false,
|
||||
};
|
||||
|
||||
type SetAccountsSyncingPayload = {
|
||||
@@ -102,542 +80,11 @@ const accountsSlice = createSlice({
|
||||
id => id !== action.payload.id,
|
||||
);
|
||||
},
|
||||
markAccountsDirty(state) {
|
||||
_markAccountsDirty(state);
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(resetApp, () => initialState);
|
||||
|
||||
builder.addCase(createAccount.fulfilled, _markAccountsDirty);
|
||||
builder.addCase(updateAccount.fulfilled, _markAccountsDirty);
|
||||
builder.addCase(closeAccount.fulfilled, _markAccountsDirty);
|
||||
builder.addCase(reopenAccount.fulfilled, _markAccountsDirty);
|
||||
|
||||
builder.addCase(reloadAccounts.fulfilled, (state, action) => {
|
||||
_loadAccounts(state, action.payload);
|
||||
});
|
||||
|
||||
builder.addCase(reloadAccounts.rejected, state => {
|
||||
state.isAccountsLoading = false;
|
||||
});
|
||||
|
||||
builder.addCase(reloadAccounts.pending, state => {
|
||||
state.isAccountsLoading = true;
|
||||
});
|
||||
|
||||
builder.addCase(getAccounts.fulfilled, (state, action) => {
|
||||
_loadAccounts(state, action.payload);
|
||||
});
|
||||
|
||||
builder.addCase(getAccounts.rejected, state => {
|
||||
state.isAccountsLoading = false;
|
||||
});
|
||||
|
||||
builder.addCase(getAccounts.pending, state => {
|
||||
state.isAccountsLoading = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
type CreateAccountPayload = {
|
||||
name: string;
|
||||
balance: number;
|
||||
offBudget: boolean;
|
||||
};
|
||||
|
||||
export const createAccount = createAppAsyncThunk(
|
||||
`${sliceName}/createAccount`,
|
||||
async ({ name, balance, offBudget }: CreateAccountPayload) => {
|
||||
const id = await send('account-create', {
|
||||
name,
|
||||
balance,
|
||||
offBudget,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
type CloseAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
transferAccountId?: AccountEntity['id'];
|
||||
categoryId?: CategoryEntity['id'];
|
||||
forced?: boolean;
|
||||
};
|
||||
|
||||
export const closeAccount = createAppAsyncThunk(
|
||||
`${sliceName}/closeAccount`,
|
||||
async ({
|
||||
id,
|
||||
transferAccountId,
|
||||
categoryId,
|
||||
forced,
|
||||
}: CloseAccountPayload) => {
|
||||
await send('account-close', {
|
||||
id,
|
||||
transferAccountId: transferAccountId || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
forced,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
type ReopenAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
};
|
||||
|
||||
export const reopenAccount = createAppAsyncThunk(
|
||||
`${sliceName}/reopenAccount`,
|
||||
async ({ id }: ReopenAccountPayload) => {
|
||||
await send('account-reopen', { id });
|
||||
},
|
||||
);
|
||||
|
||||
type UpdateAccountPayload = {
|
||||
account: AccountEntity;
|
||||
};
|
||||
|
||||
export const updateAccount = createAppAsyncThunk(
|
||||
`${sliceName}/updateAccount`,
|
||||
async ({ account }: UpdateAccountPayload) => {
|
||||
await send('account-update', account);
|
||||
return account;
|
||||
},
|
||||
);
|
||||
|
||||
export const getAccounts = createAppAsyncThunk(
|
||||
`${sliceName}/getAccounts`,
|
||||
async () => {
|
||||
// TODO: Force cast to AccountEntity.
|
||||
// Server is currently returning the DB model it should return the entity model instead.
|
||||
const accounts = (await send('accounts-get')) as unknown as AccountEntity[];
|
||||
return accounts;
|
||||
},
|
||||
{
|
||||
condition: (_, { getState }) => {
|
||||
const { account } = getState();
|
||||
return (
|
||||
!account.isAccountsLoading &&
|
||||
(account.isAccountsDirty || !account.isAccountsLoaded)
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const reloadAccounts = createAppAsyncThunk(
|
||||
`${sliceName}/reloadAccounts`,
|
||||
async () => {
|
||||
// TODO: Force cast to AccountEntity.
|
||||
// Server is currently returning the DB model it should return the entity model instead.
|
||||
const accounts = (await send('accounts-get')) as unknown as AccountEntity[];
|
||||
return accounts;
|
||||
},
|
||||
);
|
||||
|
||||
type UnlinkAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
};
|
||||
|
||||
export const unlinkAccount = createAppAsyncThunk(
|
||||
`${sliceName}/unlinkAccount`,
|
||||
async ({ id }: UnlinkAccountPayload, { dispatch }) => {
|
||||
await send('account-unlink', { id });
|
||||
dispatch(actions.markAccountSuccess({ id }));
|
||||
dispatch(actions.markAccountsDirty());
|
||||
},
|
||||
);
|
||||
|
||||
// Shared base type for link account payloads
|
||||
type LinkAccountBasePayload = {
|
||||
upgradingId?: AccountEntity['id'];
|
||||
offBudget?: boolean;
|
||||
startingDate?: string;
|
||||
startingBalance?: number;
|
||||
};
|
||||
|
||||
type LinkAccountPayload = LinkAccountBasePayload & {
|
||||
requisitionId: string;
|
||||
account: SyncServerGoCardlessAccount;
|
||||
};
|
||||
|
||||
export const linkAccount = createAppAsyncThunk(
|
||||
`${sliceName}/linkAccount`,
|
||||
async (
|
||||
{
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
await send('gocardless-accounts-link', {
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
dispatch(markPayeesDirty());
|
||||
dispatch(markAccountsDirty());
|
||||
},
|
||||
);
|
||||
|
||||
type LinkAccountSimpleFinPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerSimpleFinAccount;
|
||||
};
|
||||
|
||||
export const linkAccountSimpleFin = createAppAsyncThunk(
|
||||
`${sliceName}/linkAccountSimpleFin`,
|
||||
async (
|
||||
{
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountSimpleFinPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
await send('simplefin-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
dispatch(markPayeesDirty());
|
||||
dispatch(markAccountsDirty());
|
||||
},
|
||||
);
|
||||
|
||||
type LinkAccountPluggyAiPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerPluggyAiAccount;
|
||||
};
|
||||
|
||||
export const linkAccountPluggyAi = createAppAsyncThunk(
|
||||
`${sliceName}/linkAccountPluggyAi`,
|
||||
async (
|
||||
{
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountPluggyAiPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
await send('pluggyai-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
dispatch(markPayeesDirty());
|
||||
dispatch(markAccountsDirty());
|
||||
},
|
||||
);
|
||||
|
||||
function handleSyncResponse(
|
||||
accountId: AccountEntity['id'],
|
||||
res: SyncResponseWithErrors,
|
||||
dispatch: AppDispatch,
|
||||
resNewTransactions: Array<TransactionEntity['id']>,
|
||||
resMatchedTransactions: Array<TransactionEntity['id']>,
|
||||
resUpdatedAccounts: Array<AccountEntity['id']>,
|
||||
) {
|
||||
const { errors, newTransactions, matchedTransactions, updatedAccounts } = res;
|
||||
const { markAccountFailed, markAccountSuccess } = accountsSlice.actions;
|
||||
|
||||
// Mark the account as failed or succeeded (depending on sync output)
|
||||
const [error] = errors;
|
||||
if (error) {
|
||||
// We only want to mark the account as having problem if it
|
||||
// was a real syncing error.
|
||||
if ('type' in error && error.type === 'SyncError') {
|
||||
dispatch(
|
||||
markAccountFailed({
|
||||
id: accountId,
|
||||
errorType: error.category,
|
||||
errorCode: error.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
dispatch(markAccountSuccess({ id: accountId }));
|
||||
}
|
||||
|
||||
// Dispatch errors (if any)
|
||||
errors.forEach(error => {
|
||||
if ('type' in error && error.type === 'SyncError') {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
internal: 'internal' in error ? error.internal : undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
resNewTransactions.push(...newTransactions);
|
||||
resMatchedTransactions.push(...matchedTransactions);
|
||||
resUpdatedAccounts.push(...updatedAccounts);
|
||||
|
||||
dispatch(markAccountsDirty());
|
||||
|
||||
return newTransactions.length > 0 || matchedTransactions.length > 0;
|
||||
}
|
||||
|
||||
type SyncAccountsPayload = {
|
||||
id?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
|
||||
export const syncAccounts = createAppAsyncThunk(
|
||||
`${sliceName}/syncAccounts`,
|
||||
async ({ id }: SyncAccountsPayload, { dispatch, getState }) => {
|
||||
// Disallow two parallel sync operations
|
||||
const accountsState = getState().account;
|
||||
if (accountsState.accountsSyncing.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { setAccountsSyncing } = accountsSlice.actions;
|
||||
|
||||
if (id === 'uncategorized') {
|
||||
// Sync no accounts
|
||||
dispatch(setAccountsSyncing({ ids: [] }));
|
||||
return false;
|
||||
}
|
||||
|
||||
const { accounts } = getState().account;
|
||||
let accountIdsToSync: string[];
|
||||
if (id === 'offbudget' || id === 'onbudget') {
|
||||
const targetOffbudget = id === 'offbudget' ? 1 : 0;
|
||||
accountIdsToSync = accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone, offbudget }) =>
|
||||
!!bank && !closed && !tombstone && offbudget === targetOffbudget,
|
||||
)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map(({ id }) => id);
|
||||
} else if (id) {
|
||||
accountIdsToSync = [id];
|
||||
} else {
|
||||
// Default: all accounts
|
||||
accountIdsToSync = accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone }) => !!bank && !closed && !tombstone,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.offbudget === b.offbudget
|
||||
? a.sort_order - b.sort_order
|
||||
: a.offbudget - b.offbudget,
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
|
||||
// TODO: Force cast to AccountEntity.
|
||||
// Server is currently returning the DB model it should return the entity model instead.
|
||||
const accountsData = (await send(
|
||||
'accounts-get',
|
||||
)) as unknown as AccountEntity[];
|
||||
const simpleFinAccounts = accountsData.filter(
|
||||
a =>
|
||||
a.account_sync_source === 'simpleFin' &&
|
||||
accountIdsToSync.includes(a.id),
|
||||
);
|
||||
|
||||
let isSyncSuccess = false;
|
||||
const newTransactions: Array<TransactionEntity['id']> = [];
|
||||
const matchedTransactions: Array<TransactionEntity['id']> = [];
|
||||
const updatedAccounts: Array<AccountEntity['id']> = [];
|
||||
|
||||
if (simpleFinAccounts.length > 0) {
|
||||
console.log('Using SimpleFin batch sync');
|
||||
|
||||
const res = await send('simplefin-batch-sync', {
|
||||
ids: simpleFinAccounts.map(a => a.id),
|
||||
});
|
||||
|
||||
for (const account of res) {
|
||||
const success = handleSyncResponse(
|
||||
account.accountId,
|
||||
account.res,
|
||||
dispatch,
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
updatedAccounts,
|
||||
);
|
||||
if (success) isSyncSuccess = true;
|
||||
}
|
||||
|
||||
accountIdsToSync = accountIdsToSync.filter(
|
||||
id => !simpleFinAccounts.find(sfa => sfa.id === id),
|
||||
);
|
||||
}
|
||||
|
||||
// Loop through the accounts and perform sync operation.. one by one
|
||||
for (let idx = 0; idx < accountIdsToSync.length; idx++) {
|
||||
const accountId = accountIdsToSync[idx];
|
||||
|
||||
// Perform sync operation
|
||||
const res = await send('accounts-bank-sync', {
|
||||
ids: [accountId],
|
||||
});
|
||||
|
||||
const success = handleSyncResponse(
|
||||
accountId,
|
||||
res,
|
||||
dispatch,
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
updatedAccounts,
|
||||
);
|
||||
|
||||
if (success) isSyncSuccess = true;
|
||||
|
||||
// Dispatch the ids for the accounts that are yet to be synced
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync.slice(idx + 1) }));
|
||||
}
|
||||
|
||||
// Set new transactions
|
||||
dispatch(
|
||||
setNewTransactions({
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(markUpdatedAccounts({ ids: updatedAccounts }));
|
||||
|
||||
// Reset the sync state back to empty (fallback in case something breaks
|
||||
// in the logic above)
|
||||
dispatch(setAccountsSyncing({ ids: [] }));
|
||||
return isSyncSuccess;
|
||||
},
|
||||
);
|
||||
|
||||
type MoveAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
targetId: AccountEntity['id'] | null;
|
||||
};
|
||||
|
||||
export const moveAccount = createAppAsyncThunk(
|
||||
`${sliceName}/moveAccount`,
|
||||
async ({ id, targetId }: MoveAccountPayload, { dispatch }) => {
|
||||
await send('account-move', { id, targetId });
|
||||
dispatch(markAccountsDirty());
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
);
|
||||
|
||||
type ImportPreviewTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
};
|
||||
|
||||
export const importPreviewTransactions = createAppAsyncThunk(
|
||||
`${sliceName}/importPreviewTransactions`,
|
||||
async (
|
||||
{ accountId, transactions }: ImportPreviewTransactionsPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
const { errors = [], updatedPreview } = await send('transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return updatedPreview;
|
||||
},
|
||||
);
|
||||
|
||||
type ImportTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
reconcile: boolean;
|
||||
};
|
||||
|
||||
export const importTransactions = createAppAsyncThunk(
|
||||
`${sliceName}/importTransactions`,
|
||||
async (
|
||||
{ accountId, transactions, reconcile }: ImportTransactionsPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
if (!reconcile) {
|
||||
await send('api/transactions-add', {
|
||||
accountId,
|
||||
transactions,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
added,
|
||||
updated,
|
||||
} = await send('transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
dispatch(
|
||||
setNewTransactions({
|
||||
newTransactions: added,
|
||||
matchedTransactions: updated,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(
|
||||
markUpdatedAccounts({
|
||||
ids: added.length > 0 ? [accountId] : [],
|
||||
}),
|
||||
);
|
||||
|
||||
return added.length > 0 || updated.length > 0;
|
||||
},
|
||||
);
|
||||
|
||||
export const getAccountsById = memoizeOne(
|
||||
(accounts: AccountEntity[] | null | undefined) => groupById(accounts),
|
||||
@@ -646,39 +93,12 @@ export const getAccountsById = memoizeOne(
|
||||
export const { name, reducer, getInitialState } = accountsSlice;
|
||||
export const actions = {
|
||||
...accountsSlice.actions,
|
||||
createAccount,
|
||||
updateAccount,
|
||||
getAccounts,
|
||||
reloadAccounts,
|
||||
closeAccount,
|
||||
reopenAccount,
|
||||
linkAccount,
|
||||
linkAccountSimpleFin,
|
||||
linkAccountPluggyAi,
|
||||
moveAccount,
|
||||
unlinkAccount,
|
||||
syncAccounts,
|
||||
};
|
||||
|
||||
export const {
|
||||
markAccountRead,
|
||||
markAccountFailed,
|
||||
markAccountSuccess,
|
||||
markAccountsDirty,
|
||||
markUpdatedAccounts,
|
||||
setAccountsSyncing,
|
||||
} = accountsSlice.actions;
|
||||
|
||||
function _loadAccounts(
|
||||
state: AccountState,
|
||||
accounts: AccountState['accounts'],
|
||||
) {
|
||||
state.accounts = accounts;
|
||||
state.isAccountsLoading = false;
|
||||
state.isAccountsLoaded = true;
|
||||
state.isAccountsDirty = false;
|
||||
}
|
||||
|
||||
function _markAccountsDirty(state: AccountState) {
|
||||
state.isAccountsDirty = true;
|
||||
}
|
||||
|
||||
2
packages/desktop-client/src/accounts/index.ts
Normal file
2
packages/desktop-client/src/accounts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './queries';
|
||||
export * from './mutations';
|
||||
956
packages/desktop-client/src/accounts/mutations.ts
Normal file
956
packages/desktop-client/src/accounts/mutations.ts
Normal file
@@ -0,0 +1,956 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
useQueryClient,
|
||||
useMutation,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { type SyncResponseWithErrors } from 'loot-core/server/accounts/app';
|
||||
import {
|
||||
type SyncServerGoCardlessAccount,
|
||||
type SyncServerPluggyAiAccount,
|
||||
type SyncServerSimpleFinAccount,
|
||||
type AccountEntity,
|
||||
type CategoryEntity,
|
||||
type TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
markAccountFailed,
|
||||
markAccountSuccess,
|
||||
markUpdatedAccounts,
|
||||
setAccountsSyncing,
|
||||
} from './accountsSlice';
|
||||
import { accountQueries } from './queries';
|
||||
|
||||
import { sync } from '@desktop-client/app/appSlice';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { markPayeesDirty } from '@desktop-client/payees/payeesSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { type AppDispatch } from '@desktop-client/redux/store';
|
||||
import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
type CreateAccountPayload = {
|
||||
name: string;
|
||||
balance: number;
|
||||
offBudget: boolean;
|
||||
};
|
||||
|
||||
export function useCreateAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, balance, offBudget }: CreateAccountPayload) => {
|
||||
const id = await send('account-create', {
|
||||
name,
|
||||
balance,
|
||||
offBudget,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error creating account:', error);
|
||||
dispatchErrorNotification(
|
||||
t('There was an error creating the account. Please try again.'),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type CloseAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
transferAccountId?: AccountEntity['id'];
|
||||
categoryId?: CategoryEntity['id'];
|
||||
forced?: boolean;
|
||||
};
|
||||
|
||||
export function useCloseAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
transferAccountId,
|
||||
categoryId,
|
||||
forced,
|
||||
}: CloseAccountPayload) => {
|
||||
await send('account-close', {
|
||||
id,
|
||||
transferAccountId: transferAccountId || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
forced,
|
||||
});
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error closing account:', error);
|
||||
dispatchErrorNotification(
|
||||
t('There was an error closing the account. Please try again.'),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ReopenAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
};
|
||||
|
||||
export function useReopenAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: ReopenAccountPayload) => {
|
||||
await send('account-reopen', { id });
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error re-opening account:', error);
|
||||
dispatchErrorNotification(
|
||||
t('There was an error re-opening the account. Please try again.'),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateAccountPayload = {
|
||||
account: AccountEntity;
|
||||
};
|
||||
|
||||
export function useUpdateAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ account }: UpdateAccountPayload) => {
|
||||
await send('account-update', account);
|
||||
return account;
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error updating account:', error);
|
||||
dispatchErrorNotification(
|
||||
t('There was an error updating the account. Please try again.'),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type MoveAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
targetId: AccountEntity['id'] | null;
|
||||
};
|
||||
|
||||
export function useMoveAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, targetId }: MoveAccountPayload) => {
|
||||
await send('account-move', { id, targetId });
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error moving account:', error);
|
||||
dispatchErrorNotification(
|
||||
t('There was an error moving the account. Please try again.'),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ImportPreviewTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
};
|
||||
|
||||
export function useImportPreviewTransactionsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
accountId,
|
||||
transactions,
|
||||
}: ImportPreviewTransactionsPayload) => {
|
||||
const { errors = [], updatedPreview } = await send(
|
||||
'transactions-import',
|
||||
{
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: true,
|
||||
},
|
||||
);
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return updatedPreview;
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error importing preview transactions to account:', error);
|
||||
dispatchErrorNotification(
|
||||
t(
|
||||
'There was an error importing preview transactions to the account. Please try again.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ImportTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
reconcile: boolean;
|
||||
};
|
||||
|
||||
export function useImportTransactionsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
accountId,
|
||||
transactions,
|
||||
reconcile,
|
||||
}: ImportTransactionsPayload) => {
|
||||
if (!reconcile) {
|
||||
await send('api/transactions-add', {
|
||||
accountId,
|
||||
transactions,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
added,
|
||||
updated,
|
||||
} = await send('transactions-import', {
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: false,
|
||||
});
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
dispatch(
|
||||
setNewTransactions({
|
||||
newTransactions: added,
|
||||
matchedTransactions: updated,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(
|
||||
markUpdatedAccounts({
|
||||
ids: added.length > 0 ? [accountId] : [],
|
||||
}),
|
||||
);
|
||||
|
||||
return added.length > 0 || updated.length > 0;
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error importing transactions to account:', error);
|
||||
dispatchErrorNotification(
|
||||
t(
|
||||
'There was an error importing transactions to the account. Please try again.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type UnlinkAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
};
|
||||
|
||||
export function useUnlinkAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: UnlinkAccountPayload) => {
|
||||
await send('account-unlink', { id });
|
||||
dispatch(markAccountSuccess({ id }));
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error unlinking account:', error);
|
||||
dispatchErrorNotification(
|
||||
t('There was an error unlinking the account. Please try again.'),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Shared base type for link account payloads
|
||||
type LinkAccountBasePayload = {
|
||||
upgradingId?: AccountEntity['id'];
|
||||
offBudget?: boolean;
|
||||
startingDate?: string;
|
||||
startingBalance?: number;
|
||||
};
|
||||
|
||||
type LinkAccountPayload = LinkAccountBasePayload & {
|
||||
requisitionId: string;
|
||||
account: SyncServerGoCardlessAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountPayload) => {
|
||||
await send('gocardless-accounts-link', {
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error linking account:', error);
|
||||
dispatchErrorNotification(
|
||||
t('There was an error linking the account. Please try again.'),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type LinkAccountSimpleFinPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerSimpleFinAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountSimpleFinMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountSimpleFinPayload) => {
|
||||
await send('simplefin-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error linking account to SimpleFIN:', error);
|
||||
dispatchErrorNotification(
|
||||
t(
|
||||
'There was an error linking the account to SimpleFIN. Please try again.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type LinkAccountPluggyAiPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerPluggyAiAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountPluggyAiMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountPluggyAiPayload) => {
|
||||
await send('pluggyai-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error linking account to PluggyAI:', error);
|
||||
dispatchErrorNotification(
|
||||
t(
|
||||
'There was an error linking the account to PluggyAI. Please try again.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SyncAccountsPayload = {
|
||||
id?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
|
||||
export function useSyncAccountsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const accounts = useAccounts();
|
||||
const accountState = useSelector(state => state.account);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: SyncAccountsPayload) => {
|
||||
const { accountsSyncing } = accountState;
|
||||
|
||||
// Disallow two parallel sync operations
|
||||
if (accountsSyncing.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id === 'uncategorized') {
|
||||
// Sync no accounts
|
||||
dispatch(setAccountsSyncing({ ids: [] }));
|
||||
return false;
|
||||
}
|
||||
|
||||
let accountIdsToSync: string[];
|
||||
if (id === 'offbudget' || id === 'onbudget') {
|
||||
const targetOffbudget = id === 'offbudget' ? 1 : 0;
|
||||
accountIdsToSync = accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone, offbudget }) =>
|
||||
!!bank && !closed && !tombstone && offbudget === targetOffbudget,
|
||||
)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map(({ id }) => id);
|
||||
} else if (id) {
|
||||
accountIdsToSync = [id];
|
||||
} else {
|
||||
// Default: all accounts
|
||||
accountIdsToSync = accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone }) => !!bank && !closed && !tombstone,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.offbudget === b.offbudget
|
||||
? a.sort_order - b.sort_order
|
||||
: a.offbudget - b.offbudget,
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
|
||||
const simpleFinAccounts = accounts.filter(
|
||||
a =>
|
||||
a.account_sync_source === 'simpleFin' &&
|
||||
accountIdsToSync.includes(a.id),
|
||||
);
|
||||
|
||||
let isSyncSuccess = false;
|
||||
const newTransactions: Array<TransactionEntity['id']> = [];
|
||||
const matchedTransactions: Array<TransactionEntity['id']> = [];
|
||||
const updatedAccounts: Array<AccountEntity['id']> = [];
|
||||
|
||||
if (simpleFinAccounts.length > 0) {
|
||||
console.log('Using SimpleFin batch sync');
|
||||
|
||||
const res = await send('simplefin-batch-sync', {
|
||||
ids: simpleFinAccounts.map(a => a.id),
|
||||
});
|
||||
|
||||
for (const account of res) {
|
||||
const success = handleSyncResponse(
|
||||
account.accountId,
|
||||
account.res,
|
||||
dispatch,
|
||||
queryClient,
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
updatedAccounts,
|
||||
);
|
||||
if (success) isSyncSuccess = true;
|
||||
}
|
||||
|
||||
accountIdsToSync = accountIdsToSync.filter(
|
||||
id => !simpleFinAccounts.find(sfa => sfa.id === id),
|
||||
);
|
||||
}
|
||||
|
||||
// Loop through the accounts and perform sync operation.. one by one
|
||||
for (let idx = 0; idx < accountIdsToSync.length; idx++) {
|
||||
const accountId = accountIdsToSync[idx];
|
||||
|
||||
// Perform sync operation
|
||||
const res = await send('accounts-bank-sync', {
|
||||
ids: [accountId],
|
||||
});
|
||||
|
||||
const success = handleSyncResponse(
|
||||
accountId,
|
||||
res,
|
||||
dispatch,
|
||||
queryClient,
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
updatedAccounts,
|
||||
);
|
||||
|
||||
if (success) isSyncSuccess = true;
|
||||
|
||||
// Dispatch the ids for the accounts that are yet to be synced
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync.slice(idx + 1) }));
|
||||
}
|
||||
|
||||
// Set new transactions
|
||||
dispatch(
|
||||
setNewTransactions({
|
||||
newTransactions,
|
||||
matchedTransactions,
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(markUpdatedAccounts({ ids: updatedAccounts }));
|
||||
|
||||
// Reset the sync state back to empty (fallback in case something breaks
|
||||
// in the logic above)
|
||||
dispatch(setAccountsSyncing({ ids: [] }));
|
||||
return isSyncSuccess;
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error linking account to PluggyAI:', error);
|
||||
dispatchErrorNotification(
|
||||
t(
|
||||
'There was an error linking the account to PluggyAI. Please try again.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSyncResponse(
|
||||
accountId: AccountEntity['id'],
|
||||
res: SyncResponseWithErrors,
|
||||
dispatch: AppDispatch,
|
||||
queryClient: QueryClient,
|
||||
resNewTransactions: Array<TransactionEntity['id']>,
|
||||
resMatchedTransactions: Array<TransactionEntity['id']>,
|
||||
resUpdatedAccounts: Array<AccountEntity['id']>,
|
||||
) {
|
||||
const { errors, newTransactions, matchedTransactions, updatedAccounts } = res;
|
||||
|
||||
// Mark the account as failed or succeeded (depending on sync output)
|
||||
const [error] = errors;
|
||||
if (error) {
|
||||
// We only want to mark the account as having problem if it
|
||||
// was a real syncing error.
|
||||
if ('type' in error && error.type === 'SyncError') {
|
||||
dispatch(
|
||||
markAccountFailed({
|
||||
id: accountId,
|
||||
errorType: error.category,
|
||||
errorCode: error.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
dispatch(markAccountSuccess({ id: accountId }));
|
||||
}
|
||||
|
||||
// Dispatch errors (if any)
|
||||
errors.forEach(error => {
|
||||
if ('type' in error && error.type === 'SyncError') {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
internal: 'internal' in error ? error.internal : undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
resNewTransactions.push(...newTransactions);
|
||||
resMatchedTransactions.push(...matchedTransactions);
|
||||
resUpdatedAccounts.push(...updatedAccounts);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
|
||||
return newTransactions.length > 0 || matchedTransactions.length > 0;
|
||||
}
|
||||
|
||||
type SyncAndDownloadPayload = {
|
||||
id?: AccountEntity['id'];
|
||||
};
|
||||
|
||||
export function useSyncAndDownloadMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const invalidateAccountLists = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (message: string) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const syncAccounts = useSyncAccountsMutation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: SyncAndDownloadPayload) => {
|
||||
// It is *critical* that we sync first because of transaction
|
||||
// reconciliation. We want to get all transactions that other
|
||||
// clients have already made, so that imported transactions can be
|
||||
// reconciled against them. Otherwise, two clients will each add
|
||||
// new transactions from the bank and create duplicate ones.
|
||||
const syncState = await dispatch(sync()).unwrap();
|
||||
if (syncState.error) {
|
||||
return { error: syncState.error };
|
||||
}
|
||||
|
||||
const hasDownloaded = await syncAccounts.mutateAsync({ id });
|
||||
|
||||
if (hasDownloaded) {
|
||||
// Sync again afterwards if new transactions were created
|
||||
const syncState = await dispatch(sync()).unwrap();
|
||||
if (syncState.error) {
|
||||
return { error: syncState.error };
|
||||
}
|
||||
|
||||
// `hasDownloaded` is already true, we know there has been
|
||||
// updates
|
||||
return true;
|
||||
}
|
||||
return { hasUpdated: hasDownloaded };
|
||||
},
|
||||
onSuccess: invalidateAccountLists,
|
||||
onError: error => {
|
||||
console.error('Error linking account to PluggyAI:', error);
|
||||
dispatchErrorNotification(
|
||||
t(
|
||||
'There was an error linking the account to PluggyAI. Please try again.',
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
30
packages/desktop-client/src/accounts/queries.ts
Normal file
30
packages/desktop-client/src/accounts/queries.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
export const accountQueries = {
|
||||
all: () => ['accounts'],
|
||||
lists: () => [...accountQueries.all(), 'lists'],
|
||||
list: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
queryKey: [...accountQueries.lists()],
|
||||
queryFn: async () => {
|
||||
const accounts: AccountEntity[] = await send('accounts-get');
|
||||
return accounts;
|
||||
},
|
||||
placeholderData: [],
|
||||
// Manually invalidated when categories change
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
listOnBudget: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: accounts => accounts.filter(account => !!!account.offbudget),
|
||||
}),
|
||||
listOffBudget: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: accounts => accounts.filter(account => !!account.offbudget),
|
||||
}),
|
||||
};
|
||||
@@ -3,10 +3,8 @@ import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { getUploadError } from 'loot-core/shared/errors';
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
import type { AtLeastOne } from 'loot-core/types/util';
|
||||
import { type AtLeastOne } from 'loot-core/types/util';
|
||||
|
||||
import { syncAccounts } from '@desktop-client/accounts/accountsSlice';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { loadPrefs } from '@desktop-client/prefs/prefsSlice';
|
||||
import { createAppAsyncThunk } from '@desktop-client/redux';
|
||||
@@ -127,42 +125,6 @@ export const getLatestAppVersion = createAppAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
type SyncAndDownloadPayload = {
|
||||
accountId?: AccountEntity['id'] | string;
|
||||
};
|
||||
|
||||
export const syncAndDownload = createAppAsyncThunk(
|
||||
`${sliceName}/syncAndDownload`,
|
||||
async ({ accountId }: SyncAndDownloadPayload, { dispatch }) => {
|
||||
// It is *critical* that we sync first because of transaction
|
||||
// reconciliation. We want to get all transactions that other
|
||||
// clients have already made, so that imported transactions can be
|
||||
// reconciled against them. Otherwise, two clients will each add
|
||||
// new transactions from the bank and create duplicate ones.
|
||||
const syncState = await dispatch(sync()).unwrap();
|
||||
if (syncState.error) {
|
||||
return { error: syncState.error };
|
||||
}
|
||||
|
||||
const hasDownloaded = await dispatch(
|
||||
syncAccounts({ id: accountId }),
|
||||
).unwrap();
|
||||
|
||||
if (hasDownloaded) {
|
||||
// Sync again afterwards if new transactions were created
|
||||
const syncState = await dispatch(sync()).unwrap();
|
||||
if (syncState.error) {
|
||||
return { error: syncState.error };
|
||||
}
|
||||
|
||||
// `hasDownloaded` is already true, we know there has been
|
||||
// updates
|
||||
return true;
|
||||
}
|
||||
return { hasUpdated: hasDownloaded };
|
||||
},
|
||||
);
|
||||
|
||||
// Workaround for partial types in actions.
|
||||
// https://github.com/reduxjs/redux-toolkit/issues/1423#issuecomment-902680573
|
||||
type SetAppStatePayload = AtLeastOne<AppState>;
|
||||
@@ -195,7 +157,6 @@ export const actions = {
|
||||
updateApp,
|
||||
resetSync,
|
||||
sync,
|
||||
syncAndDownload,
|
||||
getLatestAppVersion,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Navigate, Route, Routes, useHref, useLocation } from 'react-router';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
|
||||
@@ -28,10 +29,10 @@ import { FloatableSidebar } from './sidebar';
|
||||
import { ManageTagsPage } from './tags/ManageTagsPage';
|
||||
import { Titlebar } from './Titlebar';
|
||||
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
import { getLatestAppVersion, sync } from '@desktop-client/app/appSlice';
|
||||
import { ProtectedRoute } from '@desktop-client/auth/ProtectedRoute';
|
||||
import { Permissions } from '@desktop-client/auth/types';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
|
||||
@@ -85,8 +86,10 @@ export function FinancesApp() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const accounts = useAccounts();
|
||||
const isAccountsLoaded = useSelector(state => state.account.isAccountsLoaded);
|
||||
// TODO: Replace with `useAccounts` hook once it's updated to return the useQuery results.
|
||||
const { data: accounts, isSuccess: isAccountsLoaded } = useQuery(
|
||||
accountQueries.list(),
|
||||
);
|
||||
|
||||
const versionInfo = useSelector(state => state.app.versionInfo);
|
||||
const [notifyWhenUpdateIsAvailable] = useGlobalPref(
|
||||
|
||||
@@ -40,13 +40,13 @@ import { AccountEmptyMessage } from './AccountEmptyMessage';
|
||||
import { AccountHeader } from './Header';
|
||||
|
||||
import {
|
||||
markAccountRead,
|
||||
reopenAccount,
|
||||
unlinkAccount,
|
||||
updateAccount,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
import { syncAndDownload } from '@desktop-client/app/appSlice';
|
||||
import type { SavedFilter } from '@desktop-client/components/filters/SavedFilterMenuButton';
|
||||
useReopenAccountMutation,
|
||||
useSyncAndDownloadMutation,
|
||||
useUnlinkAccountMutation,
|
||||
useUpdateAccountMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import { markAccountRead } from '@desktop-client/accounts/accountsSlice';
|
||||
import { type SavedFilter } from '@desktop-client/components/filters/SavedFilterMenuButton';
|
||||
import { TransactionList } from '@desktop-client/components/transactions/TransactionList';
|
||||
import { validateAccountName } from '@desktop-client/components/util/accountValidation';
|
||||
import { useAccountPreviewTransactions } from '@desktop-client/hooks/useAccountPreviewTransactions';
|
||||
@@ -244,7 +244,12 @@ type AccountInternalProps = {
|
||||
accountsSyncing: string[];
|
||||
dispatch: AppDispatch;
|
||||
onSetTransfer: ReturnType<typeof useTransactionBatchActions>['onSetTransfer'];
|
||||
onReopenAccount: (id: AccountEntity['id']) => void;
|
||||
onUpdateAccount: (account: AccountEntity) => void;
|
||||
onUnlinkAccount: (id: AccountEntity['id']) => void;
|
||||
onSyncAndDownload: (accountId?: AccountEntity['id']) => void;
|
||||
};
|
||||
|
||||
type AccountInternalState = {
|
||||
search: string;
|
||||
filterConditions: ConditionEntity[];
|
||||
@@ -567,9 +572,7 @@ class AccountInternal extends PureComponent<
|
||||
const accountId = this.props.accountId;
|
||||
const account = this.props.accounts.find(acct => acct.id === accountId);
|
||||
|
||||
await this.props.dispatch(
|
||||
syncAndDownload({ accountId: account ? account.id : accountId }),
|
||||
);
|
||||
this.props.onSyncAndDownload(account ? account.id : accountId);
|
||||
};
|
||||
|
||||
onImport = async () => {
|
||||
@@ -755,7 +758,7 @@ class AccountInternal extends PureComponent<
|
||||
if (!account) {
|
||||
throw new Error(`Account with ID ${this.props.accountId} not found.`);
|
||||
}
|
||||
this.props.dispatch(updateAccount({ account: { ...account, name } }));
|
||||
this.props.onUpdateAccount({ ...account, name });
|
||||
this.setState({ nameError: '' });
|
||||
}
|
||||
};
|
||||
@@ -804,7 +807,7 @@ class AccountInternal extends PureComponent<
|
||||
accountName: account.name,
|
||||
isViewBankSyncSettings: false,
|
||||
onUnlink: () => {
|
||||
this.props.dispatch(unlinkAccount({ id: accountId }));
|
||||
this.props.onUnlinkAccount(accountId);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -815,7 +818,7 @@ class AccountInternal extends PureComponent<
|
||||
this.props.dispatch(openAccountCloseModal({ accountId }));
|
||||
break;
|
||||
case 'reopen':
|
||||
this.props.dispatch(reopenAccount({ id: accountId }));
|
||||
this.props.onReopenAccount(accountId);
|
||||
break;
|
||||
case 'export':
|
||||
const accountName = this.getAccountTitle(account, accountId);
|
||||
@@ -1022,11 +1025,7 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
const lastReconciled = new Date().getTime().toString();
|
||||
this.props.dispatch(
|
||||
updateAccount({
|
||||
account: { ...account, last_reconciled: lastReconciled },
|
||||
}),
|
||||
);
|
||||
this.props.onUpdateAccount({ ...account, last_reconciled: lastReconciled });
|
||||
|
||||
this.setState({
|
||||
reconcileAmount: null,
|
||||
@@ -1996,6 +1995,22 @@ export function Account() {
|
||||
[params.id],
|
||||
);
|
||||
|
||||
const reopenAccount = useReopenAccountMutation();
|
||||
const onReopenAccount = (id: AccountEntity['id']) =>
|
||||
reopenAccount.mutate({ id });
|
||||
|
||||
const updateAccount = useUpdateAccountMutation();
|
||||
const onUpdateAccount = (account: AccountEntity) =>
|
||||
updateAccount.mutate({ account });
|
||||
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const onUnlinkAccount = (id: AccountEntity['id']) =>
|
||||
unlinkAccount.mutate({ id });
|
||||
|
||||
const syncAndDownload = useSyncAndDownloadMutation();
|
||||
const onSyncAndDownload = (id?: AccountEntity['id']) =>
|
||||
syncAndDownload.mutate({ id });
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<SplitsExpandedProvider
|
||||
@@ -2032,6 +2047,10 @@ export function Account() {
|
||||
categoryId={location?.state?.categoryId}
|
||||
location={location}
|
||||
savedFilters={savedFiters}
|
||||
onReopenAccount={onReopenAccount}
|
||||
onUpdateAccount={onUpdateAccount}
|
||||
onUnlinkAccount={onUnlinkAccount}
|
||||
onSyncAndDownload={onSyncAndDownload}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view';
|
||||
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { unlinkAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useUnlinkAccountMutation } from '@desktop-client/accounts';
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
import { authorizeBank } from '@desktop-client/gocardless';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
@@ -105,15 +105,16 @@ export function AccountSyncCheck() {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const unlink = useCallback(
|
||||
(acc: AccountEntity) => {
|
||||
if (acc.id) {
|
||||
dispatch(unlinkAccount({ id: acc.id }));
|
||||
unlinkAccount.mutate({ id: acc.id });
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
[dispatch],
|
||||
[unlinkAccount],
|
||||
);
|
||||
|
||||
if (!failedAccounts || !id) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BankSyncCheckboxOptions } from './BankSyncCheckboxOptions';
|
||||
import { FieldMapping } from './FieldMapping';
|
||||
import { useBankSyncAccountSettings } from './useBankSyncAccountSettings';
|
||||
|
||||
import { unlinkAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useUnlinkAccountMutation } from '@desktop-client/accounts';
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
@@ -161,6 +161,7 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
close();
|
||||
};
|
||||
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const onUnlink = async (close: () => void) => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
@@ -170,8 +171,12 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
accountName: account.name,
|
||||
isViewBankSyncSettings: true,
|
||||
onUnlink: () => {
|
||||
dispatch(unlinkAccount({ id: account.id }));
|
||||
close();
|
||||
unlinkAccount.mutate(
|
||||
{ id: account.id },
|
||||
{
|
||||
onSuccess: close,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,9 +18,9 @@ import { OffBudgetAccountTransactions } from './OffBudgetAccountTransactions';
|
||||
import { OnBudgetAccountTransactions } from './OnBudgetAccountTransactions';
|
||||
|
||||
import {
|
||||
reopenAccount,
|
||||
updateAccount,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
useReopenAccountMutation,
|
||||
useUpdateAccountMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import { AddTransactionButton } from '@desktop-client/components/mobile/transactions/AddTransactionButton';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
@@ -109,12 +109,13 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const updateAccount = useUpdateAccountMutation();
|
||||
|
||||
const onSave = useCallback(
|
||||
(account: AccountEntity) => {
|
||||
dispatch(updateAccount({ account }));
|
||||
updateAccount.mutate({ account });
|
||||
},
|
||||
[dispatch],
|
||||
[updateAccount],
|
||||
);
|
||||
|
||||
const onSaveNotes = useCallback(async (id: string, notes: string) => {
|
||||
@@ -143,9 +144,11 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
|
||||
dispatch(openAccountCloseModal({ accountId: account.id }));
|
||||
}, [account.id, dispatch]);
|
||||
|
||||
const reopenAccount = useReopenAccountMutation();
|
||||
|
||||
const onReopenAccount = useCallback(() => {
|
||||
dispatch(reopenAccount({ id: account.id }));
|
||||
}, [account.id, dispatch]);
|
||||
reopenAccount.mutate({ id: account.id });
|
||||
}, [account.id, reopenAccount]);
|
||||
|
||||
const [showRunningBalances, setShowRunningBalances] = useSyncedPref(
|
||||
`show-balances-${account.id}`,
|
||||
|
||||
@@ -7,8 +7,8 @@ import { isPreviewId } from 'loot-core/shared/transactions';
|
||||
import type { IntegerAmount } from 'loot-core/shared/util';
|
||||
import type { AccountEntity, TransactionEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useSyncAndDownloadMutation } from '@desktop-client/accounts';
|
||||
import { markAccountRead } from '@desktop-client/accounts/accountsSlice';
|
||||
import { syncAndDownload } from '@desktop-client/app/appSlice';
|
||||
import { TransactionListWithBalances } from '@desktop-client/components/mobile/transactions/TransactionListWithBalances';
|
||||
import { useAccountPreviewTransactions } from '@desktop-client/hooks/useAccountPreviewTransactions';
|
||||
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
@@ -108,11 +108,12 @@ function TransactionListWithPreviews({
|
||||
accountId: account?.id,
|
||||
});
|
||||
|
||||
const syncAndDownload = useSyncAndDownloadMutation();
|
||||
const onRefresh = useCallback(() => {
|
||||
if (account.id) {
|
||||
dispatch(syncAndDownload({ accountId: account.id }));
|
||||
syncAndDownload.mutate({ id: account.id });
|
||||
}
|
||||
}, [account.id, dispatch]);
|
||||
}, [account.id, syncAndDownload]);
|
||||
|
||||
const allBalances = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -24,8 +24,10 @@ import { css } from '@emotion/css';
|
||||
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { moveAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { syncAndDownload } from '@desktop-client/app/appSlice';
|
||||
import {
|
||||
useMoveAccountMutation,
|
||||
useSyncAndDownloadMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import { makeAmountFullStyle } from '@desktop-client/components/budget/util';
|
||||
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
|
||||
import { PullToRefresh } from '@desktop-client/components/mobile/PullToRefresh';
|
||||
@@ -182,26 +184,23 @@ function AccountListItem({
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
>
|
||||
{
|
||||
/* TODO: Should bankId be part of the AccountEntity type? */
|
||||
'bankId' in account && account.bankId ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isPending
|
||||
? theme.sidebarItemBackgroundPending
|
||||
: isFailed
|
||||
? theme.sidebarItemBackgroundFailed
|
||||
: theme.sidebarItemBackgroundPositive,
|
||||
marginRight: '8px',
|
||||
width: 8,
|
||||
flexShrink: 0,
|
||||
height: 8,
|
||||
borderRadius: 8,
|
||||
opacity: isConnected ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
{account.bankId ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: isPending
|
||||
? theme.sidebarItemBackgroundPending
|
||||
: isFailed
|
||||
? theme.sidebarItemBackgroundFailed
|
||||
: theme.sidebarItemBackgroundPositive,
|
||||
marginRight: '8px',
|
||||
width: 8,
|
||||
flexShrink: 0,
|
||||
height: 8,
|
||||
borderRadius: 8,
|
||||
opacity: isConnected ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<TextOneLine
|
||||
style={{
|
||||
...styles.text,
|
||||
@@ -410,7 +409,8 @@ const AccountList = forwardRef<HTMLDivElement, AccountListProps>(
|
||||
state => state.account.accountsSyncing,
|
||||
);
|
||||
const updatedAccounts = useSelector(state => state.account.updatedAccounts);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const moveAccount = useMoveAccountMutation();
|
||||
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: keys =>
|
||||
@@ -441,12 +441,10 @@ const AccountList = forwardRef<HTMLDivElement, AccountListProps>(
|
||||
const targetAccountId = e.target.key as AccountEntity['id'];
|
||||
|
||||
if (e.target.dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveAccount({
|
||||
id: accountIdToMove,
|
||||
targetId: targetAccountId,
|
||||
}),
|
||||
);
|
||||
moveAccount.mutate({
|
||||
id: accountIdToMove,
|
||||
targetId: targetAccountId,
|
||||
});
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
const targetAccountIndex = accounts.findIndex(
|
||||
account => account.id === e.target.key,
|
||||
@@ -459,17 +457,15 @@ const AccountList = forwardRef<HTMLDivElement, AccountListProps>(
|
||||
|
||||
const nextToTargetAccount = accounts[targetAccountIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveAccount({
|
||||
id: accountIdToMove,
|
||||
// Due to the way `moveAccount` works, we use the account next to the
|
||||
// actual target account here because `moveAccount` always shoves the
|
||||
// account *before* the target account.
|
||||
// On the other hand, using `null` as `targetId`moves the account
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetAccount?.id || null,
|
||||
}),
|
||||
);
|
||||
moveAccount.mutate({
|
||||
id: accountIdToMove,
|
||||
// Due to the way `moveAccount` works, we use the account next to the
|
||||
// actual target account here because `moveAccount` always shoves the
|
||||
// account *before* the target account.
|
||||
// On the other hand, using `null` as `targetId`moves the account
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetAccount?.id || null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -528,9 +524,10 @@ export function AccountsPage() {
|
||||
dispatch(replaceModal({ modal: { name: 'add-account', options: {} } }));
|
||||
}, [dispatch]);
|
||||
|
||||
const syncAndDownload = useSyncAndDownloadMutation();
|
||||
const onSync = useCallback(async () => {
|
||||
dispatch(syncAndDownload({}));
|
||||
}, [dispatch]);
|
||||
syncAndDownload.mutate({});
|
||||
}, [syncAndDownload]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { unlinkAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useUnlinkAccountMutation } from '@desktop-client/accounts';
|
||||
import { BankSyncCheckboxOptions } from '@desktop-client/components/banksync/BankSyncCheckboxOptions';
|
||||
import { FieldMapping } from '@desktop-client/components/banksync/FieldMapping';
|
||||
import { useBankSyncAccountSettings } from '@desktop-client/components/banksync/useBankSyncAccountSettings';
|
||||
@@ -54,6 +54,7 @@ export function MobileBankSyncAccountEditPage() {
|
||||
navigate('/bank-sync');
|
||||
};
|
||||
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const handleUnlink = () => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
@@ -64,8 +65,12 @@ export function MobileBankSyncAccountEditPage() {
|
||||
isViewBankSyncSettings: true,
|
||||
onUnlink: () => {
|
||||
if (accountId) {
|
||||
dispatch(unlinkAccount({ id: accountId }));
|
||||
navigate('/bank-sync');
|
||||
unlinkAccount.mutate(
|
||||
{ id: accountId },
|
||||
{
|
||||
onSuccess: () => navigate('/bank-sync'),
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ import { integerToCurrency } from 'loot-core/shared/util';
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
import type { TransObjectLiteral } from 'loot-core/types/util';
|
||||
|
||||
import { closeAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useCloseAccountMutation } from '@desktop-client/accounts';
|
||||
import { AccountAutocomplete } from '@desktop-client/components/autocomplete/AccountAutocomplete';
|
||||
import { CategoryAutocomplete } from '@desktop-client/components/autocomplete/CategoryAutocomplete';
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
@@ -91,6 +91,8 @@ export function CloseAccountModal({
|
||||
}
|
||||
: {};
|
||||
|
||||
const closeAccount = useCloseAccountMutation();
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -107,13 +109,12 @@ export function CloseAccountModal({
|
||||
|
||||
setLoading(true);
|
||||
|
||||
dispatch(
|
||||
closeAccount({
|
||||
id: account.id,
|
||||
transferAccountId: transferAccountId || null,
|
||||
categoryId: categoryId || null,
|
||||
}),
|
||||
);
|
||||
closeAccount.mutate({
|
||||
id: account.id,
|
||||
transferAccountId: transferAccountId || null,
|
||||
categoryId: categoryId || null,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -275,12 +276,10 @@ export function CloseAccountModal({
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
dispatch(
|
||||
closeAccount({
|
||||
id: account.id,
|
||||
forced: true,
|
||||
}),
|
||||
);
|
||||
closeAccount.mutate({
|
||||
id: account.id,
|
||||
forced: true,
|
||||
});
|
||||
close();
|
||||
}}
|
||||
style={{ color: theme.errorText }}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { View } from '@actual-app/components/view';
|
||||
|
||||
import { toRelaxedNumber } from 'loot-core/shared/util';
|
||||
|
||||
import { createAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useCreateAccountMutation } from '@desktop-client/accounts';
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
import {
|
||||
Modal,
|
||||
@@ -55,6 +55,8 @@ export function CreateLocalAccountModal() {
|
||||
}
|
||||
};
|
||||
|
||||
const createAccount = useCreateAccountMutation();
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -65,14 +67,18 @@ export function CreateLocalAccountModal() {
|
||||
|
||||
if (!nameError && !balanceError) {
|
||||
dispatch(closeModal());
|
||||
const id = await dispatch(
|
||||
createAccount({
|
||||
createAccount.mutate(
|
||||
{
|
||||
name,
|
||||
balance: toRelaxedNumber(balance),
|
||||
offBudget: offbudget,
|
||||
}),
|
||||
).unwrap();
|
||||
navigate('/accounts/' + id);
|
||||
},
|
||||
{
|
||||
onSuccess: id => {
|
||||
navigate('/accounts/' + id);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
import type { DateFormat, FieldMapping, ImportTransaction } from './utils';
|
||||
|
||||
import {
|
||||
importPreviewTransactions,
|
||||
importTransactions,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
useImportPreviewTransactionsMutation,
|
||||
useImportTransactionsMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
@@ -307,59 +307,9 @@ export function ImportTransactionsModal({
|
||||
});
|
||||
}
|
||||
|
||||
// Retreive the transactions that would be updated (along with the existing trx)
|
||||
const previewTrx = await dispatch(
|
||||
importPreviewTransactions({
|
||||
accountId,
|
||||
transactions: previewTransactions,
|
||||
}),
|
||||
).unwrap();
|
||||
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
|
||||
// @ts-expect-error - entry.transaction might not have trx_id property
|
||||
map[entry.transaction.trx_id] = entry;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return transactions
|
||||
.filter(trans => !trans.isMatchedTransaction)
|
||||
.reduce((previous, current_trx) => {
|
||||
let next = previous;
|
||||
const entry = matchedUpdateMap[current_trx.trx_id];
|
||||
const existing_trx = entry?.existing;
|
||||
|
||||
// if the transaction is matched with an existing one for update
|
||||
current_trx.existing = !!existing_trx;
|
||||
// if the transaction is an update that will be ignored
|
||||
// (reconciled transactions or no change detected)
|
||||
current_trx.ignored = entry?.ignored || false;
|
||||
|
||||
current_trx.tombstone = entry?.tombstone || false;
|
||||
|
||||
current_trx.selected = !current_trx.ignored;
|
||||
current_trx.selected_merge = current_trx.existing;
|
||||
|
||||
next = next.concat({ ...current_trx });
|
||||
|
||||
if (existing_trx) {
|
||||
// add the updated existing transaction in the list, with the
|
||||
// isMatchedTransaction flag to identify it in display and not send it again
|
||||
existing_trx.isMatchedTransaction = true;
|
||||
existing_trx.category = categories.find(
|
||||
cat => cat.id === existing_trx.category,
|
||||
)?.name;
|
||||
// add parent transaction attribute to mimic behaviour
|
||||
existing_trx.trx_id = current_trx.trx_id;
|
||||
existing_trx.existing = current_trx.existing;
|
||||
existing_trx.selected = current_trx.selected;
|
||||
existing_trx.selected_merge = current_trx.selected_merge;
|
||||
|
||||
next = next.concat({ ...existing_trx });
|
||||
}
|
||||
|
||||
return next;
|
||||
}, []);
|
||||
return previewTransactions;
|
||||
},
|
||||
[accountId, categories, clearOnImport, dispatch],
|
||||
[categories, clearOnImport],
|
||||
);
|
||||
|
||||
const parse = useCallback(
|
||||
@@ -574,6 +524,8 @@ export function ImportTransactionsModal({
|
||||
setTransactions(newTransactions);
|
||||
}
|
||||
|
||||
const importTransactions = useImportTransactionsMutation();
|
||||
|
||||
async function onImport(close) {
|
||||
setLoadingState('importing');
|
||||
|
||||
@@ -695,26 +647,33 @@ export function ImportTransactionsModal({
|
||||
});
|
||||
}
|
||||
|
||||
const didChange = await dispatch(
|
||||
importTransactions({
|
||||
await importTransactions.mutate(
|
||||
{
|
||||
accountId,
|
||||
transactions: finalTransactions,
|
||||
reconcile,
|
||||
}),
|
||||
).unwrap();
|
||||
if (didChange) {
|
||||
await dispatch(reloadPayees());
|
||||
}
|
||||
},
|
||||
{
|
||||
onSuccess: async didChange => {
|
||||
if (didChange) {
|
||||
await dispatch(reloadPayees());
|
||||
}
|
||||
|
||||
if (onImported) {
|
||||
onImported(didChange);
|
||||
}
|
||||
close();
|
||||
if (onImported) {
|
||||
onImported(didChange);
|
||||
}
|
||||
|
||||
close();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const importPreviewTransactions = useImportPreviewTransactionsMutation();
|
||||
|
||||
const onImportPreview = useEffectEvent(async () => {
|
||||
// always start from the original parsed transactions, not the previewed ones to ensure rules run
|
||||
const transactionPreview = await getImportPreview(
|
||||
const previewTransactionsToImport = await getImportPreview(
|
||||
parsedTransactions,
|
||||
filetype,
|
||||
flipAmount,
|
||||
@@ -725,7 +684,64 @@ export function ImportTransactionsModal({
|
||||
outValue,
|
||||
multiplierAmount,
|
||||
);
|
||||
setTransactions(transactionPreview);
|
||||
|
||||
// Retreive the transactions that would be updated (along with the existing trx)
|
||||
await importPreviewTransactions.mutate(
|
||||
{
|
||||
accountId,
|
||||
transactions: previewTransactionsToImport,
|
||||
},
|
||||
{
|
||||
onSuccess: previewTrx => {
|
||||
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
|
||||
// @ts-expect-error - entry.transaction might not have trx_id property
|
||||
map[entry.transaction.trx_id] = entry;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
const previewTransactions = parsedTransactions
|
||||
.filter(trans => !trans.isMatchedTransaction)
|
||||
.reduce((previous, currentTrx) => {
|
||||
let next = previous;
|
||||
const entry = matchedUpdateMap[currentTrx.trx_id];
|
||||
const existingTrx = entry?.existing;
|
||||
|
||||
// if the transaction is matched with an existing one for update
|
||||
currentTrx.existing = !!existingTrx;
|
||||
// if the transaction is an update that will be ignored
|
||||
// (reconciled transactions or no change detected)
|
||||
currentTrx.ignored = entry?.ignored || false;
|
||||
|
||||
currentTrx.tombstone = entry?.tombstone || false;
|
||||
|
||||
currentTrx.selected = !currentTrx.ignored;
|
||||
currentTrx.selected_merge = currentTrx.existing;
|
||||
|
||||
next = next.concat({ ...currentTrx });
|
||||
|
||||
if (existingTrx) {
|
||||
// add the updated existing transaction in the list, with the
|
||||
// isMatchedTransaction flag to identify it in display and not send it again
|
||||
existingTrx.isMatchedTransaction = true;
|
||||
existingTrx.category = categories.find(
|
||||
cat => cat.id === existingTrx.category,
|
||||
)?.name;
|
||||
// add parent transaction attribute to mimic behaviour
|
||||
existingTrx.trx_id = currentTrx.trx_id;
|
||||
existingTrx.existing = currentTrx.existing;
|
||||
existingTrx.selected = currentTrx.selected;
|
||||
existingTrx.selected_merge = currentTrx.selected_merge;
|
||||
|
||||
next = next.concat({ ...existingTrx });
|
||||
}
|
||||
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
setTransactions(previewTransactions);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -21,13 +21,15 @@ import type {
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
linkAccount,
|
||||
linkAccountPluggyAi,
|
||||
linkAccountSimpleFin,
|
||||
unlinkAccount,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete';
|
||||
import type { AutocompleteItem } from '@desktop-client/components/autocomplete/Autocomplete';
|
||||
useLinkAccountMutation,
|
||||
useLinkAccountPluggyAiMutation,
|
||||
useLinkAccountSimpleFinMutation,
|
||||
useUnlinkAccountMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import {
|
||||
Autocomplete,
|
||||
type AutocompleteItem,
|
||||
} from '@desktop-client/components/autocomplete/Autocomplete';
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
@@ -154,6 +156,11 @@ export function SelectLinkedAccountsModal({
|
||||
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
|
||||
useAddBudgetAccountOptions();
|
||||
|
||||
const linkAccount = useLinkAccountMutation();
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const linkAccountSimpleFin = useLinkAccountSimpleFinMutation();
|
||||
const linkAccountPluggyAi = useLinkAccountPluggyAiMutation();
|
||||
|
||||
async function onNext() {
|
||||
const chosenLocalAccountIds = Object.values(chosenAccounts);
|
||||
|
||||
@@ -162,7 +169,7 @@ export function SelectLinkedAccountsModal({
|
||||
localAccounts
|
||||
.filter(acc => acc.account_id)
|
||||
.filter(acc => !chosenLocalAccountIds.includes(acc.id))
|
||||
.forEach(acc => dispatch(unlinkAccount({ id: acc.id })));
|
||||
.forEach(acc => unlinkAccount.mutate({ id: acc.id }));
|
||||
|
||||
// Link new accounts
|
||||
Object.entries(chosenAccounts).forEach(
|
||||
@@ -189,57 +196,51 @@ export function SelectLinkedAccountsModal({
|
||||
customSettings?.amount != null ? customSettings.amount : undefined;
|
||||
|
||||
if (propsWithSortedExternalAccounts.syncSource === 'simpleFin') {
|
||||
dispatch(
|
||||
linkAccountSimpleFin({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}),
|
||||
);
|
||||
linkAccountSimpleFin.mutate({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
} else if (propsWithSortedExternalAccounts.syncSource === 'pluggyai') {
|
||||
dispatch(
|
||||
linkAccountPluggyAi({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}),
|
||||
);
|
||||
linkAccountPluggyAi.mutate({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
} else {
|
||||
dispatch(
|
||||
linkAccount({
|
||||
requisitionId: propsWithSortedExternalAccounts.requisitionId,
|
||||
account:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}),
|
||||
);
|
||||
linkAccount.mutate({
|
||||
requisitionId: propsWithSortedExternalAccounts.requisitionId,
|
||||
account:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,14 @@ import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { Change } from './Change';
|
||||
|
||||
import { store } from '@desktop-client/redux/store';
|
||||
import { configureAppStore } from '@desktop-client/redux/store';
|
||||
|
||||
const store = configureAppStore({ queryClient: new QueryClient() });
|
||||
|
||||
describe('Change', () => {
|
||||
it('renders a positive amount with a plus sign and positive color', () => {
|
||||
|
||||
@@ -24,9 +24,9 @@ import { css, cx } from '@emotion/css';
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
reopenAccount,
|
||||
updateAccount,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
useReopenAccountMutation,
|
||||
useUpdateAccountMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import { BalanceHistoryGraph } from '@desktop-client/components/accounts/BalanceHistoryGraph';
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
import { Notes } from '@desktop-client/components/Notes';
|
||||
@@ -136,6 +136,8 @@ export function Account<FieldName extends SheetFields<'account'>>({
|
||||
window.matchMedia('(hover: none)').matches ||
|
||||
window.matchMedia('(pointer: coarse)').matches;
|
||||
const needsTooltip = !!account?.id && !isTouchDevice;
|
||||
const reopenAccount = useReopenAccountMutation();
|
||||
const updateAccount = useUpdateAccountMutation();
|
||||
|
||||
const accountRow = (
|
||||
<View
|
||||
@@ -222,14 +224,12 @@ export function Account<FieldName extends SheetFields<'account'>>({
|
||||
onBlur={() => setIsEditing(false)}
|
||||
onEnter={newAccountName => {
|
||||
if (newAccountName.trim() !== '') {
|
||||
dispatch(
|
||||
updateAccount({
|
||||
account: {
|
||||
...account,
|
||||
name: newAccountName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
updateAccount.mutate({
|
||||
account: {
|
||||
...account,
|
||||
name: newAccountName,
|
||||
},
|
||||
});
|
||||
}
|
||||
setIsEditing(false);
|
||||
}}
|
||||
@@ -264,7 +264,7 @@ export function Account<FieldName extends SheetFields<'account'>>({
|
||||
break;
|
||||
}
|
||||
case 'reopen': {
|
||||
dispatch(reopenAccount({ id: account.id }));
|
||||
reopenAccount.mutate({ id: account.id });
|
||||
break;
|
||||
}
|
||||
case 'rename': {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { AccountEntity } from 'loot-core/types/models';
|
||||
import { Account } from './Account';
|
||||
import { SecondaryItem } from './SecondaryItem';
|
||||
|
||||
import { moveAccount } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useMoveAccountMutation } from '@desktop-client/accounts';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useClosedAccounts } from '@desktop-client/hooks/useClosedAccounts';
|
||||
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
|
||||
@@ -17,14 +17,13 @@ import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { useOffBudgetAccounts } from '@desktop-client/hooks/useOffBudgetAccounts';
|
||||
import { useOnBudgetAccounts } from '@desktop-client/hooks/useOnBudgetAccounts';
|
||||
import { useUpdatedAccounts } from '@desktop-client/hooks/useUpdatedAccounts';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { useSelector } from '@desktop-client/redux';
|
||||
import * as bindings from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
const fontWeight = 600;
|
||||
|
||||
export function Accounts() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const accounts = useAccounts();
|
||||
const failedAccounts = useFailedAccounts();
|
||||
@@ -44,6 +43,8 @@ export function Accounts() {
|
||||
setIsDragging(drag.state === 'start');
|
||||
}
|
||||
|
||||
const moveAccount = useMoveAccountMutation();
|
||||
|
||||
const makeDropPadding = (i: number) => {
|
||||
if (i === 0) {
|
||||
return {
|
||||
@@ -65,7 +66,7 @@ export function Accounts() {
|
||||
targetIdToMove = idx < accounts.length ? accounts[idx].id : null;
|
||||
}
|
||||
|
||||
dispatch(moveAccount({ id, targetId: targetIdToMove as string }));
|
||||
moveAccount.mutate({ id, targetId: targetIdToMove as string });
|
||||
}
|
||||
|
||||
const onToggleClosedAccounts = () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { QueryClient } from '@tanstack/react-query';
|
||||
import { listen } from 'loot-core/platform/client/fetch';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
|
||||
import { reloadAccounts } from './accounts/accountsSlice';
|
||||
import { accountQueries } from './accounts';
|
||||
import { setAppState } from './app/appSlice';
|
||||
import { categoryQueries } from './budget';
|
||||
import { closeBudgetUI } from './budgetfiles/budgetfilesSlice';
|
||||
@@ -74,7 +74,11 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
if (tables.includes('accounts')) {
|
||||
promises.push(store.dispatch(reloadAccounts()));
|
||||
promises.push(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const tagged = undo.getTaggedState(undoTag);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
|
||||
export function useAccount(id: string) {
|
||||
const accounts = useAccounts();
|
||||
return useMemo(() => accounts.find(a => a.id === id), [id, accounts]);
|
||||
const query = useQuery({
|
||||
...accountQueries.list(),
|
||||
select: data => data.find(c => c.id === id),
|
||||
});
|
||||
return query.data;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useInitialMount } from './useInitialMount';
|
||||
|
||||
import { getAccounts } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { useAccountsQuery } from './useAccountsQuery';
|
||||
|
||||
export function useAccounts() {
|
||||
const dispatch = useDispatch();
|
||||
const isInitialMount = useInitialMount();
|
||||
const isAccountsDirty = useSelector(state => state.account.isAccountsDirty);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialMount || isAccountsDirty) {
|
||||
dispatch(getAccounts());
|
||||
}
|
||||
}, [dispatch, isInitialMount, isAccountsDirty]);
|
||||
|
||||
return useSelector(state => state.account.accounts);
|
||||
const query = useAccountsQuery();
|
||||
// TODO: Update to return query states (e.g. isFetching, isError, etc)
|
||||
// so clients can handle loading and error states appropriately.
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
7
packages/desktop-client/src/hooks/useAccountsQuery.ts
Normal file
7
packages/desktop-client/src/hooks/useAccountsQuery.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
|
||||
export function useAccountsQuery() {
|
||||
return useQuery(accountQueries.list());
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
|
||||
export function useOffBudgetAccounts() {
|
||||
const accounts = useAccounts();
|
||||
return useMemo(
|
||||
() =>
|
||||
accounts.filter(
|
||||
account => account.closed === 0 && account.offbudget === 1,
|
||||
),
|
||||
[accounts],
|
||||
);
|
||||
const query = useQuery(accountQueries.listOffBudget());
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
|
||||
export function useOnBudgetAccounts() {
|
||||
const accounts = useAccounts();
|
||||
return useMemo(
|
||||
() =>
|
||||
accounts.filter(
|
||||
account => account.closed === 0 && account.offbudget === 0,
|
||||
),
|
||||
[accounts],
|
||||
);
|
||||
const query = useQuery(accountQueries.listOnBudget());
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
@@ -26,12 +26,17 @@ import * as notificationsSlice from './notifications/notificationsSlice';
|
||||
import * as payeesSlice from './payees/payeesSlice';
|
||||
import * as prefsSlice from './prefs/prefsSlice';
|
||||
import { aqlQuery } from './queries/aqlQuery';
|
||||
import { store } from './redux/store';
|
||||
import { configureAppStore } from './redux/store';
|
||||
import * as tagsSlice from './tags/tagsSlice';
|
||||
import * as transactionsSlice from './transactions/transactionsSlice';
|
||||
import { redo, undo } from './undo';
|
||||
import * as usersSlice from './users/usersSlice';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
|
||||
|
||||
const store = configureAppStore({ queryClient });
|
||||
|
||||
const boundActions = bindActionCreators(
|
||||
{
|
||||
...accountsSlice.actions,
|
||||
@@ -82,21 +87,18 @@ window.$send = send;
|
||||
window.$query = aqlQuery;
|
||||
window.$q = q;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<ServerProvider>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<ServerProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</ServerProvider>
|
||||
</Provider>,
|
||||
</AuthProvider>
|
||||
</ServerProvider>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
} from 'loot-core/types/models';
|
||||
import type { Template } from 'loot-core/types/models/templates';
|
||||
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
import { resetApp, setAppState } from '@desktop-client/app/appSlice';
|
||||
import type { SelectLinkedAccountsModalProps } from '@desktop-client/components/modals/SelectLinkedAccountsModal';
|
||||
import { createAppAsyncThunk } from '@desktop-client/redux';
|
||||
@@ -591,10 +592,7 @@ type OpenAccountCloseModalPayload = {
|
||||
|
||||
export const openAccountCloseModal = createAppAsyncThunk(
|
||||
`${sliceName}/openAccountCloseModal`,
|
||||
async (
|
||||
{ accountId }: OpenAccountCloseModalPayload,
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
async ({ accountId }: OpenAccountCloseModalPayload, { dispatch, extra }) => {
|
||||
const {
|
||||
balance,
|
||||
numTransactions,
|
||||
@@ -604,9 +602,10 @@ export const openAccountCloseModal = createAppAsyncThunk(
|
||||
id: accountId,
|
||||
},
|
||||
);
|
||||
const account = getState().account.accounts.find(
|
||||
acct => acct.id === accountId,
|
||||
);
|
||||
const queryClient = extra.queryClient;
|
||||
const accounts =
|
||||
queryClient.getQueryData(accountQueries.list().queryKey) ?? [];
|
||||
const account = accounts.find(acct => acct.id === accountId);
|
||||
|
||||
if (!account) {
|
||||
throw new Error(`Account with ID ${accountId} does not exist.`);
|
||||
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import type { AppDispatch, AppStore, RootState } from './store';
|
||||
import {
|
||||
type ExtraArguments,
|
||||
type AppDispatch,
|
||||
type AppStore,
|
||||
type RootState,
|
||||
} from './store';
|
||||
|
||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState;
|
||||
dispatch: AppDispatch;
|
||||
extra: ExtraArguments;
|
||||
}>();
|
||||
|
||||
export const useStore = useReduxStore.withTypes<AppStore>();
|
||||
|
||||
@@ -2,72 +2,19 @@ import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import type { store as realStore } from './store';
|
||||
import { configureAppStore, type AppStore } from './store';
|
||||
|
||||
import {
|
||||
name as accountsSliceName,
|
||||
reducer as accountsSliceReducer,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
import {
|
||||
name as appSliceName,
|
||||
reducer as appSliceReducer,
|
||||
} from '@desktop-client/app/appSlice';
|
||||
import {
|
||||
name as budgetfilesSliceName,
|
||||
reducer as budgetfilesSliceReducer,
|
||||
} from '@desktop-client/budgetfiles/budgetfilesSlice';
|
||||
import {
|
||||
name as modalsSliceName,
|
||||
reducer as modalsSliceReducer,
|
||||
} from '@desktop-client/modals/modalsSlice';
|
||||
import {
|
||||
name as notificationsSliceName,
|
||||
reducer as notificationsSliceReducer,
|
||||
} from '@desktop-client/notifications/notificationsSlice';
|
||||
import {
|
||||
name as payeesSliceName,
|
||||
reducer as payeesSliceReducer,
|
||||
} from '@desktop-client/payees/payeesSlice';
|
||||
import {
|
||||
name as prefsSliceName,
|
||||
reducer as prefsSliceReducer,
|
||||
} from '@desktop-client/prefs/prefsSlice';
|
||||
import {
|
||||
name as tagsSliceName,
|
||||
reducer as tagsSliceReducer,
|
||||
} from '@desktop-client/tags/tagsSlice';
|
||||
import {
|
||||
name as transactionsSliceName,
|
||||
reducer as transactionsSliceReducer,
|
||||
} from '@desktop-client/transactions/transactionsSlice';
|
||||
import {
|
||||
name as usersSliceName,
|
||||
reducer as usersSliceReducer,
|
||||
} from '@desktop-client/users/usersSlice';
|
||||
let mockQueryClient = new QueryClient();
|
||||
|
||||
const appReducer = combineReducers({
|
||||
[accountsSliceName]: accountsSliceReducer,
|
||||
[appSliceName]: appSliceReducer,
|
||||
[budgetfilesSliceName]: budgetfilesSliceReducer,
|
||||
[modalsSliceName]: modalsSliceReducer,
|
||||
[notificationsSliceName]: notificationsSliceReducer,
|
||||
[payeesSliceName]: payeesSliceReducer,
|
||||
[prefsSliceName]: prefsSliceReducer,
|
||||
[transactionsSliceName]: transactionsSliceReducer,
|
||||
[tagsSliceName]: tagsSliceReducer,
|
||||
[usersSliceName]: usersSliceReducer,
|
||||
});
|
||||
|
||||
export let mockStore: typeof realStore = configureStore({
|
||||
reducer: appReducer,
|
||||
export let mockStore: AppStore = configureAppStore({
|
||||
queryClient: mockQueryClient,
|
||||
});
|
||||
|
||||
export function resetMockStore() {
|
||||
mockStore = configureStore({
|
||||
reducer: appReducer,
|
||||
});
|
||||
mockQueryClient = new QueryClient();
|
||||
mockStore = configureAppStore({ queryClient: mockQueryClient });
|
||||
}
|
||||
|
||||
export function TestProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createListenerMiddleware,
|
||||
isRejected,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { type QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
name as accountsSliceName,
|
||||
@@ -77,16 +78,28 @@ notifyOnRejectedActionsMiddleware.startListening({
|
||||
},
|
||||
});
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
// TODO: Fix this in a separate PR. Remove non-serializable states in the store.
|
||||
serializableCheck: false,
|
||||
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
|
||||
});
|
||||
export function configureAppStore({
|
||||
queryClient,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
}) {
|
||||
return configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
// TODO: Fix this in a separate PR. Remove non-serializable states in the store.
|
||||
serializableCheck: false,
|
||||
thunk: {
|
||||
extraArgument: { queryClient } as ExtraArguments,
|
||||
},
|
||||
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
|
||||
});
|
||||
}
|
||||
|
||||
export type AppStore = typeof store;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type GetRootState = typeof store.getState;
|
||||
export type AppStore = ReturnType<typeof configureAppStore>;
|
||||
export type RootState = ReturnType<AppStore['getState']>;
|
||||
export type AppDispatch = AppStore['dispatch'];
|
||||
export type GetRootState = AppStore['getState'];
|
||||
export type ExtraArguments = {
|
||||
queryClient: QueryClient;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from 'i18next';
|
||||
|
||||
import { listen, send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
import { reloadAccounts } from './accounts/accountsSlice';
|
||||
import { accountQueries } from './accounts';
|
||||
import { resetSync, sync } from './app/appSlice';
|
||||
import { categoryQueries } from './budget';
|
||||
import {
|
||||
@@ -86,7 +86,9 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
if (tables.includes('accounts')) {
|
||||
store.dispatch(reloadAccounts());
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
let notif: Notification | null = null;
|
||||
|
||||
@@ -31,7 +31,7 @@ export function generateAccount(
|
||||
return {
|
||||
...offlineAccount,
|
||||
balance_current: Math.floor(random() * 100000),
|
||||
bankId: Math.floor(random() * 10000),
|
||||
bankId: Math.floor(random() * 10000).toString(),
|
||||
bankName: 'boa',
|
||||
bank: Math.floor(random() * 10000).toString(),
|
||||
account_id: 'idx',
|
||||
|
||||
@@ -89,8 +89,31 @@ async function updateAccount({
|
||||
return {};
|
||||
}
|
||||
|
||||
async function getAccounts() {
|
||||
return db.getAccounts();
|
||||
async function getAccounts(): Promise<AccountEntity[]> {
|
||||
const dbAccounts = await db.getAccounts();
|
||||
return dbAccounts.map(
|
||||
dbAccount =>
|
||||
({
|
||||
id: dbAccount.id,
|
||||
name: dbAccount.name,
|
||||
offbudget: dbAccount.offbudget,
|
||||
closed: dbAccount.closed,
|
||||
sort_order: dbAccount.sort_order,
|
||||
last_reconciled: dbAccount.last_reconciled ?? null,
|
||||
tombstone: dbAccount.tombstone,
|
||||
account_id: dbAccount.account_id ?? null,
|
||||
bank: dbAccount.bank ?? null,
|
||||
bankName: dbAccount.bankName ?? null,
|
||||
bankId: dbAccount.bankId ?? null,
|
||||
mask: dbAccount.mask ?? null,
|
||||
official_name: dbAccount.official_name ?? null,
|
||||
balance_current: dbAccount.balance_current ?? null,
|
||||
balance_available: dbAccount.balance_available ?? null,
|
||||
balance_limit: dbAccount.balance_limit ?? null,
|
||||
account_sync_source: dbAccount.account_sync_source ?? null,
|
||||
last_sync: dbAccount.last_sync ?? null,
|
||||
}) as AccountEntity,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAccountBalance({
|
||||
|
||||
@@ -570,8 +570,7 @@ handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
|
||||
|
||||
handlers['api/accounts-get'] = async function () {
|
||||
checkFileOpen();
|
||||
// TODO: Force cast to AccountEntity. This should be updated to an AQL query.
|
||||
const accounts = (await db.getAccounts()) as AccountEntity[];
|
||||
const accounts: AccountEntity[] = await handlers['accounts-get']();
|
||||
return accounts.map(account => accountModel.toExternal(account));
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export type DbAccount = {
|
||||
subtype?: string | null;
|
||||
bank?: string | null;
|
||||
account_sync_source?: 'simpleFin' | 'goCardless' | null;
|
||||
last_reconciled?: string | null;
|
||||
last_sync?: string | null;
|
||||
};
|
||||
|
||||
export type DbBank = {
|
||||
|
||||
@@ -12,7 +12,7 @@ export type _SyncFields<T> = {
|
||||
account_id: T extends true ? string : null;
|
||||
bank: T extends true ? string : null;
|
||||
bankName: T extends true ? string : null;
|
||||
bankId: T extends true ? number : null;
|
||||
bankId: T extends true ? string : null;
|
||||
mask: T extends true ? string : null; // end of bank account number
|
||||
official_name: T extends true ? string : null;
|
||||
balance_current: T extends true ? number : null;
|
||||
|
||||
Reference in New Issue
Block a user