Compare commits

..

5 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
8c190dc480 Coderabbit feedback 2026-03-03 17:20:36 +00:00
Joel Jeremy Marquez
b288ce5708 Code review 2026-02-24 22:21:59 +00:00
Joel Jeremy Marquez
8630a4fda6 Fix lint errors 2026-02-24 22:05:29 +00:00
github-actions[bot]
2cc9daf50a Add release notes for PR #7070 2026-02-24 22:04:24 +00:00
Joel Jeremy Marquez
fbc1025c2b React Query - create new queries and mutations for rules 2026-02-24 21:46:53 +00:00
56 changed files with 1079 additions and 742 deletions

View File

@@ -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'

View File

@@ -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,
};
}

View File

@@ -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."

View 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'
});

View File

@@ -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.

View File

@@ -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": [

View File

@@ -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",

View File

@@ -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"]
}

View File

@@ -1 +0,0 @@
declare module '*.pegjs';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -119,8 +119,6 @@ export function ActionableGridListItem<T extends object>({
padding: 16,
textAlign: 'left',
borderRadius: 0,
justifyContent: 'flex-start',
alignItems: 'flex-start',
}}
onClick={handleAction}
>

View File

@@ -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');
},
},
);
},
},
},

View File

@@ -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}
/>

View File

@@ -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={{

View File

@@ -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';

View File

@@ -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(

View File

@@ -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>

View File

@@ -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(

View File

@@ -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.

View File

@@ -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]);
}}
/>
);
}

View File

@@ -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}`);
}

View File

@@ -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

View File

@@ -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 = (

View File

@@ -62,6 +62,7 @@ export function CurrencySettings() {
['UAH', t('Ukrainian Hryvnia')],
['USD', t('US Dollar')],
['UZS', t('Uzbek Soum')],
['VND', t('Vietnamese Dong')],
]),
[t],
);

View File

@@ -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(

View 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 }));
}

View File

@@ -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 {

View 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 ?? {}),
});
}

View File

@@ -0,0 +1,2 @@
export * from './queries';
export * from './mutations';

View 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,
);
},
});
}

View 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,
}),
};

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -155,7 +155,7 @@ export type SetSplitAmountRuleActionEntity = {
export type LinkScheduleRuleActionEntity = {
op: 'link-schedule';
value: ScheduleEntity;
value: ScheduleEntity['id'];
};
export type PrependNoteRuleActionEntity = {

View File

@@ -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)',

View File

@@ -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(

View File

@@ -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) => {

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [antran22]
---
Adds Vietnamese Dong (VND) currency

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [Juulz]
---
Change menuAutoCompleteTextHover color to green400 in Midnight theme.

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Lint: add "--quiet" flag to stop reporting warnings

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [jfdoming]
---
Validate file IDs for correctness

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Introduce React Query hooks for rules management, enhancing data-fetching and mutation capabilities.

View File

@@ -1,6 +0,0 @@
---
category: Bugfix
authors: [MikesGlitch]
---
Fix server migrations when running on Windows

View File

@@ -1,6 +0,0 @@
---
category: Bugfix
authors: [MatissJanis]
---
Mobile: adjust rules list for better alignment and full-width container display.

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [MatissJanis]
---
API: fix module resolution

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [Copilot]
---
Remove 'suspect ai generated' label and delete associated workflow for streamlined labeling system.

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Add Cursor Cloud setup instructions and troubleshooting tips to AGENTS.md documentation.

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [MatissJanis]
---
Add 'unfreeze' label that can be used to unfreeze PRs during mergefreeze

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [matt-fidd]
---
Fix bugfix categorization in contributor points counting script