mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-10 04:02:38 -05:00
Compare commits
5 Commits
matiss/sep
...
react-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c190dc480 | ||
|
|
b288ce5708 | ||
|
|
8630a4fda6 | ||
|
|
2cc9daf50a | ||
|
|
fbc1025c2b |
@@ -13,6 +13,8 @@ reviews:
|
||||
mode: off
|
||||
enabled: false
|
||||
labeling_instructions:
|
||||
- label: 'suspect ai generated'
|
||||
instructions: 'This issue or PR is suspected to be generated by AI. Add this only if "AI generated" label is not present. Add it always if the commit or PR title is prefixed with "[AI]".'
|
||||
- label: 'API'
|
||||
instructions: 'This issue or PR updates the API in `packages/api`.'
|
||||
- label: 'documentation'
|
||||
|
||||
26
.github/scripts/count-points.mjs
vendored
26
.github/scripts/count-points.mjs
vendored
@@ -8,13 +8,13 @@ const CONFIG = {
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
|
||||
PR_CONTRIBUTION_POINTS: [
|
||||
{ categories: ['Features'], points: 2 },
|
||||
{ categories: ['Enhancements'], points: 2 },
|
||||
{ categories: ['Bugfixes', 'Bugfix'], points: 3 },
|
||||
{ categories: ['Maintenance'], points: 2 },
|
||||
{ categories: ['Unknown'], points: 2 },
|
||||
],
|
||||
PR_CONTRIBUTION_POINTS: {
|
||||
Features: 2,
|
||||
Enhancements: 2,
|
||||
Bugfix: 3,
|
||||
Maintenance: 2,
|
||||
Unknown: 2,
|
||||
},
|
||||
// Point tiers for code changes (non-docs)
|
||||
CODE_PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 500, points: 8 },
|
||||
@@ -130,14 +130,11 @@ async function getPRCategoryAndPoints(
|
||||
'utf-8',
|
||||
);
|
||||
const category = parseReleaseNotesCategory(content);
|
||||
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes(category),
|
||||
);
|
||||
|
||||
if (tier) {
|
||||
if (category && CONFIG.PR_CONTRIBUTION_POINTS[category]) {
|
||||
return {
|
||||
category,
|
||||
points: tier.points,
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS[category],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -145,12 +142,9 @@ async function getPRCategoryAndPoints(
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const unknownTier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes('Unknown'),
|
||||
);
|
||||
return {
|
||||
category: 'Unknown',
|
||||
points: unknownTier.points,
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
34
.github/workflows/merge-freeze-unfreeze.yml
vendored
34
.github/workflows/merge-freeze-unfreeze.yml
vendored
@@ -1,34 +0,0 @@
|
||||
# When the "unfreeze" label is added to a PR, add that PR to Merge Freeze's unblocked list
|
||||
# so it can be merged during a freeze. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
|
||||
# (project-specific token from Merge Freeze Web API panel for actualbudget/actual / master).
|
||||
# See: https://docs.mergefreeze.com/web-api#post-freeze-status
|
||||
|
||||
name: Merge Freeze – add PR to unblocked list
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
unfreeze:
|
||||
if: ${{ github.event.label.name == 'unfreeze' }}
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: merge-freeze-unfreeze-${{ github.ref }}-labels
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: POST to Merge Freeze – add PR to unblocked list
|
||||
env:
|
||||
MERGEFREEZE_ACCESS_TOKEN: ${{ secrets.MERGEFREEZE_ACCESS_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
USER_NAME: ${{ github.actor }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "$MERGEFREEZE_ACCESS_TOKEN" ]; then
|
||||
echo "::error::MERGEFREEZE_ACCESS_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
url="https://www.mergefreeze.com/api/branches/actualbudget/actual/master/?access_token=${MERGEFREEZE_ACCESS_TOKEN}"
|
||||
payload=$(jq -n --arg user_name "$USER_NAME" --argjson pr "$PR_NUMBER" '{frozen: true, user_name: $user_name, unblocked_prs: [$pr]}')
|
||||
curl -sf -X POST "$url" -H "Content-Type: application/json" -d "$payload"
|
||||
echo "Merge Freeze updated: PR #$PR_NUMBER added to unblocked list."
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
Normal file
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Remove 'suspect ai generated' label when 'AI generated' is present
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-suspect-label:
|
||||
if: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'suspect ai generated'
|
||||
});
|
||||
41
AGENTS.md
41
AGENTS.md
@@ -529,7 +529,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
|
||||
|
||||
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
|
||||
2. Reinstall dependencies: `yarn install`
|
||||
3. Check Node.js version (requires >=22)
|
||||
3. Check Node.js version (requires >=20)
|
||||
4. Check Yarn version (requires ^4.9.1)
|
||||
|
||||
## Testing Patterns
|
||||
@@ -619,7 +619,7 @@ yarn install:server
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- **Node.js**: >=22
|
||||
- **Node.js**: >=20
|
||||
- **Yarn**: ^4.9.1 (managed by packageManager field)
|
||||
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
|
||||
|
||||
@@ -632,40 +632,3 @@ The codebase is actively being migrated:
|
||||
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
|
||||
|
||||
When working with older code, follow the newer patterns described in this guide.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Services overview
|
||||
|
||||
| Service | Command | Port | Required |
|
||||
| ------------------- | ----------------------- | ---- | ----------------------------- |
|
||||
| Web Frontend (Vite) | `yarn start` | 3001 | Yes |
|
||||
| Sync Server | `yarn start:server-dev` | 5006 | Optional (sync features only) |
|
||||
|
||||
All storage is **SQLite** (file-based via `better-sqlite3`). No external databases or services are needed.
|
||||
|
||||
### Running the app
|
||||
|
||||
- `yarn start` builds the plugins-service worker, loot-core browser backend, and starts the Vite dev server on port **3001**.
|
||||
- `yarn start:server-dev` starts both the sync server (port 5006) and the web frontend together.
|
||||
- The Vite HMR dev server serves many unbundled modules. In constrained environments, the browser may hit `ERR_INSUFFICIENT_RESOURCES`. If that happens, use `yarn build:browser` followed by serving the built output from `packages/desktop-client/build/` with proper COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`).
|
||||
|
||||
### Lint, test, typecheck
|
||||
|
||||
Standard commands documented in `package.json` scripts and the Quick Start section above:
|
||||
|
||||
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
|
||||
- `yarn test` (lage across all workspaces)
|
||||
- `yarn typecheck` (tsc + lage typecheck)
|
||||
|
||||
### Testing and previewing the app
|
||||
|
||||
When running the app for manual testing or demos, use **"View demo"** on the initial setup screen (after selecting "Don't use a server"). This creates a test budget pre-populated with realistic sample data (accounts, transactions, categories, and budgeted amounts), which is far more useful than starting with an empty budget.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- The `engines` field requires **Node.js >=22** and **Yarn ^4.9.1**. The `.nvmrc` specifies `v22/*`.
|
||||
- Pre-commit hook runs `lint-staged` (oxfmt + oxlint) via Husky. Run `yarn prepare` once after install to set up hooks.
|
||||
- Lage caches test results in `.lage/`. If tests behave unexpectedly, clear with `rm -rf .lage`.
|
||||
- Native modules (`better-sqlite3`, `bcrypt`) require build tools (`gcc`, `make`, `python3`). These are pre-installed in the Cloud VM.
|
||||
- All yarn commands must be run from the repository root, never from child workspaces.
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
@@ -95,7 +95,7 @@
|
||||
"oxfmt --no-error-on-unmatched-pattern"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx}": [
|
||||
"oxlint --fix --type-aware --quiet"
|
||||
"oxlint --fix --type-aware"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:crdt": "yarn workspace @actual-app/crdt build",
|
||||
"build:node": "tsc && tsc-alias",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:migrations": "mkdir dist/migrations && cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
|
||||
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
|
||||
|
||||
@@ -9,13 +9,12 @@
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
},
|
||||
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
|
||||
},
|
||||
"include": ["."],
|
||||
"include": [".", "../../packages/loot-core/typings/pegjs.ts"],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
|
||||
1
packages/api/typings/pegjs.d.ts
vendored
1
packages/api/typings/pegjs.d.ts
vendored
@@ -1 +0,0 @@
|
||||
declare module '*.pegjs';
|
||||
@@ -1,16 +1,16 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
@@ -30,7 +30,9 @@ import { RulesList } from './rules/RulesList';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { usePayeeRules } from '@desktop-client/hooks/usePayeeRules';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import {
|
||||
SelectedProvider,
|
||||
@@ -38,6 +40,10 @@ import {
|
||||
} from '@desktop-client/hooks/useSelected';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import {
|
||||
useBatchDeleteRulesMutation,
|
||||
useDeleteRuleMutation,
|
||||
} from '@desktop-client/rules';
|
||||
|
||||
export type FilterData = {
|
||||
payees?: Array<{ id: string; name: string }>;
|
||||
@@ -115,17 +121,36 @@ export function ruleToString(rule: RuleEntity, data: FilterData) {
|
||||
type ManageRulesProps = {
|
||||
isModal: boolean;
|
||||
payeeId: string | null;
|
||||
setLoading?: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function ManageRules({
|
||||
isModal,
|
||||
payeeId,
|
||||
setLoading = () => {},
|
||||
}: ManageRulesProps) {
|
||||
export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const {
|
||||
data: allRules = [],
|
||||
refetch: refetchAllRules,
|
||||
isLoading: isAllRulesLoading,
|
||||
isRefetching: isAllRulesRefetching,
|
||||
} = useRules({
|
||||
enabled: !payeeId,
|
||||
});
|
||||
const {
|
||||
data: payeeRules = [],
|
||||
refetch: refetchPayeeRules,
|
||||
isLoading: isPayeeRulesLoading,
|
||||
isRefetching: isPayeeRulesRefetching,
|
||||
} = usePayeeRules({
|
||||
payeeId,
|
||||
});
|
||||
|
||||
const rulesToUse = payeeId ? payeeRules : allRules;
|
||||
const refetchRules = payeeId ? refetchPayeeRules : refetchAllRules;
|
||||
const isLoading =
|
||||
isAllRulesLoading ||
|
||||
isAllRulesRefetching ||
|
||||
isPayeeRulesLoading ||
|
||||
isPayeeRulesRefetching;
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
@@ -147,7 +172,7 @@ export function ManageRules({
|
||||
);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const rules = allRules.filter(rule => {
|
||||
const rules = rulesToUse.filter(rule => {
|
||||
const schedule = schedules.find(schedule => schedule.rule === rule.id);
|
||||
return schedule ? schedule.completed === false : true;
|
||||
});
|
||||
@@ -161,7 +186,7 @@ export function ManageRules({
|
||||
),
|
||||
)
|
||||
).slice(0, 100 + page * 50);
|
||||
}, [allRules, filter, filterData, page, schedules]);
|
||||
}, [rulesToUse, filter, filterData, page, schedules]);
|
||||
|
||||
const selectedInst = useSelected('manage-rules', filteredRules, []);
|
||||
const [hoveredRule, setHoveredRule] = useState(null);
|
||||
@@ -171,38 +196,16 @@ export function ManageRules({
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
async function loadRules() {
|
||||
setLoading(true);
|
||||
|
||||
let loadedRules = null;
|
||||
if (payeeId) {
|
||||
loadedRules = await send('payees-get-rules', {
|
||||
id: payeeId,
|
||||
});
|
||||
} else {
|
||||
loadedRules = await send('rules-get');
|
||||
}
|
||||
|
||||
setAllRules(loadedRules);
|
||||
return loadedRules;
|
||||
}
|
||||
|
||||
const init = useEffectEvent(() => {
|
||||
async function loadData() {
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (payeeId) {
|
||||
undo.setUndoState('openModal', { name: 'manage-rules', options: {} });
|
||||
}
|
||||
|
||||
void loadData();
|
||||
|
||||
return () => {
|
||||
undo.setUndoState('openModal', null);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return init();
|
||||
}, []);
|
||||
@@ -211,29 +214,33 @@ export function ManageRules({
|
||||
setPage(page => page + 1);
|
||||
}
|
||||
|
||||
const { mutate: batchDeleteRules } = useBatchDeleteRulesMutation();
|
||||
|
||||
const onDeleteSelected = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const { someDeletionsFailed } = await send('rule-delete-all', [
|
||||
...selectedInst.items,
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
alert(
|
||||
t('Some rules were not deleted because they are linked to schedules.'),
|
||||
);
|
||||
}
|
||||
|
||||
await loadRules();
|
||||
selectedInst.dispatch({ type: 'select-none' });
|
||||
setLoading(false);
|
||||
batchDeleteRules(
|
||||
{
|
||||
ids: [...selectedInst.items],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void refetchRules();
|
||||
selectedInst.dispatch({ type: 'select-none' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
async function onDeleteRule(id: string) {
|
||||
setLoading(true);
|
||||
await send('rule-delete', id);
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
function onDeleteRule(id: string) {
|
||||
deleteRule(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const onEditRule = rule => {
|
||||
@@ -244,8 +251,7 @@ export function ManageRules({
|
||||
options: {
|
||||
rule,
|
||||
onSave: async () => {
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -282,8 +288,7 @@ export function ManageRules({
|
||||
options: {
|
||||
rule,
|
||||
onSave: async () => {
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -295,6 +300,24 @@ export function ManageRules({
|
||||
setHoveredRule(id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading width={25} height={25} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isNonDeletableRuleSelected = schedules.some(schedule =>
|
||||
selectedInst.items.has(schedule.rule),
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
<View>
|
||||
@@ -361,11 +384,24 @@ export function ManageRules({
|
||||
>
|
||||
<SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}>
|
||||
{selectedInst.items.size > 0 && (
|
||||
<Button onPress={onDeleteSelected}>
|
||||
<Trans count={selectedInst.items.size}>
|
||||
Delete {{ count: selectedInst.items.size }} rules
|
||||
</Trans>
|
||||
</Button>
|
||||
<Tooltip
|
||||
isOpen={isNonDeletableRuleSelected}
|
||||
content={
|
||||
<Trans>
|
||||
Some selected rules cannot be deleted because they are
|
||||
linked to schedules.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onPress={onDeleteSelected}
|
||||
isDisabled={isNonDeletableRuleSelected}
|
||||
>
|
||||
<Trans count={selectedInst.items.size}>
|
||||
Delete {{ count: selectedInst.items.size }} rules
|
||||
</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="primary" onPress={onCreateRule}>
|
||||
<Trans>Create new rule</Trans>
|
||||
|
||||
@@ -83,6 +83,7 @@ import { pagedQuery } from '@desktop-client/queries/pagedQuery';
|
||||
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import type { AppDispatch } from '@desktop-client/redux/store';
|
||||
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
|
||||
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
|
||||
@@ -251,6 +252,7 @@ type AccountInternalProps = {
|
||||
onUnlinkAccount: (id: AccountEntity['id']) => void;
|
||||
onSyncAndDownload: (accountId?: AccountEntity['id']) => void;
|
||||
onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>;
|
||||
onRunRules: (transaction: TransactionEntity) => Promise<TransactionEntity>;
|
||||
};
|
||||
|
||||
type AccountInternalState = {
|
||||
@@ -691,9 +693,8 @@ class AccountInternal extends PureComponent<
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const res: TransactionEntity | null = await send('rules-run', {
|
||||
transaction,
|
||||
});
|
||||
const res: TransactionEntity | null =
|
||||
await this.props.onRunRules(transaction);
|
||||
if (res) {
|
||||
changedTransactions.push(...ungroupTransaction(res));
|
||||
|
||||
@@ -1055,10 +1056,9 @@ class AccountInternal extends PureComponent<
|
||||
});
|
||||
|
||||
// run rules on the reconciliation transaction
|
||||
const runRules = this.props.onRunRules;
|
||||
const ruledTransactions = await Promise.all(
|
||||
reconciliationTransactions.map(transaction =>
|
||||
send('rules-run', { transaction }),
|
||||
),
|
||||
reconciliationTransactions.map(transaction => runRules(transaction)),
|
||||
);
|
||||
|
||||
// sync the reconciliation transaction
|
||||
@@ -2017,9 +2017,13 @@ export function Account() {
|
||||
const onSyncAndDownload = (id?: AccountEntity['id']) =>
|
||||
syncAndDownload({ id });
|
||||
|
||||
const createPayee = useCreatePayeeMutation();
|
||||
const { mutateAsync: createPayeeAsync } = useCreatePayeeMutation();
|
||||
const onCreatePayee = (name: PayeeEntity['name']) =>
|
||||
createPayee.mutateAsync({ name });
|
||||
createPayeeAsync({ name });
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
const onRunRules = (transaction: TransactionEntity) =>
|
||||
runRulesAsync({ transaction });
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
@@ -2062,6 +2066,7 @@ export function Account() {
|
||||
onUnlinkAccount={onUnlinkAccount}
|
||||
onSyncAndDownload={onSyncAndDownload}
|
||||
onCreatePayee={onCreatePayee}
|
||||
onRunRules={onRunRules}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
getFieldError,
|
||||
getValidOps,
|
||||
mapField,
|
||||
unparse,
|
||||
unparseConditions,
|
||||
} from 'loot-core/shared/rules';
|
||||
import { titleFirst } from 'loot-core/shared/util';
|
||||
import type { IntegerAmount } from 'loot-core/shared/util';
|
||||
@@ -296,37 +296,39 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
field={field === 'date' || field === 'category' ? subfield : field}
|
||||
// @ts-expect-error - fix me
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ?? (op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
// @ts-expect-error - fix me
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type &&
|
||||
type !== 'boolean' &&
|
||||
(field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
field={
|
||||
field === 'date' || field === 'category' ? subfield : field
|
||||
}
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ??
|
||||
(op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field === 'payee' && isPayeeIdOp(op) && (
|
||||
<PayeeFilter
|
||||
@@ -424,7 +426,7 @@ export function FilterButton<T extends RuleConditionEntity>({
|
||||
|
||||
async function onValidateAndApply(cond: T) {
|
||||
// @ts-expect-error - fix me
|
||||
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
cond = unparseConditions({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
|
||||
if (cond.type === 'date' && cond.options) {
|
||||
if (cond.options.month) {
|
||||
@@ -614,7 +616,11 @@ export function FilterEditor<T extends RuleConditionEntity>({
|
||||
dispatch={dispatch}
|
||||
onApply={cond => {
|
||||
// @ts-expect-error - fix me
|
||||
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
cond = unparseConditions({
|
||||
...cond,
|
||||
// @ts-expect-error - fix me
|
||||
type: FIELD_TYPES.get(cond.field),
|
||||
});
|
||||
|
||||
if (cond.type === 'date' && cond.options) {
|
||||
if (
|
||||
|
||||
@@ -119,8 +119,6 @@ export function ActionableGridListItem<T extends object>({
|
||||
padding: 16,
|
||||
textAlign: 'left',
|
||||
borderRadius: 0,
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
onClick={handleAction}
|
||||
>
|
||||
|
||||
@@ -17,8 +17,8 @@ import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { useDeleteRuleMutation } from '@desktop-client/rules/mutations';
|
||||
|
||||
export function MobileRuleEditPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +107,8 @@ export function MobileRuleEditPage() {
|
||||
void navigate(-1);
|
||||
};
|
||||
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
const handleDelete = () => {
|
||||
// Runtime guard to ensure id exists
|
||||
if (!id || id === 'new') {
|
||||
@@ -120,23 +122,17 @@ export function MobileRuleEditPage() {
|
||||
options: {
|
||||
message: t('Are you sure you want to delete this rule?'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await send('rule-delete', id);
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
void navigate('/rules');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete rule. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
deleteRule(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
void navigate('/rules');
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { listen, send } from 'loot-core/platform/client/connection';
|
||||
import { listen } from 'loot-core/platform/client/connection';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
@@ -21,22 +21,24 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { useDeleteRuleMutation } from '@desktop-client/rules';
|
||||
|
||||
export function MobileRulesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { showUndoNotification } = useUndo();
|
||||
const [visibleRulesParam] = useUrlParam('visible-rules');
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const {
|
||||
data: allRules = [],
|
||||
isLoading: isRulesLoading,
|
||||
refetch: refetchRules,
|
||||
} = useRules();
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(() => q('schedules').select('*'), []),
|
||||
});
|
||||
@@ -79,28 +81,10 @@ export function MobileRulesPage() {
|
||||
);
|
||||
}, [visibleRules, filter, filterData, schedules]);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await send('rules-get');
|
||||
const rules = result || [];
|
||||
setAllRules(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to load rules:', error);
|
||||
setAllRules([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
// Listen for undo events to refresh rules list
|
||||
useEffect(() => {
|
||||
const onUndo = () => {
|
||||
void loadRules();
|
||||
void refetchRules();
|
||||
};
|
||||
|
||||
const lastUndoEvent = undo.getUndoState('undoEvent');
|
||||
@@ -109,7 +93,7 @@ export function MobileRulesPage() {
|
||||
}
|
||||
|
||||
return listen('undo-event', onUndo);
|
||||
}, [loadRules]);
|
||||
}, [refetchRules]);
|
||||
|
||||
const handleRulePress = useCallback(
|
||||
(rule: RuleEntity) => {
|
||||
@@ -125,45 +109,24 @@ export function MobileRulesPage() {
|
||||
[setFilter],
|
||||
);
|
||||
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
const handleRuleDelete = useCallback(
|
||||
async (rule: RuleEntity) => {
|
||||
try {
|
||||
const { someDeletionsFailed } = await send('rule-delete-all', [
|
||||
rule.id,
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'warning',
|
||||
message: t(
|
||||
'This rule could not be deleted because it is linked to a schedule.',
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the rules list
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete rule. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
(rule: RuleEntity) => {
|
||||
deleteRule(
|
||||
{ id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
// Refresh the rules list
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[dispatch, showUndoNotification, t, loadRules],
|
||||
[deleteRule, showUndoNotification, t, refetchRules],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -199,7 +162,7 @@ export function MobileRulesPage() {
|
||||
</View>
|
||||
<RulesList
|
||||
rules={filteredRules}
|
||||
isLoading={isLoading}
|
||||
isLoading={isRulesLoading}
|
||||
onRulePress={handleRulePress}
|
||||
onRuleDelete={handleRuleDelete}
|
||||
/>
|
||||
|
||||
@@ -52,10 +52,7 @@ export function RulesListItem({
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<SpaceBetween
|
||||
gap={12}
|
||||
style={{ alignItems: 'flex-start', width: '100%' }}
|
||||
>
|
||||
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
|
||||
{/* Column 1: PRE/POST pill */}
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -12,7 +12,11 @@ import { View } from '@actual-app/components/view';
|
||||
import { send, sendCatch } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type { RecurConfig, ScheduleEntity } from 'loot-core/types/models';
|
||||
import type {
|
||||
RecurConfig,
|
||||
RuleConditionEntity,
|
||||
ScheduleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
|
||||
@@ -72,7 +72,6 @@ import {
|
||||
} from '@desktop-client/components/mobile/MobileForms';
|
||||
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { createSingleTimeScheduleFromTransaction } from '@desktop-client/components/transactions/TransactionList';
|
||||
import { AmountInput } from '@desktop-client/components/util/AmountInput';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
@@ -90,6 +89,10 @@ import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import {
|
||||
useCreateSingleTimeScheduleFromTransaction,
|
||||
useRunRulesMutation,
|
||||
} from '@desktop-client/rules';
|
||||
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
function getFieldName(transactionId: TransactionEntity['id'], field: string) {
|
||||
@@ -671,6 +674,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
[categories, isBudgetTransfer, t],
|
||||
);
|
||||
|
||||
const { mutate: createSingleTimeScheduleFromTransaction } =
|
||||
useCreateSingleTimeScheduleFromTransaction();
|
||||
|
||||
const onSaveInner = useCallback(() => {
|
||||
const [unserializedTransaction] = unserializedTransactions;
|
||||
|
||||
@@ -729,19 +735,24 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
}
|
||||
: unserializedTransaction;
|
||||
|
||||
await createSingleTimeScheduleFromTransaction(
|
||||
transactionForSchedule,
|
||||
);
|
||||
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'message',
|
||||
message: t('Schedule created successfully'),
|
||||
createSingleTimeScheduleFromTransaction(
|
||||
{
|
||||
transaction: transactionForSchedule,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'message',
|
||||
message: t('Schedule created successfully'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
void navigate(-1);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
void navigate(-1);
|
||||
},
|
||||
onCancel: onConfirmSave,
|
||||
},
|
||||
@@ -778,6 +789,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
unserializedTransactions,
|
||||
upcomingLength,
|
||||
t,
|
||||
createSingleTimeScheduleFromTransaction,
|
||||
]);
|
||||
|
||||
const onUpdateInner = useCallback(
|
||||
@@ -1407,6 +1419,8 @@ function TransactionEditUnconnected({
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
|
||||
const onUpdate = useCallback(
|
||||
async (
|
||||
serializedTransaction: TransactionEntity,
|
||||
@@ -1422,9 +1436,7 @@ function TransactionEditUnconnected({
|
||||
// this on new transactions because that's how desktop works.
|
||||
const newTransaction = { ...transaction };
|
||||
if (isTemporary(newTransaction)) {
|
||||
const afterRules = await send('rules-run', {
|
||||
transaction: newTransaction,
|
||||
});
|
||||
const afterRules = await runRulesAsync({ transaction: newTransaction });
|
||||
const diff = getChangedValues(newTransaction, afterRules);
|
||||
|
||||
if (diff) {
|
||||
@@ -1464,7 +1476,7 @@ function TransactionEditUnconnected({
|
||||
);
|
||||
setTransactions(newTransactions);
|
||||
},
|
||||
[dateFormat, transactions],
|
||||
[dateFormat, transactions, runRulesAsync],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -17,17 +16,16 @@ type ManageRulesModalProps = Extract<
|
||||
|
||||
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<Modal name="manage-rules" isLoading={loading}>
|
||||
<Modal name="manage-rules">
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Rules')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<ManageRules isModal payeeId={payeeId} setLoading={setLoading} />
|
||||
<ManageRules isModal payeeId={payeeId} />
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { replaceModal } from '@desktop-client/modals/modalsSlice';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { useAddPayeeRenameRuleMutation } from '@desktop-client/rules';
|
||||
|
||||
const highlightStyle = { color: theme.pageTextPositive };
|
||||
|
||||
@@ -57,6 +58,9 @@ export function MergeUnusedPayeesModal({
|
||||
allPayees.filter(p => payeeIds.includes(p.id)),
|
||||
);
|
||||
|
||||
const { mutateAsync: addPayeeRenameRuleAsync } =
|
||||
useAddPayeeRenameRuleMutation();
|
||||
|
||||
const onMerge = useCallback(
|
||||
async (targetPayee: PayeeEntity) => {
|
||||
await send('payees-merge', {
|
||||
@@ -66,7 +70,7 @@ export function MergeUnusedPayeesModal({
|
||||
|
||||
let ruleId;
|
||||
if (shouldCreateRule && !isEditingRule) {
|
||||
const id = await send('rule-add-payee-rename', {
|
||||
const id = await addPayeeRenameRuleAsync({
|
||||
fromNames: payees.map(payee => payee.name),
|
||||
to: targetPayee.id,
|
||||
});
|
||||
@@ -75,7 +79,7 @@ export function MergeUnusedPayeesModal({
|
||||
|
||||
return ruleId;
|
||||
},
|
||||
[shouldCreateRule, isEditingRule, payees],
|
||||
[shouldCreateRule, isEditingRule, payees, addPayeeRenameRuleAsync],
|
||||
);
|
||||
|
||||
const onMergeAndCreateRule = useCallback(
|
||||
|
||||
@@ -37,8 +37,10 @@ import {
|
||||
isValidOp,
|
||||
makeValue,
|
||||
mapField,
|
||||
parse,
|
||||
unparse,
|
||||
parseActions,
|
||||
parseConditions,
|
||||
unparseActions,
|
||||
unparseConditions,
|
||||
} from 'loot-core/shared/rules';
|
||||
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
|
||||
import type {
|
||||
@@ -46,6 +48,7 @@ import type {
|
||||
RuleActionEntity,
|
||||
RuleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import type { WithOptional } from 'loot-core/types/util';
|
||||
|
||||
import { FormulaActionEditor } from './FormulaActionEditor';
|
||||
|
||||
@@ -63,9 +66,12 @@ import {
|
||||
SelectedProvider,
|
||||
useSelected,
|
||||
} from '@desktop-client/hooks/useSelected';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import {
|
||||
useApplyRuleActionsMutation,
|
||||
useSaveRuleMutation,
|
||||
} from '@desktop-client/rules';
|
||||
import { disableUndo, enableUndo } from '@desktop-client/undo';
|
||||
|
||||
function updateValue(array, value, update) {
|
||||
@@ -958,7 +964,7 @@ function ConditionsList({
|
||||
}
|
||||
|
||||
const getActions = splits => splits.flatMap(s => s.actions);
|
||||
const getUnparsedActions = splits => getActions(splits).map(unparse);
|
||||
const getUnparsedActions = splits => getActions(splits).map(unparseActions);
|
||||
|
||||
// TODO:
|
||||
// * Dont touch child transactions?
|
||||
@@ -996,19 +1002,27 @@ export function RuleEditor({
|
||||
}: RuleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [conditions, setConditions] = useState(
|
||||
defaultRule.conditions.map(parse).map(c => ({ ...c, inputKey: uuid() })),
|
||||
defaultRule.conditions
|
||||
.map(parseConditions)
|
||||
.map(c => ({ ...c, inputKey: uuid() })),
|
||||
);
|
||||
const [actionSplits, setActionSplits] = useState(() => {
|
||||
const parsedActions = defaultRule.actions.map(parse);
|
||||
const [actionSplits, setActionSplits] = useState<
|
||||
Array<{
|
||||
id: string;
|
||||
actions: Array<RuleActionEntity & { inputKey: string }>;
|
||||
}>
|
||||
>(() => {
|
||||
const parsedActions = defaultRule.actions.map(parseActions);
|
||||
return parsedActions.reduce(
|
||||
(acc, action) => {
|
||||
const splitIndex = action.options?.splitIndex ?? 0;
|
||||
const splitIndex =
|
||||
'options' in action ? (action.options?.splitIndex ?? 0) : 0;
|
||||
acc[splitIndex] = acc[splitIndex] ?? { id: uuid(), actions: [] };
|
||||
acc[splitIndex].actions.push({ ...action, inputKey: uuid() });
|
||||
return acc;
|
||||
},
|
||||
// The pre-split group is always there
|
||||
[{ id: uuid(), actions: [] }],
|
||||
[{ id: uuid(), actions: [] } as (typeof actionSplits)[0]],
|
||||
);
|
||||
});
|
||||
const [stage, setStage] = useState(defaultRule.stage);
|
||||
@@ -1039,7 +1053,7 @@ export function RuleEditor({
|
||||
// Run it here
|
||||
async function run() {
|
||||
const { filters } = await send('make-filters-from-conditions', {
|
||||
conditions: conditions.map(unparse),
|
||||
conditions: conditions.map(unparseConditions),
|
||||
});
|
||||
|
||||
if (filters.length > 0) {
|
||||
@@ -1211,74 +1225,67 @@ export function RuleEditor({
|
||||
});
|
||||
}
|
||||
|
||||
const { mutate: applyRuleActions } = useApplyRuleActionsMutation();
|
||||
|
||||
function onApply() {
|
||||
const selectedTransactions = transactions.filter(({ id }) =>
|
||||
selectedInst.items.has(id),
|
||||
);
|
||||
void send('rule-apply-actions', {
|
||||
transactions: selectedTransactions,
|
||||
actions: getUnparsedActions(actionSplits),
|
||||
}).then(content => {
|
||||
// This makes it refetch the transactions
|
||||
content.errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
setActionSplits([...actionSplits]);
|
||||
});
|
||||
applyRuleActions(
|
||||
{
|
||||
transactions: selectedTransactions,
|
||||
ruleActions: getUnparsedActions(actionSplits),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setActionSplits([...actionSplits]);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { mutate: saveRule } = useSaveRuleMutation();
|
||||
|
||||
async function onSave() {
|
||||
const rule = {
|
||||
const rule: WithOptional<RuleEntity, 'id'> = {
|
||||
...defaultRule,
|
||||
stage,
|
||||
conditionsOp,
|
||||
conditions: conditions.map(unparse),
|
||||
conditions: conditions.map(unparseConditions),
|
||||
actions: getUnparsedActions(actionSplits),
|
||||
};
|
||||
|
||||
// @ts-expect-error fix this
|
||||
const method = rule.id ? 'rule-update' : 'rule-add';
|
||||
// @ts-expect-error fix this
|
||||
const { error, id: newId } = await send(method, rule);
|
||||
saveRule(
|
||||
{
|
||||
rule,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ id }) => {
|
||||
originalOnSave?.({
|
||||
id,
|
||||
...rule,
|
||||
});
|
||||
},
|
||||
onError: error => {
|
||||
if ('conditionErrors' in error && error.conditionErrors) {
|
||||
setConditions(applyErrors(conditions, error.conditionErrors));
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// @ts-expect-error fix this
|
||||
if (error.conditionErrors) {
|
||||
// @ts-expect-error fix this
|
||||
setConditions(applyErrors(conditions, error.conditionErrors));
|
||||
}
|
||||
|
||||
// @ts-expect-error fix this
|
||||
if (error.actionErrors) {
|
||||
let usedErrorIdx = 0;
|
||||
setActionSplits(
|
||||
actionSplits.map(item => ({
|
||||
...item,
|
||||
actions: item.actions.map(action => ({
|
||||
...action,
|
||||
// @ts-expect-error fix this
|
||||
error: error.actionErrors[usedErrorIdx++] ?? null,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If adding a rule, we got back an id
|
||||
if (newId) {
|
||||
// @ts-expect-error fix this
|
||||
rule.id = newId;
|
||||
}
|
||||
|
||||
// @ts-expect-error fix this
|
||||
originalOnSave?.(rule);
|
||||
}
|
||||
if ('actionErrors' in error && error.actionErrors) {
|
||||
let usedErrorIdx = 0;
|
||||
setActionSplits(
|
||||
actionSplits.map(item => ({
|
||||
...item,
|
||||
actions: item.actions.map(action => ({
|
||||
...action,
|
||||
error: error.actionErrors[usedErrorIdx++] ?? null,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Enable editing existing split rules even if the feature has since been disabled.
|
||||
|
||||
@@ -14,7 +14,7 @@ import { usePayeesById } from '@desktop-client/hooks/usePayees';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
|
||||
type ScheduleValueProps = {
|
||||
value: ScheduleEntity;
|
||||
value: ScheduleEntity['id'];
|
||||
};
|
||||
|
||||
export function ScheduleValue({ value }: ScheduleValueProps) {
|
||||
@@ -35,12 +35,13 @@ export function ScheduleValue({ value }: ScheduleValueProps) {
|
||||
<Value
|
||||
value={value}
|
||||
field="rule"
|
||||
data={schedules}
|
||||
// TODO: this manual type coercion does not make much sense -
|
||||
// should we instead do `schedule._payee.id`?
|
||||
describe={schedule =>
|
||||
describeSchedule(schedule, byId[schedule._payee as unknown as string])
|
||||
}
|
||||
describe={val => {
|
||||
const schedule = schedules.find(s => s.id === val);
|
||||
if (!schedule) {
|
||||
return t('(deleted)');
|
||||
}
|
||||
return describeSchedule(schedule, byId[schedule._payee]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ type ValueProps<T> = {
|
||||
field: unknown;
|
||||
valueIsRaw?: boolean;
|
||||
inline?: boolean;
|
||||
data?: unknown;
|
||||
describe?: (item: T) => string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
@@ -34,9 +33,7 @@ export function Value<T>({
|
||||
field,
|
||||
valueIsRaw,
|
||||
inline = false,
|
||||
data: dataProp,
|
||||
// @ts-expect-error fix this later
|
||||
describe = x => x.name,
|
||||
describe,
|
||||
style,
|
||||
}: ValueProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
@@ -56,32 +53,6 @@ export function Value<T>({
|
||||
};
|
||||
const ValueText = field === 'amount' ? FinancialText : Text;
|
||||
const locale = useLocale();
|
||||
|
||||
function getData() {
|
||||
if (dataProp) {
|
||||
return dataProp;
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case 'payee':
|
||||
return payees;
|
||||
|
||||
case 'category':
|
||||
return categories;
|
||||
|
||||
case 'category_group':
|
||||
return categoryGroups;
|
||||
|
||||
case 'account':
|
||||
return accounts;
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const data = getData();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
function onExpand(e) {
|
||||
@@ -119,23 +90,39 @@ export function Value<T>({
|
||||
case 'payee_name':
|
||||
return value;
|
||||
case 'payee':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const payee = payees.find(p => p.id === value);
|
||||
return payee ? (describe?.(value) ?? payee.name) : t('(deleted)');
|
||||
case 'category':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const category = categories.find(c => c.id === value);
|
||||
return category
|
||||
? (describe?.(value) ?? category.name)
|
||||
: t('(deleted)');
|
||||
case 'category_group':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const categoryGroup = categoryGroups.find(g => g.id === value);
|
||||
return categoryGroup
|
||||
? (describe?.(value) ?? categoryGroup.name)
|
||||
: t('(deleted)');
|
||||
case 'account':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const account = accounts.find(a => a.id === value);
|
||||
return account ? (describe?.(value) ?? account.name) : t('(deleted)');
|
||||
case 'rule':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
if (data && Array.isArray(data)) {
|
||||
const item = data.find(item => item.id === value);
|
||||
if (item) {
|
||||
return describe(item);
|
||||
} else {
|
||||
return t('(deleted)');
|
||||
}
|
||||
}
|
||||
|
||||
return '…';
|
||||
return describe?.(value) ?? value;
|
||||
default:
|
||||
throw new Error(`Unknown field ${field}`);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,6 @@ export function DiscoverSchedules() {
|
||||
for (const schedule of selected) {
|
||||
const scheduleId = await send('schedule/create', {
|
||||
conditions: schedule._conditions,
|
||||
schedule: {},
|
||||
});
|
||||
|
||||
// Now query for matching transactions and link them automatically
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { extractScheduleConds } from 'loot-core/shared/schedules';
|
||||
import type { RuleConditionOp, ScheduleEntity } from 'loot-core/types/models';
|
||||
import type {
|
||||
RuleConditionEntity,
|
||||
RuleConditionOp,
|
||||
ScheduleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import type { ScheduleFormFields } from './ScheduleEditForm';
|
||||
|
||||
export function updateScheduleConditions(
|
||||
schedule: Partial<ScheduleEntity>,
|
||||
fields: ScheduleFormFields,
|
||||
): { error?: string; conditions?: unknown[] } {
|
||||
): { error?: string; conditions?: RuleConditionEntity[] } {
|
||||
const conds = extractScheduleConds(schedule._conditions);
|
||||
|
||||
const updateCond = (
|
||||
|
||||
@@ -62,6 +62,7 @@ export function CurrencySettings() {
|
||||
['UAH', t('Ukrainian Hryvnia')],
|
||||
['USD', t('US Dollar')],
|
||||
['UZS', t('Uzbek Soum')],
|
||||
['VND', t('Vietnamese Dong')],
|
||||
]),
|
||||
[t],
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { theme } from '@actual-app/components/theme';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { getUpcomingDays } from 'loot-core/shared/schedules';
|
||||
import {
|
||||
addSplitTransaction,
|
||||
@@ -22,7 +21,6 @@ import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
PayeeEntity,
|
||||
RuleActionEntity,
|
||||
RuleConditionEntity,
|
||||
ScheduleEntity,
|
||||
TransactionEntity,
|
||||
@@ -38,6 +36,10 @@ import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import {
|
||||
useCreateSingleTimeScheduleFromTransaction,
|
||||
useRunRulesMutation,
|
||||
} from '@desktop-client/rules';
|
||||
|
||||
// When data changes, there are two ways to update the UI:
|
||||
//
|
||||
@@ -82,133 +84,6 @@ async function saveDiffAndApply(diff, changes, onChange, learnCategories) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSingleTimeScheduleFromTransaction(
|
||||
transaction: TransactionEntity,
|
||||
): Promise<ScheduleEntity['id']> {
|
||||
const conditions: RuleConditionEntity[] = [
|
||||
{ op: 'is', field: 'date', value: transaction.date },
|
||||
];
|
||||
|
||||
const actions: RuleActionEntity[] = [];
|
||||
|
||||
const conditionFields = ['amount', 'payee', 'account'];
|
||||
|
||||
conditionFields.forEach(field => {
|
||||
const value = transaction[field];
|
||||
if (value != null && value !== '') {
|
||||
conditions.push({
|
||||
op: 'is',
|
||||
field,
|
||||
value,
|
||||
} as RuleConditionEntity);
|
||||
}
|
||||
});
|
||||
|
||||
if (transaction.is_parent && transaction.subtransactions) {
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
options: {
|
||||
splitIndex: 0,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
transaction.subtransactions.forEach((split, index) => {
|
||||
const splitIndex = index + 1;
|
||||
|
||||
if (split.amount != null) {
|
||||
actions.push({
|
||||
op: 'set-split-amount',
|
||||
value: split.amount,
|
||||
options: {
|
||||
splitIndex,
|
||||
method: 'fixed-amount',
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: split.category,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: split.notes,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (transaction.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: transaction.category,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
|
||||
const timestamp = Date.now();
|
||||
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
|
||||
|
||||
const scheduleId = await send('schedule/create', {
|
||||
conditions,
|
||||
schedule: {
|
||||
posts_transaction: true,
|
||||
name: scheduleName,
|
||||
},
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
const schedules = await send(
|
||||
'query',
|
||||
q('schedules').filter({ id: scheduleId }).select('rule').serialize(),
|
||||
);
|
||||
|
||||
const ruleId = schedules?.data?.[0]?.rule;
|
||||
|
||||
if (ruleId) {
|
||||
const rule = await send('rule-get', { id: ruleId });
|
||||
|
||||
if (rule) {
|
||||
const linkScheduleActions = rule.actions.filter(
|
||||
a => a.op === 'link-schedule',
|
||||
);
|
||||
|
||||
await send('rule-update', {
|
||||
...rule,
|
||||
actions: [...linkScheduleActions, ...actions],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
function isFutureTransaction(transaction: TransactionEntity): boolean {
|
||||
const today = monthUtils.currentDay();
|
||||
return transaction.date > today;
|
||||
@@ -375,6 +250,9 @@ export function TransactionList({
|
||||
[dispatch, onRefetch, upcomingLength, t],
|
||||
);
|
||||
|
||||
const { mutateAsync: createSingleTimeScheduleFromTransactionAsync } =
|
||||
useCreateSingleTimeScheduleFromTransaction();
|
||||
|
||||
const onAdd = useCallback(
|
||||
async (newTransactions: TransactionEntity[]) => {
|
||||
newTransactions = realizeTempTransactions(newTransactions);
|
||||
@@ -397,9 +275,9 @@ export function TransactionList({
|
||||
promptToConvertToSchedule(
|
||||
transactionWithSubtransactions,
|
||||
async () => {
|
||||
await createSingleTimeScheduleFromTransaction(
|
||||
transactionWithSubtransactions,
|
||||
);
|
||||
await createSingleTimeScheduleFromTransactionAsync({
|
||||
transaction: transactionWithSubtransactions,
|
||||
});
|
||||
},
|
||||
async () => {
|
||||
await saveDiff(
|
||||
@@ -414,7 +292,12 @@ export function TransactionList({
|
||||
await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled);
|
||||
onRefetch();
|
||||
},
|
||||
[isLearnCategoriesEnabled, onRefetch, promptToConvertToSchedule],
|
||||
[
|
||||
isLearnCategoriesEnabled,
|
||||
onRefetch,
|
||||
promptToConvertToSchedule,
|
||||
createSingleTimeScheduleFromTransactionAsync,
|
||||
],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
@@ -460,7 +343,9 @@ export function TransactionList({
|
||||
await send('transaction-delete', { id: transaction.id });
|
||||
}
|
||||
|
||||
await createSingleTimeScheduleFromTransaction(transaction);
|
||||
await createSingleTimeScheduleFromTransactionAsync({
|
||||
transaction,
|
||||
});
|
||||
},
|
||||
saveTransaction,
|
||||
);
|
||||
@@ -470,7 +355,13 @@ export function TransactionList({
|
||||
|
||||
await saveTransaction();
|
||||
},
|
||||
[isLearnCategoriesEnabled, onChange, onRefetch, promptToConvertToSchedule],
|
||||
[
|
||||
isLearnCategoriesEnabled,
|
||||
onChange,
|
||||
onRefetch,
|
||||
promptToConvertToSchedule,
|
||||
createSingleTimeScheduleFromTransactionAsync,
|
||||
],
|
||||
);
|
||||
|
||||
const onAddSplit = useCallback(
|
||||
@@ -503,12 +394,14 @@ export function TransactionList({
|
||||
[isLearnCategoriesEnabled, onChange],
|
||||
);
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
|
||||
const onApplyRules = useCallback(
|
||||
async (
|
||||
transaction: TransactionEntity,
|
||||
updatedFieldName: string | null = null,
|
||||
) => {
|
||||
const afterRules = await send('rules-run', { transaction });
|
||||
const afterRules = await runRulesAsync({ transaction });
|
||||
|
||||
// Show formula errors if any
|
||||
if (afterRules._ruleErrors && afterRules._ruleErrors.length > 0) {
|
||||
@@ -556,7 +449,7 @@ export function TransactionList({
|
||||
}
|
||||
return newTransaction;
|
||||
},
|
||||
[dispatch],
|
||||
[dispatch, runRulesAsync],
|
||||
);
|
||||
|
||||
const onManagePayees = useCallback(
|
||||
|
||||
13
packages/desktop-client/src/hooks/usePayeeRules.ts
Normal file
13
packages/desktop-client/src/hooks/usePayeeRules.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import { ruleQueries } from '@desktop-client/rules/queries';
|
||||
|
||||
export function usePayeeRules({
|
||||
payeeId,
|
||||
}: {
|
||||
payeeId?: PayeeEntity['id'] | null;
|
||||
}) {
|
||||
return useQuery(ruleQueries.listPayee({ payeeId }));
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules';
|
||||
import { ungroupTransactions } from 'loot-core/shared/transactions';
|
||||
import type { IntegerAmount } from 'loot-core/shared/util';
|
||||
@@ -10,6 +9,8 @@ import { useCachedSchedules } from './useCachedSchedules';
|
||||
import { useSyncedPref } from './useSyncedPref';
|
||||
import { calculateRunningBalancesBottomUp } from './useTransactions';
|
||||
|
||||
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
|
||||
|
||||
type UsePreviewTransactionsProps = {
|
||||
filter?: (schedule: ScheduleEntity) => boolean;
|
||||
options?: {
|
||||
@@ -72,6 +73,8 @@ export function usePreviewTransactions({
|
||||
);
|
||||
}, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]);
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
|
||||
@@ -88,7 +91,7 @@ export function usePreviewTransactions({
|
||||
Promise.all(
|
||||
scheduleTransactions.map(transaction =>
|
||||
// Kick off an async rules application
|
||||
send('rules-run', { transaction }),
|
||||
runRulesAsync({ transaction }),
|
||||
),
|
||||
)
|
||||
.then(newTrans => {
|
||||
@@ -137,7 +140,13 @@ export function usePreviewTransactions({
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
};
|
||||
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
|
||||
}, [
|
||||
scheduleTransactions,
|
||||
schedules,
|
||||
statuses,
|
||||
upcomingLength,
|
||||
runRulesAsync,
|
||||
]);
|
||||
|
||||
const returnError = error || scheduleQueryError;
|
||||
return {
|
||||
|
||||
15
packages/desktop-client/src/hooks/useRules.ts
Normal file
15
packages/desktop-client/src/hooks/useRules.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
import type { RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
import { ruleQueries } from '@desktop-client/rules/queries';
|
||||
|
||||
type UseRulesOptions = Pick<UseQueryOptions<RuleEntity[]>, 'enabled'>;
|
||||
|
||||
export function useRules(options?: UseRulesOptions) {
|
||||
return useQuery({
|
||||
...ruleQueries.list(),
|
||||
...(options ?? {}),
|
||||
});
|
||||
}
|
||||
2
packages/desktop-client/src/rules/index.ts
Normal file
2
packages/desktop-client/src/rules/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './queries';
|
||||
export * from './mutations';
|
||||
413
packages/desktop-client/src/rules/mutations.ts
Normal file
413
packages/desktop-client/src/rules/mutations.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type {
|
||||
NewRuleEntity,
|
||||
PayeeEntity,
|
||||
RuleActionEntity,
|
||||
RuleConditionEntity,
|
||||
RuleEntity,
|
||||
ScheduleEntity,
|
||||
TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { ruleQueries } from './queries';
|
||||
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import type { AppDispatch } from '@desktop-client/redux/store';
|
||||
|
||||
function invalidateQueries(queryClient: QueryClient, queryKey?: QueryKey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKey ?? ruleQueries.lists(),
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchErrorNotification(
|
||||
dispatch: AppDispatch,
|
||||
message: string,
|
||||
error?: Error,
|
||||
) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
pre: error?.cause ? JSON.stringify(error.cause) : error?.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type AddRulePayload = {
|
||||
rule: Omit<RuleEntity, 'id'>;
|
||||
};
|
||||
|
||||
export function useAddRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ rule }: AddRulePayload) => {
|
||||
return await send('rule-add', rule);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error creating rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error creating the rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateRulePayload = {
|
||||
rule: RuleEntity;
|
||||
};
|
||||
|
||||
export function useUpdateRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ rule }: UpdateRulePayload) => {
|
||||
return await send('rule-update', rule);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error updating rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error updating the rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SaveRulePayload = {
|
||||
rule: RuleEntity | NewRuleEntity;
|
||||
};
|
||||
|
||||
export function useSaveRuleMutation() {
|
||||
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
|
||||
const { mutateAsync: addRuleAsync } = useAddRuleMutation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ rule }: SaveRulePayload) => {
|
||||
if ('id' in rule && rule.id) {
|
||||
return await updateRuleAsync({ rule });
|
||||
} else {
|
||||
return await addRuleAsync({ rule });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type DeleteRulePayload = {
|
||||
id: RuleEntity['id'];
|
||||
};
|
||||
|
||||
export function useDeleteRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: DeleteRulePayload) => {
|
||||
return await send('rule-delete', id);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error deleting rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error deleting the rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type DeleteAllRulesPayload = {
|
||||
ids: Array<RuleEntity['id']>;
|
||||
};
|
||||
|
||||
export function useBatchDeleteRulesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ ids }: DeleteAllRulesPayload) => {
|
||||
return await send('rule-delete-all', ids);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error deleting rules:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error deleting rules. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ApplyRuleActionsPayload = {
|
||||
transactions: TransactionEntity[];
|
||||
ruleActions: RuleActionEntity[];
|
||||
};
|
||||
|
||||
export function useApplyRuleActionsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
transactions,
|
||||
ruleActions,
|
||||
}: ApplyRuleActionsPayload) => {
|
||||
const result = await send('rule-apply-actions', {
|
||||
transactions,
|
||||
actions: ruleActions,
|
||||
});
|
||||
if (result && result.errors && result.errors.length > 0) {
|
||||
throw new Error('Error applying rule actions.', {
|
||||
cause: result.errors,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error applying rule actions:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error applying the rule actions. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type AddPayeeRenameRulePayload = {
|
||||
fromNames: Array<PayeeEntity['name']>;
|
||||
to: PayeeEntity['id'];
|
||||
};
|
||||
|
||||
export function useAddPayeeRenameRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ fromNames, to }: AddPayeeRenameRulePayload) => {
|
||||
return await send('rule-add-payee-rename', {
|
||||
fromNames,
|
||||
to,
|
||||
});
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error adding payee rename rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error adding the payee rename rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type RunRulesPayload = {
|
||||
transaction: TransactionEntity;
|
||||
};
|
||||
|
||||
export function useRunRulesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ transaction }: RunRulesPayload) => {
|
||||
return await send('rules-run', { transaction });
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error running rules for transaction:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error running the rules for transaction. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Move to schedules mutations file once we have schedule-related mutations
|
||||
export function useCreateSingleTimeScheduleFromTransaction() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { data: allRules = [] } = useRules();
|
||||
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
transaction,
|
||||
}: {
|
||||
transaction: TransactionEntity;
|
||||
}): Promise<ScheduleEntity['id']> => {
|
||||
const conditions: RuleConditionEntity[] = [
|
||||
{ op: 'is', field: 'date', value: transaction.date },
|
||||
];
|
||||
|
||||
const actions: RuleActionEntity[] = [];
|
||||
|
||||
const conditionFields = ['amount', 'payee', 'account'] as const;
|
||||
|
||||
conditionFields.forEach(field => {
|
||||
const value = transaction[field];
|
||||
if (value != null && value !== '') {
|
||||
conditions.push({
|
||||
op: 'is',
|
||||
field,
|
||||
value,
|
||||
} as RuleConditionEntity);
|
||||
}
|
||||
});
|
||||
|
||||
if (transaction.is_parent && transaction.subtransactions) {
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
options: {
|
||||
splitIndex: 0,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
transaction.subtransactions.forEach((split, index) => {
|
||||
const splitIndex = index + 1;
|
||||
|
||||
if (split.amount != null) {
|
||||
actions.push({
|
||||
op: 'set-split-amount',
|
||||
value: split.amount,
|
||||
options: {
|
||||
splitIndex,
|
||||
method: 'fixed-amount',
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: split.category,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: split.notes,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (transaction.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: transaction.category,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
|
||||
const timestamp = Date.now();
|
||||
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
|
||||
|
||||
const scheduleId = await send('schedule/create', {
|
||||
conditions,
|
||||
schedule: {
|
||||
posts_transaction: true,
|
||||
name: scheduleName,
|
||||
},
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
const schedules = await aqlQuery(
|
||||
q('schedules').filter({ id: scheduleId }).select('rule'),
|
||||
);
|
||||
|
||||
const ruleId = schedules?.data?.[0]?.rule;
|
||||
|
||||
if (ruleId) {
|
||||
const rule = allRules.find(r => r.id === ruleId);
|
||||
|
||||
if (rule) {
|
||||
const linkScheduleActions = rule.actions.filter(
|
||||
a => a.op === 'link-schedule',
|
||||
);
|
||||
|
||||
await updateRuleAsync({
|
||||
rule: {
|
||||
...rule,
|
||||
actions: [...linkScheduleActions, ...actions],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error creating schedule from transaction:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error creating the schedule from the transaction. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
31
packages/desktop-client/src/rules/queries.ts
Normal file
31
packages/desktop-client/src/rules/queries.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import type { PayeeEntity, RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
export const ruleQueries = {
|
||||
all: () => ['rules'] as const,
|
||||
lists: () => [...ruleQueries.all(), 'lists'] as const,
|
||||
list: () =>
|
||||
queryOptions<RuleEntity[]>({
|
||||
queryKey: [...ruleQueries.lists()],
|
||||
queryFn: async () => {
|
||||
return await send('rules-get');
|
||||
},
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
listPayee: ({ payeeId }: { payeeId?: PayeeEntity['id'] | null }) =>
|
||||
queryOptions<RuleEntity[]>({
|
||||
queryKey: [...ruleQueries.lists(), { payeeId }] as const,
|
||||
queryFn: async () => {
|
||||
if (!payeeId) {
|
||||
// Should never happen since the query is disabled when payeeId is not provided,
|
||||
// but is needed to satisfy TypeScript.
|
||||
throw new Error('payeeId is required.');
|
||||
}
|
||||
return await send('payees-get-rules', { id: payeeId });
|
||||
},
|
||||
staleTime: Infinity,
|
||||
enabled: !!payeeId,
|
||||
}),
|
||||
};
|
||||
@@ -74,7 +74,7 @@ export const menuKeybindingText = colorPalette.purple200;
|
||||
export const menuAutoCompleteBackground = colorPalette.gray600;
|
||||
export const menuAutoCompleteBackgroundHover = colorPalette.gray500;
|
||||
export const menuAutoCompleteText = colorPalette.gray100;
|
||||
export const menuAutoCompleteTextHover = colorPalette.green400;
|
||||
export const menuAutoCompleteTextHover = colorPalette.green900;
|
||||
export const menuAutoCompleteTextHeader = colorPalette.purple200;
|
||||
export const menuAutoCompleteItemTextHover = colorPalette.gray50;
|
||||
export const menuAutoCompleteItemText = menuItemText;
|
||||
|
||||
@@ -778,24 +778,20 @@ handlers['api/payee-rules-get'] = async function ({ id }) {
|
||||
|
||||
handlers['api/rule-create'] = withMutation(async function ({ rule }) {
|
||||
checkFileOpen();
|
||||
const addedRule = await handlers['rule-add'](rule);
|
||||
|
||||
if ('error' in addedRule) {
|
||||
throw APIError('Failed creating a new rule', addedRule.error);
|
||||
try {
|
||||
return await handlers['rule-add'](rule);
|
||||
} catch (error) {
|
||||
throw APIError('Failed creating a new rule', error);
|
||||
}
|
||||
|
||||
return addedRule;
|
||||
});
|
||||
|
||||
handlers['api/rule-update'] = withMutation(async function ({ rule }) {
|
||||
checkFileOpen();
|
||||
const updatedRule = await handlers['rule-update'](rule);
|
||||
|
||||
if ('error' in updatedRule) {
|
||||
throw APIError('Failed updating the rule', updatedRule.error);
|
||||
try {
|
||||
return await handlers['rule-update'](rule);
|
||||
} catch (error) {
|
||||
throw APIError('Failed updating the rule', error);
|
||||
}
|
||||
|
||||
return updatedRule;
|
||||
});
|
||||
|
||||
handlers['api/rule-delete'] = withMutation(async function (id) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { logger } from '../../platform/server/log';
|
||||
import type {
|
||||
PayeeEntity,
|
||||
RuleActionEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
@@ -76,9 +77,9 @@ export type RulesHandlers = {
|
||||
'rule-delete-all': typeof deleteAllRules;
|
||||
'rule-apply-actions': typeof applyRuleActions;
|
||||
'rule-add-payee-rename': typeof addRulePayeeRename;
|
||||
'rules-run': typeof runRules;
|
||||
'rules-get': typeof getRules;
|
||||
'rule-get': typeof getRule;
|
||||
'rules-run': typeof runRules;
|
||||
};
|
||||
|
||||
// Expose functions to the client
|
||||
@@ -91,9 +92,9 @@ app.method('rule-delete', mutator(undoable(deleteRule)));
|
||||
app.method('rule-delete-all', mutator(undoable(deleteAllRules)));
|
||||
app.method('rule-apply-actions', mutator(undoable(applyRuleActions)));
|
||||
app.method('rule-add-payee-rename', mutator(addRulePayeeRename));
|
||||
app.method('rules-run', mutator(runRules));
|
||||
app.method('rules-get', getRules);
|
||||
app.method('rule-get', getRule);
|
||||
app.method('rules-run', runRules);
|
||||
|
||||
async function ruleValidate(
|
||||
rule: Partial<RuleEntity>,
|
||||
@@ -102,24 +103,20 @@ async function ruleValidate(
|
||||
return { error };
|
||||
}
|
||||
|
||||
async function addRule(
|
||||
rule: Omit<RuleEntity, 'id'>,
|
||||
): Promise<{ error: ValidationError } | RuleEntity> {
|
||||
async function addRule(rule: Omit<RuleEntity, 'id'>): Promise<RuleEntity> {
|
||||
const error = validateRule(rule);
|
||||
if (error) {
|
||||
return { error };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const id = await rules.insertRule(rule);
|
||||
return { id, ...rule };
|
||||
}
|
||||
|
||||
async function updateRule(
|
||||
rule: RuleEntity,
|
||||
): Promise<{ error: ValidationError } | RuleEntity> {
|
||||
async function updateRule(rule: RuleEntity): Promise<RuleEntity> {
|
||||
const error = validateRule(rule);
|
||||
if (error) {
|
||||
return { error };
|
||||
throw error;
|
||||
}
|
||||
|
||||
await rules.updateRule(rule);
|
||||
@@ -127,24 +124,32 @@ async function updateRule(
|
||||
}
|
||||
|
||||
async function deleteRule(id: RuleEntity['id']) {
|
||||
return rules.deleteRule(id);
|
||||
const isSuccess = await rules.deleteRule(id);
|
||||
if (!isSuccess) {
|
||||
throw new Error(
|
||||
'Error deleting rule. The rule may be linked to a schedule which prevents it from being deleted.',
|
||||
);
|
||||
}
|
||||
return isSuccess;
|
||||
}
|
||||
|
||||
async function deleteAllRules(
|
||||
ids: Array<RuleEntity['id']>,
|
||||
): Promise<{ someDeletionsFailed: boolean }> {
|
||||
let someDeletionsFailed = false;
|
||||
async function deleteAllRules(ids: Array<RuleEntity['id']>): Promise<void> {
|
||||
const failedIds: Array<RuleEntity['id']> = [];
|
||||
|
||||
await batchMessages(async () => {
|
||||
for (const id of ids) {
|
||||
const res = await rules.deleteRule(id);
|
||||
if (res === false) {
|
||||
someDeletionsFailed = true;
|
||||
const isSuccess = await rules.deleteRule(id);
|
||||
if (!isSuccess) {
|
||||
failedIds.push(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { someDeletionsFailed };
|
||||
if (failedIds.length > 0) {
|
||||
throw new Error(
|
||||
`Error deleting ${failedIds.length} rules. These rules may be linked to schedules which prevents them from being deleted.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRuleActions({
|
||||
@@ -165,8 +170,8 @@ async function addRulePayeeRename({
|
||||
fromNames,
|
||||
to,
|
||||
}: {
|
||||
fromNames: string[];
|
||||
to: string;
|
||||
fromNames: Array<PayeeEntity['name']>;
|
||||
to: PayeeEntity['id'];
|
||||
}): Promise<string> {
|
||||
return rules.updatePayeeRenameRule(fromNames, to);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import * as d from 'date-fns';
|
||||
import deepEqual from 'deep-equal';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { WithRequired } from 'loot-core/types/util';
|
||||
|
||||
import { captureBreadcrumb } from '../../platform/exceptions';
|
||||
import * as connection from '../../platform/server/connection';
|
||||
import { logger } from '../../platform/server/log';
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
getStatus,
|
||||
recurConfigToRSchedule,
|
||||
} from '../../shared/schedules';
|
||||
import type { ScheduleEntity } from '../../types/models';
|
||||
import type { RuleConditionEntity, ScheduleEntity } from '../../types/models';
|
||||
import { addTransactions } from '../accounts/sync';
|
||||
import { createApp } from '../app';
|
||||
import { aqlQuery } from '../aql';
|
||||
@@ -184,10 +186,13 @@ async function checkIfScheduleExists(name, scheduleId) {
|
||||
}
|
||||
|
||||
export async function createSchedule({
|
||||
schedule = null,
|
||||
schedule = {},
|
||||
conditions = [],
|
||||
} = {}): Promise<ScheduleEntity['id']> {
|
||||
const scheduleId = schedule?.id || uuidv4();
|
||||
}: {
|
||||
schedule?: Partial<Omit<ScheduleEntity, 'id'>>;
|
||||
conditions?: RuleConditionEntity[];
|
||||
}): Promise<ScheduleEntity['id']> {
|
||||
const scheduleId = uuidv4();
|
||||
|
||||
const { date: dateCond } = extractScheduleConds(conditions);
|
||||
if (dateCond == null) {
|
||||
@@ -199,14 +204,12 @@ export async function createSchedule({
|
||||
|
||||
const nextDate = getNextDate(dateCond);
|
||||
const nextDateRepr = nextDate ? toDateRepr(nextDate) : null;
|
||||
if (schedule) {
|
||||
if (schedule.name) {
|
||||
if (await checkIfScheduleExists(schedule.name, scheduleId)) {
|
||||
throw new Error('Cannot create schedules with the same name');
|
||||
}
|
||||
} else {
|
||||
schedule.name = null;
|
||||
if (schedule.name) {
|
||||
if (await checkIfScheduleExists(schedule.name, scheduleId)) {
|
||||
throw new Error('Cannot create schedules with the same name');
|
||||
}
|
||||
} else {
|
||||
schedule.name = null;
|
||||
}
|
||||
|
||||
// Create the rule here based on the info
|
||||
@@ -242,8 +245,8 @@ export async function updateSchedule({
|
||||
conditions,
|
||||
resetNextDate,
|
||||
}: {
|
||||
schedule;
|
||||
conditions?;
|
||||
schedule: WithRequired<Partial<ScheduleEntity>, 'id'>;
|
||||
conditions?: RuleConditionEntity[];
|
||||
resetNextDate?: boolean;
|
||||
}) {
|
||||
if (schedule.rule) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getApproxNumberThreshold, sortNumbers } from '../../shared/rules';
|
||||
import { ungroupTransaction } from '../../shared/transactions';
|
||||
import { fastSetMerge, partitionByField } from '../../shared/util';
|
||||
import type {
|
||||
PayeeEntity,
|
||||
RuleActionEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
@@ -784,7 +785,10 @@ function* getOneOfSetterRules(
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function updatePayeeRenameRule(fromNames: string[], to: string) {
|
||||
export async function updatePayeeRenameRule(
|
||||
fromNames: Array<PayeeEntity['name']>,
|
||||
to: PayeeEntity['id'],
|
||||
) {
|
||||
const renameRule = getOneOfSetterRules('pre', 'imported_payee', 'payee', {
|
||||
actionValue: to,
|
||||
}).next().value;
|
||||
|
||||
@@ -62,6 +62,7 @@ export const currencies: Currency[] = [
|
||||
{ code: 'UAH', name: 'Ukrainian Hryvnia', symbol: '₴', decimalPlaces: 2, numberFormat: 'space-comma', symbolFirst: false },
|
||||
{ code: 'USD', name: 'US Dollar', symbol: '$', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true },
|
||||
{ code: 'UZS', name: 'Uzbek Soum', symbol: 'UZS', decimalPlaces: 2, numberFormat: 'space-comma', symbolFirst: false },
|
||||
{ code: 'VND', name: 'Vietnamese Dong', symbol: '₫', decimalPlaces: 2, numberFormat: 'dot-comma', symbolFirst: false },
|
||||
];
|
||||
|
||||
export function getCurrency(code: string): Currency {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
// @ts-strict-ignore
|
||||
import { t } from 'i18next';
|
||||
|
||||
import type { FieldValueTypes, RuleConditionOp } from '../types/models';
|
||||
import type {
|
||||
FieldValueTypes,
|
||||
RuleActionEntity,
|
||||
RuleConditionEntity,
|
||||
RuleConditionOp,
|
||||
} from '../types/models';
|
||||
|
||||
// For now, this info is duplicated from the backend. Figure out how
|
||||
// to share it later.
|
||||
@@ -90,11 +95,11 @@ const FIELD_INFO = {
|
||||
|
||||
const fieldInfo: FieldInfoConstraint = FIELD_INFO;
|
||||
|
||||
export const FIELD_TYPES = new Map<keyof FieldValueTypes, string>(
|
||||
Object.entries(FIELD_INFO).map(([field, info]) => [
|
||||
field as unknown as keyof FieldValueTypes,
|
||||
info.type,
|
||||
]),
|
||||
export const FIELD_TYPES = new Map(
|
||||
Object.entries(FIELD_INFO).map(
|
||||
([field, info]) =>
|
||||
[field as unknown as keyof FieldValueTypes, info.type] as const,
|
||||
),
|
||||
);
|
||||
|
||||
export function isValidOp(field: keyof FieldValueTypes, op: RuleConditionOp) {
|
||||
@@ -104,6 +109,7 @@ export function isValidOp(field: keyof FieldValueTypes, op: RuleConditionOp) {
|
||||
if (fieldInfo[field].disallowedOps?.has(op)) return false;
|
||||
|
||||
return (
|
||||
// @ts-expect-error Fix op type. RuleConditionEntity is really tricky to work with...
|
||||
TYPE_INFO[type].ops.includes(op) || fieldInfo[field].internalOps?.has(op)
|
||||
);
|
||||
}
|
||||
@@ -292,24 +298,21 @@ export function sortNumbers(num1, num2) {
|
||||
return [num2, num1];
|
||||
}
|
||||
|
||||
export function parse(item) {
|
||||
if (item.op === 'set-split-amount') {
|
||||
if (item.options.method === 'fixed-amount') {
|
||||
return { ...item };
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export function parseConditions(
|
||||
item: RuleConditionEntity,
|
||||
): RuleConditionEntity & { error?: string | null } {
|
||||
switch (item.type) {
|
||||
case 'number': {
|
||||
return { ...item };
|
||||
}
|
||||
case 'string': {
|
||||
const parsed = item.value == null ? '' : item.value;
|
||||
// @ts-expect-error Fix me
|
||||
return { ...item, value: parsed };
|
||||
}
|
||||
case 'boolean': {
|
||||
const parsed = item.value;
|
||||
// @ts-expect-error Fix me
|
||||
return { ...item, value: parsed };
|
||||
}
|
||||
default:
|
||||
@@ -318,7 +321,74 @@ export function parse(item) {
|
||||
return { ...item, error: null };
|
||||
}
|
||||
|
||||
export function unparse({ error: _error, inputKey: _inputKey, ...item }) {
|
||||
export function unparseConditions({
|
||||
error: _error,
|
||||
inputKey: _inputKey,
|
||||
...item
|
||||
}: RuleConditionEntity & {
|
||||
inputKey?: string;
|
||||
error?: string | null;
|
||||
}): RuleConditionEntity {
|
||||
if ('type' in item && item.type) {
|
||||
switch (item.type) {
|
||||
case 'number': {
|
||||
return { ...item };
|
||||
}
|
||||
case 'string': {
|
||||
const unparsed = item.value == null ? '' : item.value;
|
||||
// @ts-expect-error Fix me
|
||||
return { ...item, value: unparsed };
|
||||
}
|
||||
case 'boolean': {
|
||||
const unparsed = item.value == null ? false : item.value;
|
||||
// @ts-expect-error Fix me
|
||||
return { ...item, value: unparsed };
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export function parseActions(
|
||||
item: RuleActionEntity,
|
||||
): RuleActionEntity & { error?: string | null } {
|
||||
if (item.op === 'set-split-amount') {
|
||||
if (item.options.method === 'fixed-amount') {
|
||||
return { ...item };
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
if ('type' in item && item.type) {
|
||||
switch (item.type) {
|
||||
case 'number': {
|
||||
return { ...item };
|
||||
}
|
||||
case 'string': {
|
||||
const parsed = item.value == null ? '' : item.value;
|
||||
return { ...item, value: parsed };
|
||||
}
|
||||
case 'boolean': {
|
||||
const parsed = item.value;
|
||||
return { ...item, value: parsed };
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return { ...item, error: null };
|
||||
}
|
||||
|
||||
export function unparseActions({
|
||||
error: _error,
|
||||
inputKey: _inputKey,
|
||||
...item
|
||||
}: RuleActionEntity & {
|
||||
inputKey?: string;
|
||||
error?: string | null;
|
||||
}): RuleActionEntity {
|
||||
if (item.op === 'set-split-amount') {
|
||||
if (item.options.method === 'fixed-amount') {
|
||||
return {
|
||||
@@ -328,25 +398,27 @@ export function unparse({ error: _error, inputKey: _inputKey, ...item }) {
|
||||
if (item.options.method === 'fixed-percent') {
|
||||
return {
|
||||
...item,
|
||||
value: item.value && parseFloat(item.value),
|
||||
value: item.value && parseFloat(`${item.value}`),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
switch (item.type) {
|
||||
case 'number': {
|
||||
return { ...item };
|
||||
if ('type' in item && item.type) {
|
||||
switch ('type' in item && item.type) {
|
||||
case 'number': {
|
||||
return { ...item };
|
||||
}
|
||||
case 'string': {
|
||||
const unparsed = item.value == null ? '' : item.value;
|
||||
return { ...item, value: unparsed };
|
||||
}
|
||||
case 'boolean': {
|
||||
const unparsed = item.value == null ? false : item.value;
|
||||
return { ...item, value: unparsed };
|
||||
}
|
||||
default:
|
||||
}
|
||||
case 'string': {
|
||||
const unparsed = item.value == null ? '' : item.value;
|
||||
return { ...item, value: unparsed };
|
||||
}
|
||||
case 'boolean': {
|
||||
const unparsed = item.value == null ? false : item.value;
|
||||
return { ...item, value: unparsed };
|
||||
}
|
||||
default:
|
||||
}
|
||||
|
||||
return item;
|
||||
|
||||
@@ -155,7 +155,7 @@ export type SetSplitAmountRuleActionEntity = {
|
||||
|
||||
export type LinkScheduleRuleActionEntity = {
|
||||
op: 'link-schedule';
|
||||
value: ScheduleEntity;
|
||||
value: ScheduleEntity['id'];
|
||||
};
|
||||
|
||||
export type PrependNoteRuleActionEntity = {
|
||||
|
||||
@@ -366,19 +366,6 @@ describe('/upload-user-file', () => {
|
||||
expect(res.text).toBe('fileId is required');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid fileId format', async () => {
|
||||
const res = await request(app)
|
||||
.post('/upload-user-file')
|
||||
.set('Content-Type', 'application/encrypted-file')
|
||||
.set('x-actual-token', 'valid-token')
|
||||
.set('x-actual-name', 'test-file')
|
||||
.set('x-actual-file-id', 'budget@2026')
|
||||
.send(Buffer.from('file content'));
|
||||
|
||||
expect(res.statusCode).toEqual(400);
|
||||
expect(res.text).toBe('invalid fileId');
|
||||
});
|
||||
|
||||
it('uploads a new file successfully', async () => {
|
||||
const fileId = crypto.randomBytes(16).toString('hex');
|
||||
const fileName = 'test-file.txt';
|
||||
@@ -683,16 +670,6 @@ describe('/download-user-file', () => {
|
||||
expect(res.text).toBe('User or file not found');
|
||||
});
|
||||
|
||||
it('returns 400 for invalid fileId format', async () => {
|
||||
const res = await request(app)
|
||||
.get('/download-user-file')
|
||||
.set('x-actual-token', 'valid-token')
|
||||
.set('x-actual-file-id', 'budget@2026');
|
||||
|
||||
expect(res.statusCode).toEqual(400);
|
||||
expect(res.text).toBe('invalid fileId');
|
||||
});
|
||||
|
||||
it('returns 500 error if the file does not exist on the filesystem', async () => {
|
||||
getAccountDb().mutate(
|
||||
'INSERT INTO files (id, deleted) VALUES (?, FALSE)',
|
||||
|
||||
@@ -49,16 +49,11 @@ app.use(express.json({ limit: `${config.get('upload.fileSizeLimitMB')}mb` }));
|
||||
export { app as handlers };
|
||||
|
||||
const OK_RESPONSE = { status: 'ok' };
|
||||
const FILE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
function boolToInt(deleted) {
|
||||
return deleted ? 1 : 0;
|
||||
}
|
||||
|
||||
function isValidFileId(fileId: unknown): fileId is string {
|
||||
return typeof fileId === 'string' && FILE_ID_PATTERN.test(fileId);
|
||||
}
|
||||
|
||||
const verifyFileExists = (fileId, filesService, res, errorObject) => {
|
||||
try {
|
||||
return filesService.get(fileId);
|
||||
@@ -261,10 +256,6 @@ app.post('/upload-user-file', async (req, res) => {
|
||||
res.status(400).send('fileId is required');
|
||||
return;
|
||||
}
|
||||
if (!isValidFileId(fileId)) {
|
||||
res.status(400).send('invalid fileId');
|
||||
return;
|
||||
}
|
||||
|
||||
let groupId = req.headers['x-actual-group-id'] || null;
|
||||
const encryptMeta = req.headers['x-actual-encrypt-meta'] || null;
|
||||
@@ -361,10 +352,6 @@ app.get('/download-user-file', async (req, res) => {
|
||||
res.status(400).send('Single file ID is required');
|
||||
return;
|
||||
}
|
||||
if (!isValidFileId(fileId)) {
|
||||
res.status(400).send('invalid fileId');
|
||||
return;
|
||||
}
|
||||
|
||||
const filesService = new FilesService(getAccountDb());
|
||||
const file = verifyFileExists(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import path, { dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { load } from 'migrate';
|
||||
|
||||
@@ -30,9 +30,7 @@ export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
|
||||
for (const f of files
|
||||
.filter(f => f.endsWith('.js') || f.endsWith('.ts'))
|
||||
.sort()) {
|
||||
migrationsModules[f] = await import(
|
||||
pathToFileURL(path.join(migrationsDir, f)).href
|
||||
);
|
||||
migrationsModules[f] = await import(path.join(migrationsDir, f));
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
|
||||
6
upcoming-release-notes/6902.md
Normal file
6
upcoming-release-notes/6902.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [antran22]
|
||||
---
|
||||
|
||||
Adds Vietnamese Dong (VND) currency
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [Juulz]
|
||||
---
|
||||
|
||||
Change menuAutoCompleteTextHover color to green400 in Midnight theme.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Lint: add "--quiet" flag to stop reporting warnings
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Validate file IDs for correctness
|
||||
6
upcoming-release-notes/7070.md
Normal file
6
upcoming-release-notes/7070.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Introduce React Query hooks for rules management, enhancing data-fetching and mutation capabilities.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Fix server migrations when running on Windows
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Mobile: adjust rules list for better alignment and full-width container display.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
API: fix module resolution
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [Copilot]
|
||||
---
|
||||
|
||||
Remove 'suspect ai generated' label and delete associated workflow for streamlined labeling system.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add Cursor Cloud setup instructions and troubleshooting tips to AGENTS.md documentation.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add 'unfreeze' label that can be used to unfreeze PRs during mergefreeze
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Fix bugfix categorization in contributor points counting script
|
||||
Reference in New Issue
Block a user