mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-11 01:18:59 -05:00
Compare commits
14 Commits
ai/stabili
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da33183c11 | ||
|
|
7f2f22f00b | ||
|
|
4a37e7aefc | ||
|
|
6be6d1066e | ||
|
|
119679cf1b | ||
|
|
51e85d3369 | ||
|
|
ae9b559c84 | ||
|
|
a66071ccbd | ||
|
|
36c97bbb90 | ||
|
|
f081a47d86 | ||
|
|
9d852fdae0 | ||
|
|
e4724e50cf | ||
|
|
49c8ad7224 | ||
|
|
a070b4a6df |
@@ -1,24 +1,10 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import memoizeOne from 'memoize-one';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { type SyncResponseWithErrors } from 'loot-core/server/accounts/app';
|
||||
import { groupById } from 'loot-core/shared/util';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type CategoryEntity,
|
||||
type SyncServerGoCardlessAccount,
|
||||
type SyncServerPluggyAiAccount,
|
||||
type SyncServerSimpleFinAccount,
|
||||
type 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';
|
||||
|
||||
@@ -28,20 +14,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 = {
|
||||
@@ -101,542 +79,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),
|
||||
@@ -645,39 +92,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';
|
||||
775
packages/desktop-client/src/accounts/mutations.ts
Normal file
775
packages/desktop-client/src/accounts/mutations.ts
Normal file
@@ -0,0 +1,775 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { logger } from 'loot-core/platform/server/log';
|
||||
import { type SyncResponseWithErrors } from 'loot-core/server/accounts/app';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type CategoryEntity,
|
||||
type SyncServerGoCardlessAccount,
|
||||
type SyncServerPluggyAiAccount,
|
||||
type SyncServerSimpleFinAccount,
|
||||
type TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
markAccountFailed,
|
||||
markAccountSuccess,
|
||||
markUpdatedAccounts,
|
||||
setAccountsSyncing,
|
||||
} from './accountsSlice';
|
||||
import { accountQueries } from './queries';
|
||||
|
||||
import { sync } from '@desktop-client/app/appSlice';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { markPayeesDirty } from '@desktop-client/payees/payeesSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { type AppDispatch } from '@desktop-client/redux/store';
|
||||
import { setNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
const sendThrow: typeof send = async function (name, args) {
|
||||
const { data, error } = await send(name, args, { catchErrors: true });
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
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 sendThrow('account-create', {
|
||||
name,
|
||||
balance,
|
||||
offBudget,
|
||||
});
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error creating account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error creating the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type CloseAccountPayload = {
|
||||
id: AccountEntity['id'];
|
||||
transferAccountId?: AccountEntity['id'];
|
||||
categoryId?: CategoryEntity['id'];
|
||||
forced?: boolean;
|
||||
};
|
||||
|
||||
export function useCloseAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
transferAccountId,
|
||||
categoryId,
|
||||
forced,
|
||||
}: CloseAccountPayload) => {
|
||||
await sendThrow('account-close', {
|
||||
id,
|
||||
transferAccountId: transferAccountId || undefined,
|
||||
categoryId: categoryId || undefined,
|
||||
forced,
|
||||
});
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error closing account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error closing the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw 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 sendThrow('account-reopen', { id });
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error re-opening account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error re-opening the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateAccountPayload = {
|
||||
account: AccountEntity;
|
||||
};
|
||||
|
||||
export function useUpdateAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ account }: UpdateAccountPayload) => {
|
||||
await sendThrow('account-update', account);
|
||||
return account;
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error updating account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error updating the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw 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 sendThrow('account-move', { id, targetId });
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error moving account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error moving the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw 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 sendThrow(
|
||||
'transactions-import',
|
||||
{
|
||||
accountId,
|
||||
transactions,
|
||||
isPreview: true,
|
||||
},
|
||||
);
|
||||
|
||||
errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return updatedPreview;
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.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,
|
||||
);
|
||||
throw 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 sendThrow('api/transactions-add', {
|
||||
accountId,
|
||||
transactions,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const {
|
||||
errors = [],
|
||||
added,
|
||||
updated,
|
||||
} = await sendThrow('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 => {
|
||||
logger.error('Error importing transactions to account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error importing transactions to the account. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
throw 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 sendThrow('account-unlink', { id });
|
||||
dispatch(markAccountSuccess({ id }));
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error unlinking account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error unlinking the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Shared base type for link account payloads
|
||||
type LinkAccountBasePayload = {
|
||||
upgradingId?: AccountEntity['id'];
|
||||
offBudget?: boolean;
|
||||
startingDate?: string;
|
||||
startingBalance?: number;
|
||||
};
|
||||
|
||||
type LinkAccountPayload = LinkAccountBasePayload & {
|
||||
requisitionId: string;
|
||||
account: SyncServerGoCardlessAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountPayload) => {
|
||||
await sendThrow('gocardless-accounts-link', {
|
||||
requisitionId,
|
||||
account,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error linking account:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error linking the account. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw 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 sendThrow('simplefin-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error linking account to SimpleFIN:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error linking the account to SimpleFIN. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
throw 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 sendThrow('pluggyai-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
|
||||
// TODO: Change to a call to queryClient.invalidateQueries
|
||||
// once payees have been moved to react-query.
|
||||
dispatch(markPayeesDirty());
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
logger.error('Error linking account to PluggyAI:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error linking the account to PluggyAI. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SyncAccountsPayload = {
|
||||
id?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
|
||||
export function useSyncAccountsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const accounts = useAccounts();
|
||||
const accountState = useSelector(state => state.account);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: SyncAccountsPayload) => {
|
||||
const { accountsSyncing } = accountState;
|
||||
|
||||
// Disallow two parallel sync operations
|
||||
if (accountsSyncing.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id === 'uncategorized') {
|
||||
// Sync no accounts
|
||||
dispatch(setAccountsSyncing({ ids: [] }));
|
||||
return false;
|
||||
}
|
||||
|
||||
let accountIdsToSync: string[];
|
||||
if (id === 'offbudget' || id === 'onbudget') {
|
||||
const targetOffbudget = id === 'offbudget' ? 1 : 0;
|
||||
accountIdsToSync = accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone, offbudget }) =>
|
||||
!!bank && !closed && !tombstone && offbudget === targetOffbudget,
|
||||
)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map(({ id }) => id);
|
||||
} else if (id) {
|
||||
accountIdsToSync = [id];
|
||||
} else {
|
||||
// Default: all accounts
|
||||
accountIdsToSync = accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone }) => !!bank && !closed && !tombstone,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.offbudget === b.offbudget
|
||||
? a.sort_order - b.sort_order
|
||||
: a.offbudget - b.offbudget,
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
|
||||
const simpleFinAccounts = accounts.filter(
|
||||
a =>
|
||||
a.account_sync_source === 'simpleFin' &&
|
||||
accountIdsToSync.includes(a.id),
|
||||
);
|
||||
|
||||
let isSyncSuccess = false;
|
||||
const newTransactions: Array<TransactionEntity['id']> = [];
|
||||
const matchedTransactions: Array<TransactionEntity['id']> = [];
|
||||
const updatedAccounts: Array<AccountEntity['id']> = [];
|
||||
|
||||
if (simpleFinAccounts.length > 0) {
|
||||
logger.log('Using SimpleFin batch sync');
|
||||
|
||||
const res = await sendThrow('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 sendThrow('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 => {
|
||||
logger.error('Error syncing accounts:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error syncing accounts. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleSyncResponse(
|
||||
accountId: AccountEntity['id'],
|
||||
res: SyncResponseWithErrors,
|
||||
dispatch: AppDispatch,
|
||||
queryClient: QueryClient,
|
||||
resNewTransactions: Array<TransactionEntity['id']>,
|
||||
resMatchedTransactions: Array<TransactionEntity['id']>,
|
||||
resUpdatedAccounts: Array<AccountEntity['id']>,
|
||||
) {
|
||||
const { errors, newTransactions, matchedTransactions, updatedAccounts } = res;
|
||||
|
||||
// Mark the account as failed or succeeded (depending on sync output)
|
||||
const [error] = errors;
|
||||
if (error) {
|
||||
// We only want to mark the account as having problem if it
|
||||
// was a real syncing error.
|
||||
if ('type' in error && error.type === 'SyncError') {
|
||||
dispatch(
|
||||
markAccountFailed({
|
||||
id: accountId,
|
||||
errorType: error.category,
|
||||
errorCode: error.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
dispatch(markAccountSuccess({ id: accountId }));
|
||||
}
|
||||
|
||||
// Dispatch errors (if any)
|
||||
errors.forEach(error => {
|
||||
if ('type' in error && error.type === 'SyncError') {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error.message,
|
||||
internal: 'internal' in error ? error.internal : undefined,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
resNewTransactions.push(...newTransactions);
|
||||
resMatchedTransactions.push(...matchedTransactions);
|
||||
resUpdatedAccounts.push(...updatedAccounts);
|
||||
|
||||
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 => {
|
||||
logger.error('Error syncing accounts:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error syncing accounts. Please try again.'),
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
42
packages/desktop-client/src/accounts/queries.ts
Normal file
42
packages/desktop-client/src/accounts/queries.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
export const accountQueries = {
|
||||
all: () => ['accounts'],
|
||||
lists: () => [...accountQueries.all(), 'lists'],
|
||||
list: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
queryKey: [...accountQueries.lists()],
|
||||
queryFn: async () => {
|
||||
const accounts: AccountEntity[] = await send('accounts-get');
|
||||
return accounts;
|
||||
},
|
||||
placeholderData: [],
|
||||
// Manually invalidated when accounts change
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
listActive: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: accounts => accounts.filter(account => !account.closed),
|
||||
}),
|
||||
listClosed: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.list(),
|
||||
select: accounts => accounts.filter(account => !!account.closed),
|
||||
}),
|
||||
listOnBudget: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.listActive(),
|
||||
select: accounts =>
|
||||
accounts.filter(account => !account.offbudget && !account.closed),
|
||||
}),
|
||||
listOffBudget: () =>
|
||||
queryOptions<AccountEntity[]>({
|
||||
...accountQueries.listActive(),
|
||||
select: accounts =>
|
||||
accounts.filter(account => !!account.offbudget && !account.closed),
|
||||
}),
|
||||
};
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { getUploadError } from 'loot-core/shared/errors';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
import { type AtLeastOne } from 'loot-core/types/util';
|
||||
|
||||
import { 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';
|
||||
@@ -130,42 +128,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>;
|
||||
@@ -198,7 +160,6 @@ export const actions = {
|
||||
updateApp,
|
||||
resetSync,
|
||||
sync,
|
||||
syncAndDownload,
|
||||
getLatestAppVersion,
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type 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';
|
||||
@@ -152,9 +152,8 @@ 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 ?? [];
|
||||
@@ -302,7 +301,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];
|
||||
|
||||
@@ -11,6 +11,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';
|
||||
|
||||
@@ -32,10 +33,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';
|
||||
@@ -89,8 +90,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(
|
||||
|
||||
@@ -44,12 +44,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';
|
||||
@@ -252,7 +252,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[];
|
||||
@@ -575,9 +580,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 () => {
|
||||
@@ -763,7 +766,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: '' });
|
||||
}
|
||||
};
|
||||
@@ -812,7 +815,7 @@ class AccountInternal extends PureComponent<
|
||||
accountName: account.name,
|
||||
isViewBankSyncSettings: false,
|
||||
onUnlink: () => {
|
||||
this.props.dispatch(unlinkAccount({ id: accountId }));
|
||||
this.props.onUnlinkAccount(accountId);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -823,7 +826,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);
|
||||
@@ -1030,11 +1033,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,
|
||||
@@ -2004,6 +2003,22 @@ export function Account() {
|
||||
[params.id],
|
||||
);
|
||||
|
||||
const reopenAccount = useReopenAccountMutation();
|
||||
const onReopenAccount = (id: AccountEntity['id']) =>
|
||||
reopenAccount.mutate({ id });
|
||||
|
||||
const updateAccount = useUpdateAccountMutation();
|
||||
const onUpdateAccount = (account: AccountEntity) =>
|
||||
updateAccount.mutate({ account });
|
||||
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const onUnlinkAccount = (id: AccountEntity['id']) =>
|
||||
unlinkAccount.mutate({ id });
|
||||
|
||||
const syncAndDownload = useSyncAndDownloadMutation();
|
||||
const onSyncAndDownload = (id?: AccountEntity['id']) =>
|
||||
syncAndDownload.mutate({ id });
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<SplitsExpandedProvider
|
||||
@@ -2040,6 +2055,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) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { type AccountEntity } from 'loot-core/types/models';
|
||||
import { ReconcileMenu, ReconcilingMessage } from './Reconcile';
|
||||
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { TestProvider } from '@desktop-client/redux/mock';
|
||||
import { TestProviders } from '@desktop-client/mocks';
|
||||
|
||||
vi.mock('@desktop-client/hooks/useSheetValue', () => ({
|
||||
useSheetValue: vi.fn(),
|
||||
@@ -40,14 +40,14 @@ describe('ReconcilingMessage math & UI', () => {
|
||||
const onCreateTransaction = vi.fn();
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcilingMessage
|
||||
balanceQuery={makeBalanceQuery()}
|
||||
targetBalance={5000}
|
||||
onDone={onDone}
|
||||
onCreateTransaction={onCreateTransaction}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('All reconciled!')).toBeInTheDocument();
|
||||
@@ -67,14 +67,14 @@ describe('ReconcilingMessage math & UI', () => {
|
||||
const onCreateTransaction = vi.fn();
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcilingMessage
|
||||
balanceQuery={makeBalanceQuery()}
|
||||
targetBalance={10000}
|
||||
onDone={vi.fn()}
|
||||
onCreateTransaction={onCreateTransaction}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
// Formatted amounts present
|
||||
@@ -95,14 +95,14 @@ describe('ReconcilingMessage math & UI', () => {
|
||||
const onCreateTransaction = vi.fn();
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcilingMessage
|
||||
balanceQuery={makeBalanceQuery()}
|
||||
targetBalance={10000}
|
||||
onDone={vi.fn()}
|
||||
onCreateTransaction={onCreateTransaction}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('120.00')).toBeInTheDocument();
|
||||
@@ -133,13 +133,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcileMenu
|
||||
account={baseAccount as AccountEntity}
|
||||
onReconcile={onReconcile}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
@@ -162,13 +162,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcileMenu
|
||||
account={baseAccount as AccountEntity}
|
||||
onReconcile={onReconcile}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
@@ -200,13 +200,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
|
||||
connectedAccount.balance_current = 4321;
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcileMenu
|
||||
account={connectedAccount}
|
||||
onReconcile={onReconcile}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
// Fill from last synced value (43.21)
|
||||
@@ -223,13 +223,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
|
||||
const onReconcile = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcileMenu
|
||||
account={baseAccount as AccountEntity}
|
||||
onReconcile={onReconcile}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
@@ -247,13 +247,13 @@ describe('ReconcileMenu arithmetic evaluation', () => {
|
||||
const onReconcile = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<ReconcileMenu
|
||||
account={baseAccount as AccountEntity}
|
||||
onReconcile={onReconcile}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Reconcile' }));
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
|
||||
import { useCommonPayees } from '@desktop-client/hooks/usePayees';
|
||||
import { TestProvider } from '@desktop-client/redux/mock';
|
||||
import { TestProviders } from '@desktop-client/mocks';
|
||||
|
||||
const PAYEE_SELECTOR = '[data-testid][role=option]';
|
||||
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';
|
||||
@@ -75,7 +75,7 @@ function renderPayeeAutocomplete(
|
||||
};
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<AuthProvider>
|
||||
<div data-testid="autocomplete-test">
|
||||
<PayeeAutocomplete
|
||||
@@ -87,7 +87,7 @@ function renderPayeeAutocomplete(
|
||||
/>
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
return screen.getByTestId('autocomplete-test');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,9 +17,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';
|
||||
@@ -108,12 +108,13 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const updateAccount = useUpdateAccountMutation();
|
||||
|
||||
const onSave = useCallback(
|
||||
(account: AccountEntity) => {
|
||||
dispatch(updateAccount({ account }));
|
||||
updateAccount.mutate({ account });
|
||||
},
|
||||
[dispatch],
|
||||
[updateAccount],
|
||||
);
|
||||
|
||||
const onSaveNotes = useCallback(async (id: string, notes: string) => {
|
||||
@@ -142,9 +143,11 @@ function AccountHeader({ account }: { readonly account: AccountEntity }) {
|
||||
dispatch(openAccountCloseModal({ accountId: account.id }));
|
||||
}, [account.id, dispatch]);
|
||||
|
||||
const reopenAccount = useReopenAccountMutation();
|
||||
|
||||
const onReopenAccount = useCallback(() => {
|
||||
dispatch(reopenAccount({ id: account.id }));
|
||||
}, [account.id, dispatch]);
|
||||
reopenAccount.mutate({ id: account.id });
|
||||
}, [account.id, reopenAccount]);
|
||||
|
||||
const [showRunningBalances, setShowRunningBalances] = useSyncedPref(
|
||||
`show-balances-${account.id}`,
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
type 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';
|
||||
@@ -111,11 +111,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(
|
||||
() =>
|
||||
|
||||
@@ -29,8 +29,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';
|
||||
@@ -187,26 +189,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,
|
||||
@@ -415,7 +414,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 =>
|
||||
@@ -446,12 +446,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,
|
||||
@@ -464,17 +462,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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -533,9 +529,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'),
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MobilePayeesPage } from './MobilePayeesPage';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayeeRuleCounts } from '@desktop-client/hooks/usePayeeRuleCounts';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { TestProvider } from '@desktop-client/redux/mock';
|
||||
import { TestProviders } from '@desktop-client/mocks';
|
||||
|
||||
vi.mock('@use-gesture/react', () => ({
|
||||
useDrag: vi.fn().mockReturnValue(() => ({})),
|
||||
@@ -57,9 +57,9 @@ describe('MobilePayeesPage', () => {
|
||||
|
||||
const renderPayeesPage = () => {
|
||||
return render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<MobilePayeesPage />
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,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';
|
||||
@@ -92,6 +92,8 @@ export function CloseAccountModal({
|
||||
}
|
||||
: {};
|
||||
|
||||
const closeAccount = useCloseAccountMutation();
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -108,13 +110,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;
|
||||
};
|
||||
|
||||
@@ -276,12 +277,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 }}
|
||||
|
||||
@@ -14,7 +14,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,
|
||||
@@ -54,6 +54,8 @@ export function CreateLocalAccountModal() {
|
||||
}
|
||||
};
|
||||
|
||||
const createAccount = useCreateAccountMutation();
|
||||
|
||||
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -63,15 +65,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 (
|
||||
|
||||
@@ -3,7 +3,7 @@ import { vi } from 'vitest';
|
||||
|
||||
import { GoCardlessExternalMsgModal } from './GoCardlessExternalMsgModal';
|
||||
|
||||
import { TestProvider } from '@desktop-client/redux/mock';
|
||||
import { TestProviders } from '@desktop-client/mocks';
|
||||
|
||||
vi.mock('@desktop-client/hooks/useGlobalPref', () => ({
|
||||
useGlobalPref: () => [null],
|
||||
@@ -50,9 +50,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<GoCardlessExternalMsgModal {...mockProps} />
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
const countryInput = screen.getByPlaceholderText('(please select)');
|
||||
@@ -76,9 +76,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<GoCardlessExternalMsgModal {...mockProps} />
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
const countryInput = screen.getByPlaceholderText('(please select)');
|
||||
@@ -101,9 +101,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<GoCardlessExternalMsgModal {...mockProps} />
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
const countryInput = screen.getByPlaceholderText('(please select)');
|
||||
@@ -126,9 +126,9 @@ describe('GoCardlessExternalMsgModal - Country Auto-selection', () => {
|
||||
});
|
||||
|
||||
render(
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<GoCardlessExternalMsgModal {...mockProps} />
|
||||
</TestProvider>,
|
||||
</TestProviders>,
|
||||
);
|
||||
|
||||
const countryInput = screen.getByPlaceholderText('(please select)');
|
||||
|
||||
@@ -39,9 +39,9 @@ import {
|
||||
} from './utils';
|
||||
|
||||
import {
|
||||
importPreviewTransactions,
|
||||
importTransactions,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
useImportPreviewTransactionsMutation,
|
||||
useImportTransactionsMutation,
|
||||
} from '@desktop-client/accounts';
|
||||
import {
|
||||
Modal,
|
||||
ModalCloseButton,
|
||||
@@ -314,59 +314,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(
|
||||
@@ -581,6 +531,8 @@ export function ImportTransactionsModal({
|
||||
setTransactions(newTransactions);
|
||||
}
|
||||
|
||||
const importTransactions = useImportTransactionsMutation();
|
||||
|
||||
async function onImport(close) {
|
||||
setLoadingState('importing');
|
||||
|
||||
@@ -702,26 +654,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,
|
||||
@@ -732,7 +691,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 {
|
||||
} 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,
|
||||
type AutocompleteItem,
|
||||
@@ -156,6 +156,11 @@ export function SelectLinkedAccountsModal({
|
||||
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
|
||||
useAddBudgetAccountOptions();
|
||||
|
||||
const linkAccount = useLinkAccountMutation();
|
||||
const unlinkAccount = useUnlinkAccountMutation();
|
||||
const linkAccountSimpleFin = useLinkAccountSimpleFinMutation();
|
||||
const linkAccountPluggyAi = useLinkAccountPluggyAiMutation();
|
||||
|
||||
async function onNext() {
|
||||
const chosenLocalAccountIds = Object.values(chosenAccounts);
|
||||
|
||||
@@ -164,7 +169,7 @@ export function SelectLinkedAccountsModal({
|
||||
localAccounts
|
||||
.filter(acc => acc.account_id)
|
||||
.filter(acc => !chosenLocalAccountIds.includes(acc.id))
|
||||
.forEach(acc => dispatch(unlinkAccount({ id: acc.id })));
|
||||
.forEach(acc => unlinkAccount.mutate({ id: acc.id }));
|
||||
|
||||
// Link new accounts
|
||||
Object.entries(chosenAccounts).forEach(
|
||||
@@ -191,57 +196,51 @@ export function SelectLinkedAccountsModal({
|
||||
customSettings?.amount != null ? customSettings.amount : undefined;
|
||||
|
||||
if (propsWithSortedExternalAccounts.syncSource === 'simpleFin') {
|
||||
dispatch(
|
||||
linkAccountSimpleFin({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}),
|
||||
);
|
||||
linkAccountSimpleFin.mutate({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
} else if (propsWithSortedExternalAccounts.syncSource === 'pluggyai') {
|
||||
dispatch(
|
||||
linkAccountPluggyAi({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}),
|
||||
);
|
||||
linkAccountPluggyAi.mutate({
|
||||
externalAccount:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
} else {
|
||||
dispatch(
|
||||
linkAccount({
|
||||
requisitionId: propsWithSortedExternalAccounts.requisitionId,
|
||||
account:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}),
|
||||
);
|
||||
linkAccount.mutate({
|
||||
requisitionId: propsWithSortedExternalAccounts.requisitionId,
|
||||
account:
|
||||
propsWithSortedExternalAccounts.externalAccounts[
|
||||
externalAccountIndex
|
||||
],
|
||||
upgradingId:
|
||||
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
|
||||
chosenLocalAccountId !== addOffBudgetAccountOption.id
|
||||
? chosenLocalAccountId
|
||||
: undefined,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2,11 +2,14 @@ import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { Change } from './Change';
|
||||
|
||||
import { store } from '@desktop-client/redux/store';
|
||||
import { configureAppStore } from '@desktop-client/redux/store';
|
||||
|
||||
const store = configureAppStore({ queryClient: new QueryClient() });
|
||||
|
||||
describe('Change', () => {
|
||||
it('renders a positive amount with a plus sign and positive color', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useMultiuserEnabled,
|
||||
} from '@desktop-client/components/ServerContext';
|
||||
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||
import { TestProvider } from '@desktop-client/redux/mock';
|
||||
import { TestProviders } from '@desktop-client/mocks';
|
||||
|
||||
vi.mock('@desktop-client/hooks/useSyncServerStatus', () => ({
|
||||
useSyncServerStatus: vi.fn(),
|
||||
@@ -28,7 +28,7 @@ describe('AuthSettings', () => {
|
||||
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
|
||||
vi.mocked(useLoginMethod).mockReturnValue('password');
|
||||
|
||||
const { container } = render(<AuthSettings />, { wrapper: TestProvider });
|
||||
const { container } = render(<AuthSettings />, { wrapper: TestProviders });
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
@@ -42,7 +42,7 @@ describe('AuthSettings', () => {
|
||||
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
|
||||
vi.mocked(useLoginMethod).mockReturnValue('password');
|
||||
|
||||
render(<AuthSettings />, { wrapper: TestProvider });
|
||||
render(<AuthSettings />, { wrapper: TestProviders });
|
||||
|
||||
const startUsingButton = screen.getByRole('button', {
|
||||
name: /start using openid/i,
|
||||
@@ -59,7 +59,7 @@ describe('AuthSettings', () => {
|
||||
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
|
||||
vi.mocked(useLoginMethod).mockReturnValue('openid');
|
||||
|
||||
render(<AuthSettings />, { wrapper: TestProvider });
|
||||
render(<AuthSettings />, { wrapper: TestProviders });
|
||||
|
||||
const disableButton = screen.getByRole('button', {
|
||||
name: /disable openid/i,
|
||||
@@ -82,7 +82,7 @@ describe('AuthSettings', () => {
|
||||
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
|
||||
vi.mocked(useLoginMethod).mockReturnValue('password');
|
||||
|
||||
render(<AuthSettings />, { wrapper: TestProvider });
|
||||
render(<AuthSettings />, { wrapper: TestProviders });
|
||||
|
||||
const startUsingButton = screen.getByRole('button', {
|
||||
name: /start using openid/i,
|
||||
@@ -99,7 +99,7 @@ describe('AuthSettings', () => {
|
||||
vi.mocked(useMultiuserEnabled).mockReturnValue(false);
|
||||
vi.mocked(useLoginMethod).mockReturnValue('openid');
|
||||
|
||||
render(<AuthSettings />, { wrapper: TestProvider });
|
||||
render(<AuthSettings />, { wrapper: TestProviders });
|
||||
|
||||
const disableButton = screen.getByRole('button', {
|
||||
name: /disable openid/i,
|
||||
@@ -116,7 +116,7 @@ describe('AuthSettings', () => {
|
||||
vi.mocked(useMultiuserEnabled).mockReturnValue(true);
|
||||
vi.mocked(useLoginMethod).mockReturnValue('openid');
|
||||
|
||||
render(<AuthSettings />, { wrapper: TestProvider });
|
||||
render(<AuthSettings />, { wrapper: TestProviders });
|
||||
|
||||
const warningText = screen.getByText(
|
||||
/disabling openid will deactivate multi-user mode\./i,
|
||||
|
||||
@@ -23,9 +23,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';
|
||||
@@ -133,6 +133,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
|
||||
@@ -219,14 +221,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);
|
||||
}}
|
||||
@@ -261,7 +261,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 = () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
|
||||
import { SplitsExpandedProvider } from '@desktop-client/hooks/useSplitsExpanded';
|
||||
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
|
||||
import { TestProvider } from '@desktop-client/redux/mock';
|
||||
import { TestProviders } from '@desktop-client/mocks';
|
||||
|
||||
vi.mock('loot-core/platform/client/fetch');
|
||||
vi.mock('../../hooks/useFeatureFlag', () => ({
|
||||
@@ -195,7 +195,7 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
|
||||
// implementation properly uses the right latest state even if the
|
||||
// hook dependencies haven't changed
|
||||
return (
|
||||
<TestProvider>
|
||||
<TestProviders>
|
||||
<AuthProvider>
|
||||
<SpreadsheetProvider>
|
||||
<SchedulesProvider>
|
||||
@@ -226,7 +226,7 @@ function LiveTransactionTable(props: LiveTransactionTableProps) {
|
||||
</SchedulesProvider>
|
||||
</SpreadsheetProvider>
|
||||
</AuthProvider>
|
||||
</TestProvider>
|
||||
</TestProviders>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type QueryClient } from '@tanstack/react-query';
|
||||
import { listen } from 'loot-core/platform/client/fetch';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
|
||||
import { reloadAccounts } from './accounts/accountsSlice';
|
||||
import { accountQueries } from './accounts';
|
||||
import { setAppState } from './app/appSlice';
|
||||
import { categoryQueries } from './budget';
|
||||
import { closeBudgetUI } from './budgetfiles/budgetfilesSlice';
|
||||
@@ -74,7 +74,11 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
if (tables.includes('accounts')) {
|
||||
promises.push(store.dispatch(reloadAccounts()));
|
||||
promises.push(
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const tagged = undo.getTaggedState(undoTag);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useAccounts } from './useAccounts';
|
||||
import { accountQueries } from '@desktop-client/accounts';
|
||||
|
||||
export function useAccount(id: string) {
|
||||
const accounts = useAccounts();
|
||||
return useMemo(() => accounts.find(a => a.id === id), [id, accounts]);
|
||||
const query = useQuery({
|
||||
...accountQueries.list(),
|
||||
select: data => data.find(c => c.id === id),
|
||||
});
|
||||
return query.data;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,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 ?? [];
|
||||
}
|
||||
|
||||
@@ -26,12 +26,17 @@ import * as notificationsSlice from './notifications/notificationsSlice';
|
||||
import * as payeesSlice from './payees/payeesSlice';
|
||||
import * as prefsSlice from './prefs/prefsSlice';
|
||||
import { aqlQuery } from './queries/aqlQuery';
|
||||
import { store } from './redux/store';
|
||||
import { configureAppStore } from './redux/store';
|
||||
import * as tagsSlice from './tags/tagsSlice';
|
||||
import * as transactionsSlice from './transactions/transactionsSlice';
|
||||
import { redo, undo } from './undo';
|
||||
import * as usersSlice from './users/usersSlice';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
|
||||
|
||||
const store = configureAppStore({ queryClient });
|
||||
|
||||
const boundActions = bindActionCreators(
|
||||
{
|
||||
...accountsSlice.actions,
|
||||
@@ -82,21 +87,18 @@ window.$send = send;
|
||||
window.$query = aqlQuery;
|
||||
window.$q = q;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
|
||||
|
||||
const container = document.getElementById('root');
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<ServerProvider>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<ServerProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</ServerProvider>
|
||||
</Provider>,
|
||||
</AuthProvider>
|
||||
</ServerProvider>
|
||||
</Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
declare global {
|
||||
|
||||
25
packages/desktop-client/src/mocks.tsx
Normal file
25
packages/desktop-client/src/mocks.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import { configureAppStore, type AppStore } from './redux/store';
|
||||
|
||||
let mockQueryClient = new QueryClient();
|
||||
|
||||
export let mockStore: AppStore = configureAppStore({
|
||||
queryClient: mockQueryClient,
|
||||
});
|
||||
|
||||
export function resetTestProviders() {
|
||||
mockQueryClient = new QueryClient();
|
||||
mockStore = configureAppStore({ queryClient: mockQueryClient });
|
||||
}
|
||||
|
||||
export function TestProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
<Provider store={mockStore}>{children}</Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} 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';
|
||||
@@ -590,10 +591,7 @@ type OpenAccountCloseModalPayload = {
|
||||
|
||||
export const openAccountCloseModal = createAppAsyncThunk(
|
||||
`${sliceName}/openAccountCloseModal`,
|
||||
async (
|
||||
{ accountId }: OpenAccountCloseModalPayload,
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
async ({ accountId }: OpenAccountCloseModalPayload, { dispatch, extra }) => {
|
||||
const {
|
||||
balance,
|
||||
numTransactions,
|
||||
@@ -603,9 +601,10 @@ export const openAccountCloseModal = createAppAsyncThunk(
|
||||
id: accountId,
|
||||
},
|
||||
);
|
||||
const account = getState().account.accounts.find(
|
||||
acct => acct.id === accountId,
|
||||
);
|
||||
const queryClient = extra.queryClient;
|
||||
const accounts =
|
||||
queryClient.getQueryData(accountQueries.list().queryKey) ?? [];
|
||||
const account = accounts.find(acct => acct.id === accountId);
|
||||
|
||||
if (!account) {
|
||||
throw new Error(`Account with ID ${accountId} does not exist.`);
|
||||
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
|
||||
import { type AppDispatch, type AppStore, type RootState } from './store';
|
||||
import {
|
||||
type AppDispatch,
|
||||
type AppStore,
|
||||
type ExtraArguments,
|
||||
type RootState,
|
||||
} from './store';
|
||||
|
||||
export const createAppAsyncThunk = createAsyncThunk.withTypes<{
|
||||
state: RootState;
|
||||
dispatch: AppDispatch;
|
||||
extra: ExtraArguments;
|
||||
}>();
|
||||
|
||||
export const useStore = useReduxStore.withTypes<AppStore>();
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
|
||||
import { type store as realStore } from './store';
|
||||
|
||||
import {
|
||||
name as accountsSliceName,
|
||||
reducer as accountsSliceReducer,
|
||||
} from '@desktop-client/accounts/accountsSlice';
|
||||
import {
|
||||
name as appSliceName,
|
||||
reducer as appSliceReducer,
|
||||
} from '@desktop-client/app/appSlice';
|
||||
import {
|
||||
name as budgetfilesSliceName,
|
||||
reducer as budgetfilesSliceReducer,
|
||||
} from '@desktop-client/budgetfiles/budgetfilesSlice';
|
||||
import {
|
||||
name as modalsSliceName,
|
||||
reducer as modalsSliceReducer,
|
||||
} from '@desktop-client/modals/modalsSlice';
|
||||
import {
|
||||
name as notificationsSliceName,
|
||||
reducer as notificationsSliceReducer,
|
||||
} from '@desktop-client/notifications/notificationsSlice';
|
||||
import {
|
||||
name as payeesSliceName,
|
||||
reducer as payeesSliceReducer,
|
||||
} from '@desktop-client/payees/payeesSlice';
|
||||
import {
|
||||
name as prefsSliceName,
|
||||
reducer as prefsSliceReducer,
|
||||
} from '@desktop-client/prefs/prefsSlice';
|
||||
import {
|
||||
name as tagsSliceName,
|
||||
reducer as tagsSliceReducer,
|
||||
} from '@desktop-client/tags/tagsSlice';
|
||||
import {
|
||||
name as transactionsSliceName,
|
||||
reducer as transactionsSliceReducer,
|
||||
} from '@desktop-client/transactions/transactionsSlice';
|
||||
import {
|
||||
name as usersSliceName,
|
||||
reducer as usersSliceReducer,
|
||||
} from '@desktop-client/users/usersSlice';
|
||||
|
||||
const appReducer = combineReducers({
|
||||
[accountsSliceName]: accountsSliceReducer,
|
||||
[appSliceName]: appSliceReducer,
|
||||
[budgetfilesSliceName]: budgetfilesSliceReducer,
|
||||
[modalsSliceName]: modalsSliceReducer,
|
||||
[notificationsSliceName]: notificationsSliceReducer,
|
||||
[payeesSliceName]: payeesSliceReducer,
|
||||
[prefsSliceName]: prefsSliceReducer,
|
||||
[transactionsSliceName]: transactionsSliceReducer,
|
||||
[tagsSliceName]: tagsSliceReducer,
|
||||
[usersSliceName]: usersSliceReducer,
|
||||
});
|
||||
|
||||
export let mockStore: typeof realStore = configureStore({
|
||||
reducer: appReducer,
|
||||
});
|
||||
|
||||
export function resetMockStore() {
|
||||
mockStore = configureStore({
|
||||
reducer: appReducer,
|
||||
});
|
||||
}
|
||||
|
||||
export function TestProvider({ children }: { children: ReactNode }) {
|
||||
return <Provider store={mockStore}>{children}</Provider>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createListenerMiddleware,
|
||||
isRejected,
|
||||
} from '@reduxjs/toolkit';
|
||||
import { type QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
name as accountsSliceName,
|
||||
@@ -77,16 +78,28 @@ notifyOnRejectedActionsMiddleware.startListening({
|
||||
},
|
||||
});
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
// TODO: Fix this in a separate PR. Remove non-serializable states in the store.
|
||||
serializableCheck: false,
|
||||
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
|
||||
});
|
||||
export function configureAppStore({
|
||||
queryClient,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
}) {
|
||||
return configureStore({
|
||||
reducer: rootReducer,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
// TODO: Fix this in a separate PR. Remove non-serializable states in the store.
|
||||
serializableCheck: false,
|
||||
thunk: {
|
||||
extraArgument: { queryClient } as ExtraArguments,
|
||||
},
|
||||
}).prepend(notifyOnRejectedActionsMiddleware.middleware),
|
||||
});
|
||||
}
|
||||
|
||||
export type AppStore = typeof store;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type GetRootState = typeof store.getState;
|
||||
export type AppStore = ReturnType<typeof configureAppStore>;
|
||||
export type RootState = ReturnType<AppStore['getState']>;
|
||||
export type AppDispatch = AppStore['dispatch'];
|
||||
export type GetRootState = AppStore['getState'];
|
||||
export type ExtraArguments = {
|
||||
queryClient: QueryClient;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { resetTestProviders } from './mocks';
|
||||
import { installPolyfills } from './polyfills';
|
||||
import { resetMockStore } from './redux/mock';
|
||||
|
||||
installPolyfills();
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('react-virtualized-auto-sizer', () => {
|
||||
global.Date.now = () => 123456789;
|
||||
|
||||
global.__resetWorld = () => {
|
||||
resetMockStore();
|
||||
resetTestProviders();
|
||||
};
|
||||
|
||||
process.on('unhandledRejection', reason => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from 'i18next';
|
||||
|
||||
import { listen, send } from 'loot-core/platform/client/fetch';
|
||||
|
||||
import { reloadAccounts } from './accounts/accountsSlice';
|
||||
import { accountQueries } from './accounts';
|
||||
import { resetSync, sync } from './app/appSlice';
|
||||
import { categoryQueries } from './budget';
|
||||
import {
|
||||
@@ -88,7 +88,9 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
|
||||
}
|
||||
|
||||
if (tables.includes('accounts')) {
|
||||
store.dispatch(reloadAccounts());
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: accountQueries.lists(),
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
let notif: Notification | null = null;
|
||||
|
||||
@@ -31,7 +31,7 @@ export function generateAccount(
|
||||
return {
|
||||
...offlineAccount,
|
||||
balance_current: Math.floor(random() * 100000),
|
||||
bankId: Math.floor(random() * 10000),
|
||||
bankId: Math.floor(random() * 10000).toString(),
|
||||
bankName: 'boa',
|
||||
bank: Math.floor(random() * 10000).toString(),
|
||||
account_id: 'idx',
|
||||
|
||||
@@ -89,8 +89,31 @@ async function updateAccount({
|
||||
return {};
|
||||
}
|
||||
|
||||
async function getAccounts() {
|
||||
return db.getAccounts();
|
||||
async function getAccounts(): Promise<AccountEntity[]> {
|
||||
const dbAccounts = await db.getAccounts();
|
||||
return dbAccounts.map(
|
||||
dbAccount =>
|
||||
({
|
||||
id: dbAccount.id,
|
||||
name: dbAccount.name,
|
||||
offbudget: dbAccount.offbudget,
|
||||
closed: dbAccount.closed,
|
||||
sort_order: dbAccount.sort_order,
|
||||
last_reconciled: dbAccount.last_reconciled ?? null,
|
||||
tombstone: dbAccount.tombstone,
|
||||
account_id: dbAccount.account_id ?? null,
|
||||
bank: dbAccount.bank ?? null,
|
||||
bankName: dbAccount.bankName ?? null,
|
||||
bankId: dbAccount.bankId ?? null,
|
||||
mask: dbAccount.mask ?? null,
|
||||
official_name: dbAccount.official_name ?? null,
|
||||
balance_current: dbAccount.balance_current ?? null,
|
||||
balance_available: dbAccount.balance_available ?? null,
|
||||
balance_limit: dbAccount.balance_limit ?? null,
|
||||
account_sync_source: dbAccount.account_sync_source ?? null,
|
||||
last_sync: dbAccount.last_sync ?? null,
|
||||
}) as AccountEntity,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAccountBalance({
|
||||
|
||||
@@ -571,8 +571,7 @@ handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
|
||||
|
||||
handlers['api/accounts-get'] = async function () {
|
||||
checkFileOpen();
|
||||
// TODO: Force cast to AccountEntity. This should be updated to an AQL query.
|
||||
const accounts = (await db.getAccounts()) as AccountEntity[];
|
||||
const accounts: AccountEntity[] = await handlers['accounts-get']();
|
||||
return accounts.map(account => accountModel.toExternal(account));
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export type DbAccount = {
|
||||
subtype?: string | null;
|
||||
bank?: string | null;
|
||||
account_sync_source?: 'simpleFin' | 'goCardless' | null;
|
||||
last_reconciled?: string | null;
|
||||
last_sync?: string | null;
|
||||
};
|
||||
|
||||
export type DbBank = {
|
||||
|
||||
@@ -12,7 +12,7 @@ export type _SyncFields<T> = {
|
||||
account_id: T extends true ? string : null;
|
||||
bank: T extends true ? string : null;
|
||||
bankName: T extends true ? string : null;
|
||||
bankId: T extends true ? number : null;
|
||||
bankId: T extends true ? string : null;
|
||||
mask: T extends true ? string : null; // end of bank account number
|
||||
official_name: T extends true ? string : null;
|
||||
balance_current: T extends true ? number : null;
|
||||
|
||||
6
upcoming-release-notes/6140.md
Normal file
6
upcoming-release-notes/6140.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Migrate account state management from Redux to React Query with updated hooks.
|
||||
Reference in New Issue
Block a user