mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Move redux state to react-query - account states (#6140)
* Fix typecheck errors * Move redux state to react-query - account states * TestProviders * Add release notes for PR #6140 * Fix lint error * Fix TestProviders * Coderabbit feedback * Cleanup * [autofix.ci] apply automated fixes * Fix TestProviders * Fix onbudget and offbudget displaying closed accounts * [skip ci] Change category to Maintenance and update migration text * Replace logger calls in desktop-client to console * Fix lint errors * Clear react query on closing of budget file similar to redux resetApp action * [autofix.ci] apply automated fixes * Remove sendThrow * Code review feedback * [autofix.ci] apply automated fixes * Fix import * Fix import * Coderabbit feedback --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
5062fa78a8
commit
465608c76b
@@ -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/connection';
|
||||
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';
|
||||
760
packages/desktop-client/src/accounts/mutations.ts
Normal file
760
packages/desktop-client/src/accounts/mutations.ts
Normal file
@@ -0,0 +1,760 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import type { SyncResponseWithErrors } from 'loot-core/server/accounts/app';
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
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, useStore } from '@desktop-client/redux';
|
||||
import type { AppDispatch } from '@desktop-client/redux/store';
|
||||
import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
const invalidateQueries = (queryClient: QueryClient, queryKey?: QueryKey) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKey ?? accountQueries.lists(),
|
||||
});
|
||||
};
|
||||
|
||||
const dispatchErrorNotification = (
|
||||
dispatch: AppDispatch,
|
||||
message: string,
|
||||
error?: Error,
|
||||
) => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
pre: error ? error.message : undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
type CreateAccountPayload = {
|
||||
name: string;
|
||||
balance: number;
|
||||
offBudget: boolean;
|
||||
};
|
||||
|
||||
export function useCreateAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, balance, offBudget }: CreateAccountPayload) => {
|
||||
const id = await send('account-create', {
|
||||
name,
|
||||
balance,
|
||||
offBudget,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error creating account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error creating the account. Please try again.'),
|
||||
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();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
transferAccountId,
|
||||
categoryId,
|
||||
forced,
|
||||
}: CloseAccountPayload) => {
|
||||
await send('account-close', {
|
||||
id,
|
||||
transferAccountId: transferAccountId || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
forced,
|
||||
});
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error closing account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error closing the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ReopenAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
};
|
||||
|
||||
export function useReopenAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: ReopenAccountPayload) => {
|
||||
await send('account-reopen', { id });
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error re-opening account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error re-opening the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateAccountPayload = {
|
||||
account: AccountEntity;
|
||||
};
|
||||
|
||||
export function useUpdateAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ account }: UpdateAccountPayload) => {
|
||||
await send('account-update', account);
|
||||
return account;
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error updating account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error updating the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type MoveAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
targetId: AccountEntity['id'] | null;
|
||||
};
|
||||
|
||||
export function useMoveAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, targetId }: MoveAccountPayload) => {
|
||||
await send('account-move', { id, targetId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateQueries(queryClient);
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error moving account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error moving the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ImportPreviewTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
};
|
||||
|
||||
export function useImportPreviewTransactionsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
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: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error importing preview transactions to account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error importing preview transactions to the account. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ImportTransactionsPayload = {
|
||||
accountId: string;
|
||||
transactions: TransactionEntity[];
|
||||
reconcile: boolean;
|
||||
};
|
||||
|
||||
export function useImportTransactionsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
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: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error importing transactions to account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error importing transactions to the account. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type UnlinkAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
};
|
||||
|
||||
export function useUnlinkAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: UnlinkAccountPayload) => {
|
||||
await send('account-unlink', { id });
|
||||
},
|
||||
onSuccess: (_, { id }) => {
|
||||
invalidateQueries(queryClient);
|
||||
dispatch(markAccountSuccess({ id }));
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error unlinking account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error unlinking the account. Please try again.'),
|
||||
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();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountPayload) => {
|
||||
await send('gocardless-accounts-link', {
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateQueries(queryClient);
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error linking account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error linking the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type LinkAccountSimpleFinPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerSimpleFinAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountSimpleFinMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountSimpleFinPayload) => {
|
||||
await send('simplefin-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateQueries(queryClient);
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error linking account to SimpleFIN:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error linking the account to SimpleFIN. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type LinkAccountPluggyAiPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerPluggyAiAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountPluggyAiMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountPluggyAiPayload) => {
|
||||
await send('pluggyai-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateQueries(queryClient);
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error linking account to PluggyAI:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error linking the account to PluggyAI. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SyncAccountsPayload = {
|
||||
id?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
|
||||
export function useSyncAccountsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const accounts = useAccounts();
|
||||
const store = useStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: SyncAccountsPayload) => {
|
||||
const {
|
||||
account: { accountsSyncing = [] },
|
||||
} = store.getState();
|
||||
|
||||
// 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: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error syncing accounts:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error syncing accounts. Please try again.'),
|
||||
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);
|
||||
|
||||
invalidateQueries(queryClient);
|
||||
|
||||
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 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: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error syncing accounts:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error syncing accounts. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
46
packages/desktop-client/src/accounts/queries.ts
Normal file
46
packages/desktop-client/src/accounts/queries.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
function selectActive(accounts: AccountEntity[]) {
|
||||
return accounts.filter(account => !account.closed);
|
||||
}
|
||||
|
||||
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 accounts change
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
listActive: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: selectActive,
|
||||
}),
|
||||
listClosed: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: accounts => accounts.filter(account => !!account.closed),
|
||||
}),
|
||||
listOnBudget: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: accounts =>
|
||||
selectActive(accounts).filter(account => !account.offbudget),
|
||||
}),
|
||||
listOffBudget: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: accounts =>
|
||||
selectActive(accounts).filter(account => !!account.offbudget),
|
||||
}),
|
||||
};
|
||||
@@ -3,10 +3,8 @@ import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import { getUploadError } from 'loot-core/shared/errors';
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
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,14 +7,13 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { sendCatch } from 'loot-core/platform/client/connection';
|
||||
import type { send } from 'loot-core/platform/client/connection';
|
||||
import { logger } from 'loot-core/platform/server/log';
|
||||
import type { IntegerAmount } from 'loot-core/shared/util';
|
||||
import type {
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { categoryQueries } from '.';
|
||||
import { categoryQueries } from './queries';
|
||||
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
@@ -99,7 +98,7 @@ export function useCreateCategoryMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error creating category:', error);
|
||||
console.error('Error creating category:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error creating the category. Please try again.'),
|
||||
@@ -125,7 +124,7 @@ export function useUpdateCategoryMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error updating category:', error);
|
||||
console.error('Error updating category:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error updating the category. Please try again.'),
|
||||
@@ -149,17 +148,15 @@ export function useSaveCategoryMutation() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ category }: SaveCategoryPayload) => {
|
||||
const { grouped: categoryGroups } = await queryClient.ensureQueryData(
|
||||
categoryQueries.list(),
|
||||
);
|
||||
const { grouped: categoryGroups = [] } =
|
||||
await queryClient.ensureQueryData(categoryQueries.list());
|
||||
|
||||
const group = categoryGroups.find(g => g.id === category.group);
|
||||
const categoriesInGroup = group?.categories ?? [];
|
||||
const exists = categoriesInGroup.some(c =>
|
||||
category.id === 'new'
|
||||
? true
|
||||
: c.id !== category.id &&
|
||||
c.name.toUpperCase() === category.name.toUpperCase(),
|
||||
const exists = categoriesInGroup.some(
|
||||
c =>
|
||||
c.id !== category.id &&
|
||||
c.name.toUpperCase() === category.name.toUpperCase(),
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
@@ -230,7 +227,7 @@ export function useDeleteCategoryMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error deleting category:', error);
|
||||
console.error('Error deleting category:', error);
|
||||
|
||||
if (error) {
|
||||
switch (error.cause) {
|
||||
@@ -274,7 +271,7 @@ export function useMoveCategoryMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error moving category:', error);
|
||||
console.error('Error moving category:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error moving the category. Please try again.'),
|
||||
@@ -299,7 +296,7 @@ export function useReorderCategoryMutation() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, groupId, targetId }: ReoderCategoryPayload) => {
|
||||
const { grouped: categoryGroups, list: categories } =
|
||||
const { grouped: categoryGroups = [], list: categories = [] } =
|
||||
await queryClient.ensureQueryData(categoryQueries.list());
|
||||
|
||||
const moveCandidate = categories.filter(c => c.id === id)[0];
|
||||
@@ -341,7 +338,7 @@ export function useCreateCategoryGroupMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error creating category group:', error);
|
||||
console.error('Error creating category group:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error creating the category group. Please try again.'),
|
||||
@@ -389,7 +386,7 @@ export function useUpdateCategoryGroupMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error updating category group:', error);
|
||||
console.error('Error updating category group:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error updating the category group. Please try again.'),
|
||||
@@ -472,7 +469,7 @@ export function useDeleteCategoryGroupMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error deleting category group:', error);
|
||||
console.error('Error deleting category group:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error deleting the category group. Please try again.'),
|
||||
@@ -499,7 +496,7 @@ export function useMoveCategoryGroupMutation() {
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error moving category group:', error);
|
||||
console.error('Error moving category group:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error moving the category group. Please try again.'),
|
||||
@@ -828,7 +825,7 @@ export function useBudgetActions() {
|
||||
}
|
||||
},
|
||||
onError: error => {
|
||||
logger.error('Error applying budget action:', error);
|
||||
console.error('Error applying budget action:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error applying the budget action. Please try again.'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -41,12 +41,12 @@ 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';
|
||||
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';
|
||||
@@ -245,7 +245,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[];
|
||||
@@ -568,9 +573,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 () => {
|
||||
@@ -756,7 +759,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: '' });
|
||||
}
|
||||
};
|
||||
@@ -805,7 +808,7 @@ class AccountInternal extends PureComponent<
|
||||
accountName: account.name,
|
||||
isViewBankSyncSettings: false,
|
||||
onUnlink: () => {
|
||||
this.props.dispatch(unlinkAccount({ id: accountId }));
|
||||
this.props.onUnlinkAccount(accountId);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -816,7 +819,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);
|
||||
@@ -1023,11 +1026,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,
|
||||
@@ -1998,6 +1997,20 @@ export function Account() {
|
||||
[params.id],
|
||||
);
|
||||
|
||||
const { mutate: reopenAccount } = useReopenAccountMutation();
|
||||
const onReopenAccount = (id: AccountEntity['id']) => reopenAccount({ id });
|
||||
|
||||
const { mutate: updateAccount } = useUpdateAccountMutation();
|
||||
const onUpdateAccount = (account: AccountEntity) =>
|
||||
updateAccount({ account });
|
||||
|
||||
const { mutate: unlinkAccount } = useUnlinkAccountMutation();
|
||||
const onUnlinkAccount = (id: AccountEntity['id']) => unlinkAccount({ id });
|
||||
|
||||
const { mutate: syncAndDownload } = useSyncAndDownloadMutation();
|
||||
const onSyncAndDownload = (id?: AccountEntity['id']) =>
|
||||
syncAndDownload({ id });
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<SplitsExpandedProvider
|
||||
@@ -2034,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 { mutate: updateAccount } = useUpdateAccountMutation();
|
||||
|
||||
const onSave = useCallback(
|
||||
(account: AccountEntity) => {
|
||||
dispatch(updateAccount({ account }));
|
||||
updateAccount({ 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 { mutate: reopenAccount } = useReopenAccountMutation();
|
||||
|
||||
const onReopenAccount = useCallback(() => {
|
||||
dispatch(reopenAccount({ id: account.id }));
|
||||
}, [account.id, dispatch]);
|
||||
reopenAccount({ 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';
|
||||
@@ -96,6 +96,8 @@ export function CloseAccountModal({
|
||||
}
|
||||
: {};
|
||||
|
||||
const closeAccount = useCloseAccountMutation();
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -112,13 +114,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;
|
||||
};
|
||||
|
||||
@@ -280,12 +281,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();
|
||||
|
||||
@@ -64,15 +66,19 @@ export function CreateLocalAccountModal() {
|
||||
setBalanceError(balanceError);
|
||||
|
||||
if (!nameError && !balanceError) {
|
||||
dispatch(closeModal());
|
||||
const id = await dispatch(
|
||||
createAccount({
|
||||
createAccount.mutate(
|
||||
{
|
||||
name,
|
||||
balance: toRelaxedNumber(balance),
|
||||
offBudget: offbudget,
|
||||
}),
|
||||
).unwrap();
|
||||
navigate('/accounts/' + id);
|
||||
},
|
||||
{
|
||||
onSuccess: id => {
|
||||
dispatch(closeModal());
|
||||
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({
|
||||
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)
|
||||
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,11 +21,11 @@ import type {
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
linkAccount,
|
||||
linkAccountPluggyAi,
|
||||
linkAccountSimpleFin,
|
||||
unlinkAccount,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
useLinkAccountMutation,
|
||||
useLinkAccountPluggyAiMutation,
|
||||
useLinkAccountSimpleFinMutation,
|
||||
useUnlinkAccountMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete';
|
||||
import type { AutocompleteItem } from '@desktop-client/components/autocomplete/Autocomplete';
|
||||
import {
|
||||
@@ -154,6 +154,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 +167,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 +194,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,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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/connection';
|
||||
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,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useInitialMount } from './useInitialMount';
|
||||
|
||||
import { getAccounts } from '@desktop-client/accounts/accountsSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
|
||||
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 = useQuery(accountQueries.list());
|
||||
// TODO: Update to return query states (e.g. isFetching, isError, etc)
|
||||
// so clients can handle loading and error states appropriately.
|
||||
return query.data ?? [];
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
|
||||
export function useClosedAccounts() {
|
||||
const accounts = useAccounts();
|
||||
return useMemo(
|
||||
() => accounts.filter(account => account.closed === 1),
|
||||
[accounts],
|
||||
);
|
||||
const query = useQuery(accountQueries.listClosed());
|
||||
// TODO: Update to return query states (e.g. isFetching, isError, etc)
|
||||
// so clients can handle loading and error states appropriately.
|
||||
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 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 ?? [];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -592,10 +593,7 @@ type OpenAccountCloseModalPayload = {
|
||||
|
||||
export const openAccountCloseModal = createAppAsyncThunk(
|
||||
`${sliceName}/openAccountCloseModal`,
|
||||
async (
|
||||
{ accountId }: OpenAccountCloseModalPayload,
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
async ({ accountId }: OpenAccountCloseModalPayload, { dispatch, extra }) => {
|
||||
const {
|
||||
balance,
|
||||
numTransactions,
|
||||
@@ -605,9 +603,9 @@ export const openAccountCloseModal = createAppAsyncThunk(
|
||||
id: accountId,
|
||||
},
|
||||
);
|
||||
const account = getState().account.accounts.find(
|
||||
acct => acct.id === accountId,
|
||||
);
|
||||
const queryClient = extra.queryClient;
|
||||
const accounts = await queryClient.ensureQueryData(accountQueries.list());
|
||||
const account = accounts.find(acct => acct.id === accountId);
|
||||
|
||||
if (!account) {
|
||||
throw new Error(`Account with ID ${accountId} does not exist.`);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from 'i18next';
|
||||
|
||||
import { listen, send } from 'loot-core/platform/client/connection';
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user