mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-07 04:18:51 -05:00
add confirmation dialog when changing half-reconciled transfers (#7269)
* check paired transfer transactions for reconciliation status * add confirm messages * note * bulk edit * improve types * add checks to individual transaction edits * add to mobile * wabbit
This commit is contained in:
@@ -75,6 +75,7 @@ import {
|
||||
pushModal,
|
||||
replaceModal,
|
||||
} from '@desktop-client/modals/modalsSlice';
|
||||
import type { ConfirmTransactionEditReason } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useCreatePayeeMutation } from '@desktop-client/payees';
|
||||
import * as queries from '@desktop-client/queries';
|
||||
@@ -1230,7 +1231,7 @@ class AccountInternal extends PureComponent<
|
||||
|
||||
checkForReconciledTransactions = async (
|
||||
ids: string[],
|
||||
confirmReason: string,
|
||||
confirmReason: ConfirmTransactionEditReason,
|
||||
onConfirm: (ids: string[]) => void,
|
||||
) => {
|
||||
const { data } = await aqlQuery(
|
||||
|
||||
@@ -686,7 +686,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
[categories, isBudgetTransfer, t],
|
||||
);
|
||||
|
||||
const onSaveInner = useCallback(() => {
|
||||
const onSaveInner = useCallback(async () => {
|
||||
const [unserializedTransaction] = unserializedTransactions;
|
||||
|
||||
const onConfirmSave = () => {
|
||||
@@ -766,7 +766,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
if (unserializedTransaction.reconciled) {
|
||||
if (unserializedTransactions.some(t => t.reconciled)) {
|
||||
// On mobile any save gives the warning.
|
||||
// On the web only certain changes trigger a warning.
|
||||
// Should we bring that here as well? Or does the nature of the editing form
|
||||
@@ -783,7 +783,37 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onConfirmSave();
|
||||
const transferIds = unserializedTransactions
|
||||
.map(t => t.transfer_id)
|
||||
.filter((id): id is string => id != null);
|
||||
|
||||
if (transferIds.length > 0) {
|
||||
const { data } = await aqlQuery(
|
||||
q('transactions')
|
||||
.filter({
|
||||
id: { $oneof: transferIds },
|
||||
reconciled: true,
|
||||
})
|
||||
.select('id'),
|
||||
);
|
||||
if ((data as TransactionEntity[]).length > 0) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-transaction-edit',
|
||||
options: {
|
||||
onConfirm: onConfirmSave,
|
||||
confirmReason: 'batchEditWithReconciledTransfer',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onConfirmSave();
|
||||
}
|
||||
} else {
|
||||
onConfirmSave();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isAdding,
|
||||
@@ -943,8 +973,10 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
);
|
||||
|
||||
const onDeleteInner = useCallback(
|
||||
(id: TransactionEntity['id']) => {
|
||||
const [unserializedTransaction] = unserializedTransactions;
|
||||
async (id: TransactionEntity['id']) => {
|
||||
const [parentTransaction] = unserializedTransactions;
|
||||
const targetTransaction =
|
||||
unserializedTransactions.find(t => t.id === id) ?? parentTransaction;
|
||||
|
||||
const onConfirmDelete = () => {
|
||||
dispatch(
|
||||
@@ -958,7 +990,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
onConfirm: () => {
|
||||
onDelete(id);
|
||||
|
||||
if (unserializedTransaction.id !== id) {
|
||||
if (parentTransaction.id !== id) {
|
||||
// Only a child transaction was deleted.
|
||||
onClearActiveEdit();
|
||||
return;
|
||||
@@ -972,7 +1004,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
);
|
||||
};
|
||||
|
||||
if (unserializedTransaction.reconciled) {
|
||||
if (targetTransaction.reconciled) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
@@ -984,6 +1016,30 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else if (targetTransaction.transfer_id) {
|
||||
const { data } = await aqlQuery(
|
||||
q('transactions')
|
||||
.filter({
|
||||
id: targetTransaction.transfer_id,
|
||||
reconciled: true,
|
||||
})
|
||||
.select('id'),
|
||||
);
|
||||
if ((data as TransactionEntity[]).length > 0) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-transaction-edit',
|
||||
options: {
|
||||
onConfirm: onConfirmDelete,
|
||||
confirmReason: 'batchDeleteWithReconciledTransfer',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onConfirmDelete();
|
||||
}
|
||||
} else {
|
||||
onConfirmDelete();
|
||||
}
|
||||
|
||||
@@ -47,13 +47,29 @@ export function ConfirmTransactionEditModal({
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
{confirmReason === 'batchDeleteWithReconciled' ? (
|
||||
{confirmReason === 'batchDeleteWithReconciledTransfer' ? (
|
||||
<Block>
|
||||
<Trans>
|
||||
This transfer has a linked transaction in another account that
|
||||
is reconciled. Deleting it may bring that account's
|
||||
reconciliation out of balance.
|
||||
</Trans>
|
||||
</Block>
|
||||
) : confirmReason === 'batchDeleteWithReconciled' ? (
|
||||
<Block>
|
||||
<Trans>
|
||||
Deleting reconciled transactions may bring your reconciliation
|
||||
out of balance.
|
||||
</Trans>
|
||||
</Block>
|
||||
) : confirmReason === 'batchEditWithReconciledTransfer' ? (
|
||||
<Block>
|
||||
<Trans>
|
||||
This transfer has a linked transaction in another account that
|
||||
is reconciled. Editing it may bring that account's
|
||||
reconciliation out of balance.
|
||||
</Trans>
|
||||
</Block>
|
||||
) : confirmReason === 'batchEditWithReconciled' ? (
|
||||
<Block>
|
||||
<Trans>
|
||||
@@ -61,6 +77,14 @@ export function ConfirmTransactionEditModal({
|
||||
out of balance.
|
||||
</Trans>
|
||||
</Block>
|
||||
) : confirmReason === 'batchDuplicateWithReconciledTransfer' ? (
|
||||
<Block>
|
||||
<Trans>
|
||||
This transfer has a linked transaction in another account that
|
||||
is reconciled. Duplicating it may bring that account's
|
||||
reconciliation out of balance.
|
||||
</Trans>
|
||||
</Block>
|
||||
) : confirmReason === 'batchDuplicateWithReconciled' ? (
|
||||
<Block>
|
||||
<Trans>
|
||||
|
||||
@@ -152,6 +152,7 @@ import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { NotesTagFormatter } from '@desktop-client/notes/NotesTagFormatter';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { getPayeesById } from '@desktop-client/payees';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type TransactionHeaderProps = {
|
||||
@@ -993,7 +994,7 @@ const Transaction = memo(function Transaction({
|
||||
const [showReconciliationWarning, setShowReconciliationWarning] =
|
||||
useState(false);
|
||||
|
||||
const onUpdate: TransactionUpdateFunction = (name, value) => {
|
||||
const onUpdate: TransactionUpdateFunction = async (name, value) => {
|
||||
// Had some issues with this is called twice which is a problem now that we are showing a warning
|
||||
// modal if the transaction is locked. I added a boolean to guard against showing the modal twice.
|
||||
// I'm still not completely happy with how the cells update pre/post modal. Sometimes you have to
|
||||
@@ -1002,14 +1003,14 @@ const Transaction = memo(function Transaction({
|
||||
// of the cell all have different implications as well.
|
||||
|
||||
if (transaction[name] !== value) {
|
||||
if (
|
||||
transaction.reconciled === true &&
|
||||
(name === 'credit' ||
|
||||
name === 'debit' ||
|
||||
name === 'payee' ||
|
||||
name === 'account' ||
|
||||
name === 'date')
|
||||
) {
|
||||
const isReconciledField =
|
||||
name === 'credit' ||
|
||||
name === 'debit' ||
|
||||
name === 'payee' ||
|
||||
name === 'account' ||
|
||||
name === 'date';
|
||||
|
||||
if (transaction.reconciled === true && isReconciledField) {
|
||||
if (showReconciliationWarning === false) {
|
||||
setShowReconciliationWarning(true);
|
||||
dispatch(
|
||||
@@ -1030,6 +1031,38 @@ const Transaction = memo(function Transaction({
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
isReconciledField &&
|
||||
transaction.transfer_id &&
|
||||
showReconciliationWarning === false
|
||||
) {
|
||||
const { data } = await aqlQuery(
|
||||
q('transactions')
|
||||
.filter({ id: transaction.transfer_id, reconciled: true })
|
||||
.select('id'),
|
||||
);
|
||||
if ((data as TransactionEntity[]).length > 0) {
|
||||
setShowReconciliationWarning(true);
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-transaction-edit',
|
||||
options: {
|
||||
onCancel: () => {
|
||||
setShowReconciliationWarning(false);
|
||||
},
|
||||
onConfirm: () => {
|
||||
setShowReconciliationWarning(false);
|
||||
onUpdateAfterConfirm(name, value);
|
||||
},
|
||||
confirmReason: 'batchEditWithReconciledTransfer',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onUpdateAfterConfirm(name, value);
|
||||
}
|
||||
} else {
|
||||
onUpdateAfterConfirm(name, value);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,18 @@ import type {
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
import type {
|
||||
ConfirmTransactionEditReason,
|
||||
Modal as ModalType,
|
||||
} from '@desktop-client/modals/modalsSlice';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type BatchReconciledReason = Extract<
|
||||
ConfirmTransactionEditReason,
|
||||
`batch${string}Reconciled`
|
||||
>;
|
||||
|
||||
type BatchEditProps = {
|
||||
name: keyof TransactionEntity;
|
||||
ids: Array<TransactionEntity['id']>;
|
||||
@@ -242,49 +250,35 @@ export function useTransactionBatchActions() {
|
||||
);
|
||||
};
|
||||
|
||||
const openFieldEditor = () => {
|
||||
if (name === 'cleared') {
|
||||
// Cleared just toggles it on/off and it depends on the data
|
||||
// loaded. Need to clean this up in the future.
|
||||
void onChange('cleared', null);
|
||||
} else if (name === 'category') {
|
||||
pushCategoryAutocompleteModal();
|
||||
} else if (name === 'payee') {
|
||||
pushPayeeAutocompleteModal();
|
||||
} else if (name === 'account') {
|
||||
pushAccountAutocompleteModal();
|
||||
} else {
|
||||
pushEditField();
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
name === 'amount' ||
|
||||
name === 'payee' ||
|
||||
name === 'account' ||
|
||||
name === 'date'
|
||||
) {
|
||||
const reconciledTransactions = transactions.filter(t => t.reconciled);
|
||||
if (reconciledTransactions.length > 0) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-transaction-edit',
|
||||
options: {
|
||||
onConfirm: () => {
|
||||
if (name === 'payee') {
|
||||
pushPayeeAutocompleteModal();
|
||||
} else if (name === 'account') {
|
||||
pushAccountAutocompleteModal();
|
||||
} else {
|
||||
pushEditField();
|
||||
}
|
||||
},
|
||||
confirmReason: 'batchEditWithReconciled',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'cleared') {
|
||||
// Cleared just toggles it on/off and it depends on the data
|
||||
// loaded. Need to clean this up in the future.
|
||||
void onChange('cleared', null);
|
||||
} else if (name === 'category') {
|
||||
pushCategoryAutocompleteModal();
|
||||
} else if (name === 'payee') {
|
||||
pushPayeeAutocompleteModal();
|
||||
} else if (name === 'account') {
|
||||
pushAccountAutocompleteModal();
|
||||
await checkForReconciledTransactions(
|
||||
ids,
|
||||
'batchEditWithReconciled',
|
||||
openFieldEditor,
|
||||
);
|
||||
} else {
|
||||
pushEditField();
|
||||
openFieldEditor();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -445,9 +439,18 @@ export function useTransactionBatchActions() {
|
||||
onSuccess?.(ids);
|
||||
};
|
||||
|
||||
const transferReasonMap: Record<
|
||||
BatchReconciledReason,
|
||||
ConfirmTransactionEditReason
|
||||
> = {
|
||||
batchDeleteWithReconciled: 'batchDeleteWithReconciledTransfer',
|
||||
batchEditWithReconciled: 'batchEditWithReconciledTransfer',
|
||||
batchDuplicateWithReconciled: 'batchDuplicateWithReconciledTransfer',
|
||||
};
|
||||
|
||||
const checkForReconciledTransactions = async (
|
||||
ids: Array<TransactionEntity['id']>,
|
||||
confirmReason: string,
|
||||
confirmReason: BatchReconciledReason,
|
||||
onConfirm: (ids: Array<TransactionEntity['id']>) => void,
|
||||
) => {
|
||||
const { data } = await aqlQuery(
|
||||
@@ -457,6 +460,7 @@ export function useTransactionBatchActions() {
|
||||
.options({ splits: 'grouped' }),
|
||||
);
|
||||
const transactions = ungroupTransactions(data as TransactionEntity[]);
|
||||
|
||||
if (transactions.length > 0) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
@@ -471,9 +475,46 @@ export function useTransactionBatchActions() {
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onConfirm(ids);
|
||||
return;
|
||||
}
|
||||
|
||||
// check paired transfer transactions
|
||||
const { data: selectedData } = await aqlQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select(['transfer_id']),
|
||||
);
|
||||
|
||||
const transferIds = (selectedData as TransactionEntity[])
|
||||
.map(t => t.transfer_id)
|
||||
.filter((id): id is string => id != null);
|
||||
|
||||
if (transferIds.length > 0) {
|
||||
const { data: reconciledTransfers } = await aqlQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: transferIds }, reconciled: true })
|
||||
.select('*'),
|
||||
);
|
||||
|
||||
if ((reconciledTransfers as TransactionEntity[]).length > 0) {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-transaction-edit',
|
||||
options: {
|
||||
onConfirm: () => {
|
||||
onConfirm(ids);
|
||||
},
|
||||
confirmReason: transferReasonMap[confirmReason],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onConfirm(ids);
|
||||
};
|
||||
|
||||
const onSetTransfer = async (
|
||||
|
||||
@@ -28,6 +28,17 @@ import { signOut } from '@desktop-client/users/usersSlice';
|
||||
|
||||
const sliceName = 'modals';
|
||||
|
||||
export type ConfirmTransactionEditReason =
|
||||
| 'batchDeleteWithReconciled'
|
||||
| 'batchDeleteWithReconciledTransfer'
|
||||
| 'batchEditWithReconciled'
|
||||
| 'batchEditWithReconciledTransfer'
|
||||
| 'batchDuplicateWithReconciled'
|
||||
| 'batchDuplicateWithReconciledTransfer'
|
||||
| 'editReconciled'
|
||||
| 'unlockReconciled'
|
||||
| 'deleteReconciled';
|
||||
|
||||
export type Modal =
|
||||
| {
|
||||
name: 'import-transactions';
|
||||
@@ -518,7 +529,7 @@ export type Modal =
|
||||
options: {
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
confirmReason: string;
|
||||
confirmReason: ConfirmTransactionEditReason;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
6
upcoming-release-notes/7269.md
Normal file
6
upcoming-release-notes/7269.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Show confirmation dialog when editing/duplicating/deleting transfers where the other half is reconciled
|
||||
Reference in New Issue
Block a user