Compare commits

..

13 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
f79560c57c Refactor no-cross-package-imports rule to remove hard-banned imports and streamline relative path resolution. Update tests to reflect changes in error messaging for imports not declared in dependencies. 2026-03-17 21:33:54 +00:00
Matiss Janis Aboltins
e30225ed42 Enhance no-cross-package-imports rule to support hard-banned imports and relative path resolution. Introduce checks for exports from undeclared dependencies and add tests for new behaviors, including hard bans on specific package imports. 2026-03-17 21:13:02 +00:00
github-actions[bot]
8876299f71 Add release notes for PR #7233 2026-03-17 20:31:14 +00:00
Claude
554da806ce [AI] Revert verbose no-restricted-imports overrides, restore custom rule
Revert the per-package no-restricted-imports overrides approach (~485
lines of duplicated config) in favor of the custom no-cross-package-imports
rule in eslint-plugin-actual (~150 lines). The custom rule auto-reads
each package's package.json to determine allowed dependencies, requiring
zero config duplication and zero maintenance when dependencies change.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 19:06:16 +00:00
Claude
fc622c200b [AI] Replace custom ESLint rule with no-restricted-imports overrides
Remove the custom no-cross-package-imports rule and instead use oxlint's
built-in eslint/no-restricted-imports with per-package overrides to enforce
cross-package dependency boundaries. Each package gets an override that
restricts @actual-app/* imports to only its declared dependencies.

This also catches relative cross-package imports (e.g. ../../desktop-client/)
via glob patterns in the overrides.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 17:56:20 +00:00
Claude
d059d57cc7 [AI] Detect relative imports that cross package boundaries
Extend no-cross-package-imports rule to also catch relative imports like
`../../desktop-client/src/...` that traverse into a different package
directory. Resolves the import path against the file's directory and
checks if the target package is declared as a dependency.

Add oxlint-disable for the known intentional violation in
component-library/.storybook/preview.tsx.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 16:30:08 +00:00
Claude
8eb096957e [AI] Restore @actual-app/web no-restricted-imports rule and fix stacked disable comments
Keep the existing no-restricted-imports rule for @actual-app/web in loot-core
as-is alongside the new no-cross-package-imports rule. Fix the stacked
oxlint-disable-next-line comments in desktop-electron/e2e/onboarding.test.ts
by combining them into a single line so both suppressions apply correctly.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 12:01:14 +00:00
Claude
9d9f76d7df [AI] Add no-cross-package-imports ESLint rule to enforce package dependency boundaries
Add a custom ESLint rule that validates @actual-app/* imports are declared
as dependencies in the importing package's package.json. This replaces the
manual no-restricted-imports pattern for @actual-app/web in loot-core with
an automatic, self-maintaining rule that covers all packages.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 11:26:04 +00:00
Michael Clark
4f7c3c51a5 🐛 Using a shared worker to coordinate multiple tabs (#7172)
* attempt to enable sync when multiple tabs are open

* allow multiple tabs to work

* release notes

* rehome the host if the tab closes

* ensure new tabs always receive failure  messages by broadcasting them on interval

* reject after retries are exhausted

* forwarding the logs from the worker to the main browser

* [autofix.ci] apply automated fixes

* add preflight fetch from main thread to server endpoint to trigger permission prompt if required

* remove the log prefix for cleaner logs

* adding heardbeat to detect closed tabs so they can be removed from the list

* store failure payload and broadcast for new tabs after timeout is cleared

* if a tab closes a budget, force other tabs to go to the budget list screen

* fix safari by detecting crossoriginisolated as a dependency for shared worker

* all ios to fallback to non-shared-worker implemenation

* coordinator and all backend work going through a leader tab to enable ios

* electing new leader tab when oone tab closes or is refreshed

* logic for standalone tabs to rejoin shared workers when on same budget

* remove the preflight request, shouldnt be needed now the code runs on the main process

* handling brand new tabs going to open budgets that are current standalone with no leader

* allowing budgets to be closed  without kickother others by transfering leadership to remaining oopened tabs

* remove unnedd comments

* change approach slightly - no more standalone, now every budget gets leader promotion automatically)

* adding tests and fixed minor bug to do with deleting budget with multiple tabs open

* fix worker not loading

* trouble with ts - moving to js

* reintroduce ts for the worker

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-17 09:30:34 +00:00
Matt Fiddaman
0e1fc07bf3 ⬆️ @types/react (#7223)
* bump @types/react

* note
2026-03-17 08:17:48 +00:00
Matiss Janis Aboltins
53cdc6fa48 [AI] Further hardening of "/change-password" endpoint (#7207)
* [AI] Fix OIDC privilege escalation in /change-password endpoint

Add admin role check and password auth_method session check to prevent
non-admin or OIDC-authenticated users from changing the server password.
Previously, any authenticated user could overwrite the password hash and
then login via password method to obtain an ADMIN session.

https://claude.ai/code/session_01Wne9FY2QnKp6JF7g61B1Sn

* Add release notes for PR #7207

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 08:16:46 +00:00
okxint
1d0281025d fix: preserve schedule link when merging transactions (#7177)
* [AI] fix: preserve schedule link when merging transactions

When merging two transactions where one is linked to a schedule,
the schedule field was not included in the merge update, causing
the schedule association to be silently dropped. This resulted in
duplicate transactions and incorrect "Due" status for scheduled
transactions.

Add `schedule: keep.schedule || drop.schedule` to both the normal
merge path and the subtransaction merge path, matching the existing
fallback pattern used for payee, category, notes, etc.

Add three test cases covering:
- Schedule preserved from dropped transaction when kept has none
- Kept transaction's schedule takes priority when both have one
- Schedule preserved when merging manual scheduled with banksynced

Fixes #6997

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add release notes for PR #7177

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 05:14:16 +00:00
Matiss Janis Aboltins
f73c5e9210 [AI] Fix adm-zip dependency resolution in loot-core (#7219) 2026-03-16 21:11:51 +00:00
66 changed files with 3052 additions and 1066 deletions

View File

@@ -88,6 +88,7 @@
"typescript": "^5.9.3"
},
"resolutions": {
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"rollup": "4.40.1",
"socks": ">=2.8.3"
},

View File

@@ -4,10 +4,12 @@ import type { Preview } from '@storybook/react-vite';
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
// TODO: this needs refactoring
// oxlint-disable actual/no-cross-package-imports -- intentional cross-package import, needs refactoring
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
import * as lightTheme from '../../desktop-client/src/style/themes/light';
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
// oxlint-enable actual/no-cross-package-imports
const THEMES = {
light: lightTheme,

View File

@@ -53,7 +53,7 @@
"@storybook/addon-docs": "^10.2.7",
"@storybook/react-vite": "^10.2.7",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"@types/react": "^19.2.14",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@vitejs/plugin-react": "^6.0.0",
"eslint-plugin-storybook": "^10.2.7",

View File

@@ -4,7 +4,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type BlockProps = HTMLProps<HTMLDivElement> & {
type BlockProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
innerRef?: Ref<HTMLDivElement>;
style?: CSSProperties;
};

View File

@@ -4,7 +4,7 @@ import { css } from '@emotion/css';
import type { CSSProperties } from './styles';
type ParagraphProps = HTMLProps<HTMLDivElement> & {
type ParagraphProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
style?: CSSProperties;
isLast?: boolean;
};

View File

@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type TextProps = HTMLProps<HTMLSpanElement> & {
type TextProps = Omit<HTMLProps<HTMLSpanElement>, 'style'> & {
innerRef?: Ref<HTMLSpanElement>;
className?: string;
children?: ReactNode;

View File

@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
import type { CSSProperties } from './styles';
type ViewProps = HTMLProps<HTMLDivElement> & {
type ViewProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
className?: string;
style?: CSSProperties;
nativeStyle?: CSSProperties;

View File

@@ -45,7 +45,7 @@
"@types/lodash": "^4",
"@types/pikaday": "^1.7.10",
"@types/promise-retry": "^1.1.6",
"@types/react": "^19.2.5",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-modal": "^3.16.3",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",

View File

@@ -6,6 +6,8 @@ import * as Platform from 'loot-core/shared/platform';
// oxlint-disable-next-line typescript-paths/absolute-parent-import
import packageJson from '../package.json';
import SharedBrowserServerWorker from './shared-browser-server.ts?sharedworker';
const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
// This file installs global variables that the app expects.
@@ -21,9 +23,235 @@ const ACTUAL_VERSION = Platform.isPlaywright
: packageJson.version;
// *** Start the backend ***
let worker = null;
// The regular Worker running the backend, created only on the leader tab
let localBackendWorker = null;
/**
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
* (onmessage, postMessage, addEventListener, start) to the connection layer.
*
* The SharedWorker coordinator assigns each tab a role per budget:
* - LEADER: this tab runs the backend in a dedicated Worker
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
*
* Multiple budgets can be open simultaneously — each has its own leader.
*/
class WorkerBridge {
constructor(sharedPort) {
this._sharedPort = sharedPort;
this._onmessage = null;
this._listeners = [];
this._started = false;
// Listen for all messages from the SharedWorker port
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
}
set onmessage(handler) {
this._onmessage = handler;
// Setting onmessage on a real MessagePort implicitly starts it.
// We need to do this explicitly on the underlying port.
if (!this._started) {
this._started = true;
this._sharedPort.start();
}
}
get onmessage() {
return this._onmessage;
}
postMessage(msg) {
// All messages go through the SharedWorker for coordination.
// The SharedWorker forwards to the leader's Worker via __to-worker.
this._sharedPort.postMessage(msg);
}
addEventListener(type, handler) {
this._listeners.push({ type, handler });
}
start() {
if (!this._started) {
this._started = true;
this._sharedPort.start();
}
}
_dispatch(event) {
if (this._onmessage) this._onmessage(event);
for (const { type, handler } of this._listeners) {
if (type === 'message') handler(event);
}
}
_onSharedMessage(event) {
const msg = event.data;
// Elected as leader: create the real backend Worker on this tab
if (msg && msg.type === '__become-leader') {
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
return;
}
// Forward requests from SharedWorker to our local Worker
if (msg && msg.type === '__to-worker') {
if (localBackendWorker) {
localBackendWorker.postMessage(msg.msg);
}
return;
}
// Leadership transfer: this tab is closing the budget but other tabs
// still need it. Terminate our Worker (don't actually close-budget on
// the backend) and dispatch a synthetic reply so the UI navigates to
// show-budgets normally.
if (msg && msg.type === '__close-and-transfer') {
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
if (localBackendWorker) {
localBackendWorker.terminate();
localBackendWorker = null;
}
// Only dispatch a synthetic reply if there's an actual close-budget
// request to complete. When requestId is null the eviction was
// triggered externally (e.g. another tab deleted this budget).
if (msg.requestId) {
this._dispatch({
data: { type: 'reply', id: msg.requestId, data: {} },
});
}
return;
}
// Role change notification
if (msg && msg.type === '__role-change') {
console.log(
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
);
return;
}
// Surface SharedWorker console output in this tab's DevTools
if (msg && msg.type === '__shared-worker-console') {
const method = console[msg.level] || console.log;
method(...msg.args);
return;
}
// Respond to heartbeat pings
if (msg && msg.type === '__heartbeat-ping') {
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
return;
}
// Everything else goes to the connection layer
this._dispatch(event);
}
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
if (localBackendWorker) {
localBackendWorker.terminate();
}
localBackendWorker = new Worker(backendWorkerUrl);
initSQLBackend(localBackendWorker);
const sharedPort = this._sharedPort;
localBackendWorker.onmessage = workerEvent => {
const workerMsg = workerEvent.data;
// absurd-sql internal messages are handled by initSQLBackend
if (
workerMsg &&
workerMsg.type &&
workerMsg.type.startsWith('__absurd:')
) {
return;
}
// After the backend connects, automatically reload the budget that was
// open before the leader left (e.g. page refresh). This lets other tabs
// continue working without being sent to the budget list.
if (workerMsg.type === 'connect') {
if (budgetToRestore) {
console.log(
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
);
const id = budgetToRestore;
budgetToRestore = null;
localBackendWorker.postMessage({
id: '__restore-budget',
name: 'load-budget',
args: { id },
catchErrors: true,
});
// Tell SharedWorker to track the restore request so
// currentBudgetId gets updated when the reply arrives.
sharedPort.postMessage({
type: '__track-restore',
requestId: '__restore-budget',
budgetId: id,
});
} else if (pendingMsg) {
const toSend = pendingMsg;
pendingMsg = null;
localBackendWorker.postMessage(toSend);
}
}
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
};
localBackendWorker.postMessage(initMsg);
}
}
function createBackendWorker() {
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
// Each budget gets its own leader tab running a dedicated Worker. All other
// tabs on the same budget are followers — their messages are routed through
// the SharedWorker to the leader's Worker.
// The SharedWorker never touches SharedArrayBuffer, so this works on all
// platforms including iOS/Safari.
if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) {
try {
const sharedWorker = new SharedBrowserServerWorker({
name: 'actual-backend',
});
const sharedPort = sharedWorker.port;
worker = new WorkerBridge(sharedPort);
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
// Don't call start() here. The port must remain un-started so that
// messages (especially 'connect') are queued until connectWorker()
// sets onmessage, which implicitly starts the port via the bridge.
if (window.SharedArrayBuffer) {
localStorage.removeItem('SharedArrayBufferOverride');
}
sharedPort.postMessage({
type: 'init',
version: ACTUAL_VERSION,
isDev: IS_DEV,
publicUrl: process.env.PUBLIC_URL,
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
'SharedArrayBufferOverride',
),
});
window.addEventListener('beforeunload', () => {
sharedPort.postMessage({ type: 'tab-closing' });
});
return;
} catch (e) {
console.log('SharedWorker failed, falling back to Worker:', e);
}
}
// Fallback: regular Worker (Playwright, no SharedWorker support, or failure)
console.log('[WorkerBridge] No SharedWorker available, using direct Worker');
worker = new Worker(backendWorkerUrl);
initSQLBackend(worker);
@@ -37,6 +265,7 @@ function createBackendWorker() {
isDev: IS_DEV,
publicUrl: process.env.PUBLIC_URL,
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
'SharedArrayBufferOverride',
),

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,9 +30,7 @@ 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,
@@ -40,10 +38,6 @@ 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 }>;
@@ -121,36 +115,17 @@ export function ruleToString(rule: RuleEntity, data: FilterData) {
type ManageRulesProps = {
isModal: boolean;
payeeId: string | null;
setLoading?: Dispatch<SetStateAction<boolean>>;
};
export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
export function ManageRules({
isModal,
payeeId,
setLoading = () => {},
}: ManageRulesProps) {
const { t } = useTranslation();
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 [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [page, setPage] = useState(0);
const [filter, setFilter] = useState('');
const dispatch = useDispatch();
@@ -172,7 +147,7 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
);
const filteredRules = useMemo(() => {
const rules = rulesToUse.filter(rule => {
const rules = allRules.filter(rule => {
const schedule = schedules.find(schedule => schedule.rule === rule.id);
return schedule ? schedule.completed === false : true;
});
@@ -186,7 +161,7 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
),
)
).slice(0, 100 + page * 50);
}, [rulesToUse, filter, filterData, page, schedules]);
}, [allRules, filter, filterData, page, schedules]);
const selectedInst = useSelected('manage-rules', filteredRules, []);
const [hoveredRule, setHoveredRule] = useState(null);
@@ -196,16 +171,38 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
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();
}, []);
@@ -214,33 +211,29 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
setPage(page => page + 1);
}
const { mutate: batchDeleteRules } = useBatchDeleteRulesMutation();
const onDeleteSelected = async () => {
batchDeleteRules(
{
ids: [...selectedInst.items],
},
{
onSuccess: () => {
void refetchRules();
selectedInst.dispatch({ type: 'select-none' });
},
},
);
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);
};
const { mutate: deleteRule } = useDeleteRuleMutation();
function onDeleteRule(id: string) {
deleteRule(
{ id },
{
onSuccess: () => {
void refetchRules();
},
},
);
async function onDeleteRule(id: string) {
setLoading(true);
await send('rule-delete', id);
await loadRules();
setLoading(false);
}
const onEditRule = rule => {
@@ -251,7 +244,8 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
options: {
rule,
onSave: async () => {
void refetchRules();
await loadRules();
setLoading(false);
},
},
},
@@ -288,7 +282,8 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
options: {
rule,
onSave: async () => {
void refetchRules();
await loadRules();
setLoading(false);
},
},
},
@@ -300,24 +295,6 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
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>
@@ -384,24 +361,11 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
>
<SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}>
{selectedInst.items.size > 0 && (
<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 onPress={onDeleteSelected}>
<Trans count={selectedInst.items.size}>
Delete {{ count: selectedInst.items.size }} rules
</Trans>
</Button>
)}
<Button variant="primary" onPress={onCreateRule}>
<Trans>Create new rule</Trans>

View File

@@ -1,10 +1,11 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { ComponentProps, CSSProperties } from 'react';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgCustomNotesPaper } from '@actual-app/components/icons/v2';
import { Popover } from '@actual-app/components/popover';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';

View File

@@ -83,7 +83,6 @@ 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;
@@ -252,7 +251,6 @@ 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 = {
@@ -693,8 +691,9 @@ class AccountInternal extends PureComponent<
const allErrors: string[] = [];
for (const transaction of transactions) {
const res: TransactionEntity | null =
await this.props.onRunRules(transaction);
const res: TransactionEntity | null = await send('rules-run', {
transaction,
});
if (res) {
changedTransactions.push(...ungroupTransaction(res));
@@ -1056,9 +1055,10 @@ class AccountInternal extends PureComponent<
});
// run rules on the reconciliation transaction
const runRules = this.props.onRunRules;
const ruledTransactions = await Promise.all(
reconciliationTransactions.map(transaction => runRules(transaction)),
reconciliationTransactions.map(transaction =>
send('rules-run', { transaction }),
),
);
// sync the reconciliation transaction
@@ -2028,13 +2028,9 @@ export function Account() {
const onSyncAndDownload = (id?: AccountEntity['id']) =>
syncAndDownload({ id });
const { mutateAsync: createPayeeAsync } = useCreatePayeeMutation();
const createPayee = useCreatePayeeMutation();
const onCreatePayee = (name: PayeeEntity['name']) =>
createPayeeAsync({ name });
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onRunRules = (transaction: TransactionEntity) =>
runRulesAsync({ transaction });
createPayee.mutateAsync({ name });
return (
<SchedulesProvider query={schedulesQuery}>
@@ -2077,7 +2073,6 @@ export function Account() {
onUnlinkAccount={onUnlinkAccount}
onSyncAndDownload={onSyncAndDownload}
onCreatePayee={onCreatePayee}
onRunRules={onRunRules}
/>
</SplitsExpandedProvider>
</SchedulesProvider>

View File

@@ -1,9 +1,9 @@
import React from 'react';
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgChartPie } from '@actual-app/components/icons/v1';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { css, cx } from '@emotion/css';

View File

@@ -1,7 +1,6 @@
// @ts-strict-ignore
import type { CSSProperties } from 'react';
import { styles } from '@actual-app/components/styles';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { t } from 'i18next';

View File

@@ -17,8 +17,12 @@ type TextLinkProps = {
children?: ReactNode;
};
type ButtonLinkProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
type ButtonLinkProps = Omit<
ComponentProps<typeof Button>,
'variant' | 'style'
> & {
buttonVariant?: ComponentProps<typeof Button>['variant'];
style?: CSSProperties;
to?: string;
activeStyle?: CSSProperties;
};

View File

@@ -28,7 +28,7 @@ import {
getFieldError,
getValidOps,
mapField,
unparseConditions,
unparse,
} from 'loot-core/shared/rules';
import { titleFirst } from 'loot-core/shared/util';
import type { IntegerAmount } from 'loot-core/shared/util';
@@ -296,39 +296,37 @@ function ConfigureField<T extends RuleConditionEntity>({
});
}}
>
{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 });
}}
/>
)}
{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 });
}}
/>
)}
{field === 'payee' && isPayeeIdOp(op) && (
<PayeeFilter
@@ -426,7 +424,7 @@ export function FilterButton<T extends RuleConditionEntity>({
async function onValidateAndApply(cond: T) {
// @ts-expect-error - fix me
cond = unparseConditions({ ...cond, type: FIELD_TYPES.get(cond.field) });
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
if (cond.type === 'date' && cond.options) {
if (cond.options.month) {
@@ -630,11 +628,7 @@ export function FilterEditor<T extends RuleConditionEntity>({
dispatch={dispatch}
onApply={cond => {
// @ts-expect-error - fix me
cond = unparseConditions({
...cond,
// @ts-expect-error - fix me
type: FIELD_TYPES.get(cond.field),
});
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
if (cond.type === 'date' && cond.options) {
if (

View File

@@ -86,7 +86,7 @@ export const FormField = ({ style, children }: FormFieldProps) => {
// Custom inputs
type CheckboxProps = ComponentProps<'input'> & {
type CheckboxProps = Omit<ComponentProps<'input'>, 'style'> & {
style?: CSSProperties;
};

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,8 +107,6 @@ export function MobileRuleEditPage() {
void navigate(-1);
};
const { mutate: deleteRule } = useDeleteRuleMutation();
const handleDelete = () => {
// Runtime guard to ensure id exists
if (!id || id === 'new') {
@@ -122,17 +120,23 @@ export function MobileRuleEditPage() {
options: {
message: t('Are you sure you want to delete this rule?'),
onConfirm: async () => {
deleteRule(
{ id },
{
onSuccess: () => {
showUndoNotification({
message: t('Rule deleted successfully'),
});
void navigate('/rules');
},
},
);
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.'),
},
}),
);
}
},
},
},

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 } from 'loot-core/platform/client/connection';
import { listen, 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';
@@ -21,24 +21,22 @@ 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 { useDeleteRuleMutation } from '@desktop-client/rules';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
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('*'), []),
});
@@ -81,10 +79,28 @@ 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 refetchRules();
void loadRules();
};
const lastUndoEvent = undo.getUndoState('undoEvent');
@@ -93,7 +109,7 @@ export function MobileRulesPage() {
}
return listen('undo-event', onUndo);
}, [refetchRules]);
}, [loadRules]);
const handleRulePress = useCallback(
(rule: RuleEntity) => {
@@ -109,22 +125,45 @@ export function MobileRulesPage() {
[setFilter],
);
const { mutate: deleteRule } = useDeleteRuleMutation();
const handleRuleDelete = useCallback(
(rule: RuleEntity) => {
deleteRule(
{ id: rule.id },
{
onSuccess: () => {
showUndoNotification({
message: t('Rule deleted successfully'),
});
},
},
);
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.'),
},
}),
);
}
},
[deleteRule, showUndoNotification, t],
[dispatch, showUndoNotification, t, loadRules],
);
return (
@@ -160,7 +199,7 @@ export function MobileRulesPage() {
</View>
<RulesList
rules={filteredRules}
isLoading={isRulesLoading}
isLoading={isLoading}
onRulePress={handleRulePress}
onRuleDelete={handleRuleDelete}
/>

View File

@@ -12,11 +12,7 @@ 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,
RuleConditionEntity,
ScheduleEntity,
} from 'loot-core/types/models';
import type { RecurConfig, ScheduleEntity } from 'loot-core/types/models';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';

View File

@@ -7,6 +7,7 @@ import type {
} from 'react';
import { Button } from '@actual-app/components/button';
import type { CSSProperties as EmotionCSSProperties } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -167,7 +168,9 @@ type FocusableAmountInputProps = Omit<AmountInputProps, 'onFocus'> & {
focused?: boolean;
disabled?: boolean;
focusedStyle?: CSSProperties;
buttonProps?: ComponentPropsWithRef<typeof Button>;
buttonProps?: Omit<ComponentPropsWithRef<typeof Button>, 'style'> & {
style?: EmotionCSSProperties;
};
onFocus?: () => void;
};

View File

@@ -75,6 +75,7 @@ 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';
@@ -96,10 +97,6 @@ import { useSavePayeeLocationMutation } from '@desktop-client/payees';
import { locationService } from '@desktop-client/payees/location';
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) {
@@ -689,9 +686,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
[categories, isBudgetTransfer, t],
);
const { mutate: createSingleTimeScheduleFromTransaction } =
useCreateSingleTimeScheduleFromTransaction();
const onSaveInner = useCallback(() => {
const [unserializedTransaction] = unserializedTransactions;
@@ -750,24 +744,19 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
}
: unserializedTransaction;
createSingleTimeScheduleFromTransaction(
{
transaction: transactionForSchedule,
},
{
onSuccess: () => {
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
void navigate(-1);
},
},
await createSingleTimeScheduleFromTransaction(
transactionForSchedule,
);
dispatch(
addNotification({
notification: {
type: 'message',
message: t('Schedule created successfully'),
},
}),
);
void navigate(-1);
},
onCancel: onConfirmSave,
},
@@ -804,7 +793,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
unserializedTransactions,
upcomingLength,
t,
createSingleTimeScheduleFromTransaction,
]);
const onUpdateInner = useCallback(
@@ -1496,8 +1484,6 @@ function TransactionEditUnconnected({
searchParams,
]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onUpdate = useCallback(
async (
serializedTransaction: TransactionEntity,
@@ -1513,7 +1499,9 @@ function TransactionEditUnconnected({
// this on new transactions because that's how desktop works.
const newTransaction = { ...transaction };
if (isTemporary(newTransaction)) {
const afterRules = await runRulesAsync({ transaction: newTransaction });
const afterRules = await send('rules-run', {
transaction: newTransaction,
});
const diff = getChangedValues(newTransaction, afterRules);
if (diff) {
@@ -1582,7 +1570,7 @@ function TransactionEditUnconnected({
}
}
},
[dateFormat, transactions, locationAccess, runRulesAsync],
[dateFormat, transactions, locationAccess],
);
const onSave = useCallback(

View File

@@ -1,4 +1,5 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -16,16 +17,17 @@ type ManageRulesModalProps = Extract<
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
return (
<Modal name="manage-rules">
<Modal name="manage-rules" isLoading={loading}>
{({ state }) => (
<>
<ModalHeader
title={t('Rules')}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<ManageRules isModal payeeId={payeeId} />
<ManageRules isModal payeeId={payeeId} setLoading={setLoading} />
</>
)}
</Modal>

View File

@@ -17,7 +17,6 @@ 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 };
@@ -58,9 +57,6 @@ export function MergeUnusedPayeesModal({
allPayees.filter(p => payeeIds.includes(p.id)),
);
const { mutateAsync: addPayeeRenameRuleAsync } =
useAddPayeeRenameRuleMutation();
const onMerge = useCallback(
async (targetPayee: PayeeEntity) => {
await send('payees-merge', {
@@ -70,7 +66,7 @@ export function MergeUnusedPayeesModal({
let ruleId;
if (shouldCreateRule && !isEditingRule) {
const id = await addPayeeRenameRuleAsync({
const id = await send('rule-add-payee-rename', {
fromNames: payees.map(payee => payee.name),
to: targetPayee.id,
});
@@ -79,7 +75,7 @@ export function MergeUnusedPayeesModal({
return ruleId;
},
[shouldCreateRule, isEditingRule, payees, addPayeeRenameRuleAsync],
[shouldCreateRule, isEditingRule, payees],
);
const onMergeAndCreateRule = useCallback(

View File

@@ -1,7 +1,8 @@
import React from 'react';
import type { CSSProperties, ReactNode } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@actual-app/components/button';
import type { CSSProperties } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { css } from '@emotion/css';

View File

@@ -37,10 +37,8 @@ import {
isValidOp,
makeValue,
mapField,
parseActions,
parseConditions,
unparseActions,
unparseConditions,
parse,
unparse,
} from 'loot-core/shared/rules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type {
@@ -48,7 +46,6 @@ import type {
RuleActionEntity,
RuleEntity,
} from 'loot-core/types/models';
import type { WithOptional } from 'loot-core/types/util';
import { FormulaActionEditor } from './FormulaActionEditor';
@@ -66,12 +63,9 @@ 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) {
@@ -964,7 +958,7 @@ function ConditionsList({
}
const getActions = splits => splits.flatMap(s => s.actions);
const getUnparsedActions = splits => getActions(splits).map(unparseActions);
const getUnparsedActions = splits => getActions(splits).map(unparse);
// TODO:
// * Dont touch child transactions?
@@ -1002,27 +996,19 @@ export function RuleEditor({
}: RuleEditorProps) {
const { t } = useTranslation();
const [conditions, setConditions] = useState(
defaultRule.conditions
.map(parseConditions)
.map(c => ({ ...c, inputKey: uuid() })),
defaultRule.conditions.map(parse).map(c => ({ ...c, inputKey: uuid() })),
);
const [actionSplits, setActionSplits] = useState<
Array<{
id: string;
actions: Array<RuleActionEntity & { inputKey: string }>;
}>
>(() => {
const parsedActions = defaultRule.actions.map(parseActions);
const [actionSplits, setActionSplits] = useState(() => {
const parsedActions = defaultRule.actions.map(parse);
return parsedActions.reduce(
(acc, action) => {
const splitIndex =
'options' in action ? (action.options?.splitIndex ?? 0) : 0;
const splitIndex = action.options?.splitIndex ?? 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: [] } as (typeof actionSplits)[0]],
[{ id: uuid(), actions: [] }],
);
});
const [stage, setStage] = useState(defaultRule.stage);
@@ -1053,7 +1039,7 @@ export function RuleEditor({
// Run it here
async function run() {
const { filters } = await send('make-filters-from-conditions', {
conditions: conditions.map(unparseConditions),
conditions: conditions.map(unparse),
});
if (filters.length > 0) {
@@ -1225,64 +1211,74 @@ export function RuleEditor({
});
}
const { mutate: applyRuleActions } = useApplyRuleActionsMutation();
function onApply() {
const selectedTransactions = transactions.filter(({ id }) =>
selectedInst.items.has(id),
);
applyRuleActions(
{
transactions: selectedTransactions,
ruleActions: getUnparsedActions(actionSplits),
},
{
onSuccess: () => {
setActionSplits([...actionSplits]);
},
},
);
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]);
});
}
const { mutate: saveRule } = useSaveRuleMutation();
async function onSave() {
const rule: WithOptional<RuleEntity, 'id'> = {
const rule = {
...defaultRule,
stage,
conditionsOp,
conditions: conditions.map(unparseConditions),
conditions: conditions.map(unparse),
actions: getUnparsedActions(actionSplits),
};
saveRule(
{
rule,
},
{
onSuccess: savedRule => {
originalOnSave?.(savedRule);
},
onError: error => {
if ('conditionErrors' in error && error.conditionErrors) {
setConditions(applyErrors(conditions, error.conditionErrors));
}
// @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);
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,
})),
})),
);
}
},
},
);
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);
}
}
// 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['id'];
value: ScheduleEntity;
};
export function ScheduleValue({ value }: ScheduleValueProps) {
@@ -35,13 +35,12 @@ export function ScheduleValue({ value }: ScheduleValueProps) {
<Value
value={value}
field="rule"
describe={val => {
const schedule = schedules.find(s => s.id === val);
if (!schedule) {
return t('(deleted)');
}
return describeSchedule(schedule, byId[schedule._payee]);
}}
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])
}
/>
);
}

View File

@@ -24,6 +24,7 @@ type ValueProps<T> = {
field: unknown;
valueIsRaw?: boolean;
inline?: boolean;
data?: unknown;
describe?: (item: T) => string;
style?: CSSProperties;
};
@@ -33,7 +34,9 @@ export function Value<T>({
field,
valueIsRaw,
inline = false,
describe,
data: dataProp,
// @ts-expect-error fix this later
describe = x => x.name,
style,
}: ValueProps<T>) {
const { t } = useTranslation();
@@ -53,6 +56,32 @@ 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) {
@@ -90,39 +119,23 @@ 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 describe?.(value) ?? value;
return '…';
default:
throw new Error(`Unknown field ${String(field)}`);
}

View File

@@ -179,6 +179,7 @@ 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,18 +1,14 @@
import { t } from 'i18next';
import { extractScheduleConds } from 'loot-core/shared/schedules';
import type {
RuleConditionEntity,
RuleConditionOp,
ScheduleEntity,
} from 'loot-core/types/models';
import type { RuleConditionOp, ScheduleEntity } from 'loot-core/types/models';
import type { ScheduleFormFields } from './ScheduleEditForm';
export function updateScheduleConditions(
schedule: Partial<ScheduleEntity>,
fields: ScheduleFormFields,
): { error?: string; conditions?: RuleConditionEntity[] } {
): { error?: string; conditions?: unknown[] } {
const conds = extractScheduleConds(schedule._conditions);
const updateCond = (

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { ChangeEvent, KeyboardEvent } from 'react';
import type { KeyboardEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -124,8 +124,7 @@ export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
}
inputProps={{
value: tag || '',
onInput: ({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
setTag(value.replace(/\s/g, '')),
onChange: e => setTag(e.target.value.replace(/\s/g, '')),
placeholder: t('New tag'),
ref: tagInput,
}}

View File

@@ -8,6 +8,7 @@ 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,6 +23,7 @@ import type {
AccountEntity,
CategoryEntity,
PayeeEntity,
RuleActionEntity,
RuleConditionEntity,
ScheduleEntity,
TransactionEntity,
@@ -39,11 +41,6 @@ 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:
//
// * Optimistic updates: we apply the needed updates to local data
@@ -87,6 +84,133 @@ 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;
@@ -257,9 +381,6 @@ export function TransactionList({
[dispatch, onRefetch, upcomingLength, t],
);
const { mutateAsync: createSingleTimeScheduleFromTransactionAsync } =
useCreateSingleTimeScheduleFromTransaction();
const onAdd = useCallback(
async (newTransactions: TransactionEntity[]) => {
newTransactions = realizeTempTransactions(newTransactions);
@@ -282,9 +403,9 @@ export function TransactionList({
promptToConvertToSchedule(
transactionWithSubtransactions,
async () => {
await createSingleTimeScheduleFromTransactionAsync({
transaction: transactionWithSubtransactions,
});
await createSingleTimeScheduleFromTransaction(
transactionWithSubtransactions,
);
},
async () => {
await saveDiff(
@@ -299,12 +420,7 @@ export function TransactionList({
await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled);
onRefetch();
},
[
isLearnCategoriesEnabled,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
[isLearnCategoriesEnabled, onRefetch, promptToConvertToSchedule],
);
const onSave = useCallback(
@@ -350,9 +466,7 @@ export function TransactionList({
await send('transaction-delete', { id: transaction.id });
}
await createSingleTimeScheduleFromTransactionAsync({
transaction,
});
await createSingleTimeScheduleFromTransaction(transaction);
},
saveTransaction,
);
@@ -362,13 +476,7 @@ export function TransactionList({
await saveTransaction();
},
[
isLearnCategoriesEnabled,
onChange,
onRefetch,
promptToConvertToSchedule,
createSingleTimeScheduleFromTransactionAsync,
],
[isLearnCategoriesEnabled, onChange, onRefetch, promptToConvertToSchedule],
);
const onAddSplit = useCallback(
@@ -401,14 +509,12 @@ export function TransactionList({
[isLearnCategoriesEnabled, onChange],
);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
const onApplyRules = useCallback(
async (
transaction: TransactionEntity,
updatedFieldName: string | null = null,
) => {
const afterRules = await runRulesAsync({ transaction });
const afterRules = await send('rules-run', { transaction });
// Show formula errors if any
if (afterRules._ruleErrors && afterRules._ruleErrors.length > 0) {
@@ -456,7 +562,7 @@ export function TransactionList({
}
return newTransaction;
},
[dispatch, runRulesAsync],
[dispatch],
);
const onManagePayees = useCallback(

View File

@@ -1,13 +0,0 @@
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,5 +1,6 @@
import { useEffect, useMemo, 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';
@@ -9,8 +10,6 @@ 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?: {
@@ -64,8 +63,6 @@ export function usePreviewTransactions({
);
}, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]);
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
useEffect(() => {
let isUnmounted = false;
@@ -82,7 +79,7 @@ export function usePreviewTransactions({
Promise.all(
scheduleTransactions.map(transaction =>
// Kick off an async rules application
runRulesAsync({ transaction }),
send('rules-run', { transaction }),
),
)
.then(newTrans => {
@@ -116,13 +113,7 @@ export function usePreviewTransactions({
return () => {
isUnmounted = true;
};
}, [
scheduleTransactions,
schedules,
statuses,
upcomingLength,
runRulesAsync,
]);
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
const runningBalances = useMemo(() => {
if (!options?.calculateRunningBalances) {

View File

@@ -1,15 +0,0 @@
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

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

View File

@@ -1,413 +0,0 @@
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

@@ -1,31 +0,0 @@
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

@@ -0,0 +1,897 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { createCoordinator } from './shared-browser-server-core';
// ── Types ───────────────────────────────────────────────────────────────
type MockPort = {
postMessage: Mock;
start: Mock;
onmessage: ((event: { data: unknown }) => void) | null;
};
type Coordinator = ReturnType<typeof createCoordinator>;
// ── Test helpers ────────────────────────────────────────────────────────
function createMockPort(): MockPort {
return { postMessage: vi.fn(), start: vi.fn(), onmessage: null };
}
function setup(): Coordinator {
return createCoordinator();
}
/** Simulate a new tab connecting to the SharedWorker. */
function connectTab(coordinator: Coordinator): MockPort {
const port = createMockPort();
coordinator.onconnect({ ports: [port] });
return port;
}
/** Send a message on behalf of a port (as if the tab sent it). */
function sendMsg(port: MockPort, msg: Record<string, unknown>): void {
port.onmessage!({ data: msg });
}
/** Send the standard init message from a tab. */
function sendInit(port: MockPort): void {
sendMsg(port, { type: 'init', version: '1.0', isDev: false });
}
/**
* Simulate the leader's Worker reporting that the backend is connected.
* In the real flow the Worker sends a 'connect' message → the bridge
* wraps it in __from-worker → the SharedWorker broadcasts it.
*/
function simulateWorkerConnect(leaderPort: MockPort): void {
sendMsg(leaderPort, {
type: '__from-worker',
msg: { type: 'connect' },
});
}
/**
* Set up a fully running budget group with one leader tab.
* Returns the leader port.
*/
function setupBudgetGroup(
coordinator: Coordinator,
budgetId: string,
): MockPort {
const leader = connectTab(coordinator);
sendInit(leader);
leader.postMessage.mockClear();
// Lobby leader → load budget → migrates lobby to real budget
sendMsg(leader, {
id: 'lb-1',
name: 'load-budget',
args: { id: budgetId },
});
// Simulate the Worker reporting connect
simulateWorkerConnect(leader);
// Simulate successful load-budget reply
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'reply', id: 'lb-1', result: {} },
});
leader.postMessage.mockClear();
return leader;
}
// ── Tests ───────────────────────────────────────────────────────────────
describe('SharedWorker coordinator', () => {
let coordinator: Coordinator;
beforeEach(() => {
vi.useFakeTimers();
coordinator = setup();
});
afterEach(() => {
coordinator.destroy();
vi.useRealTimers();
});
// ── Initialization ──────────────────────────────────────────────────
describe('initialization', () => {
it('first tab is elected as lobby leader', () => {
const port = connectTab(coordinator);
sendInit(port);
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: '__lobby',
}),
);
});
it('second tab with no connected backend gets UNASSIGNED role', () => {
const port1 = connectTab(coordinator);
sendInit(port1);
const port2 = connectTab(coordinator);
sendInit(port2);
// Second tab should be UNASSIGNED (backend is booting, not connected)
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'UNASSIGNED',
}),
);
});
it('second tab gets connect message when backend is already running', () => {
setupBudgetGroup(coordinator, 'budget-1');
const port2 = connectTab(coordinator);
sendInit(port2);
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: '__role-change', role: 'UNASSIGNED' }),
);
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
});
it('caches init message for new leaders', () => {
const port1 = connectTab(coordinator);
const initMsg = { type: 'init', version: '2.0', isDev: true };
sendMsg(port1, initMsg);
expect(coordinator.getState().cachedInitMsg).toEqual(initMsg);
});
it('sends cached init failure to late-joining tabs', () => {
// Set up a leader whose Worker reports init failure
const leader = connectTab(coordinator);
sendInit(leader);
simulateWorkerConnect(leader);
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'app-init-failure', error: 'boom' },
});
const port2 = connectTab(coordinator);
sendInit(port2);
expect(port2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'app-init-failure', error: 'boom' }),
);
});
});
// ── Load budget ─────────────────────────────────────────────────────
describe('load-budget', () => {
it('lobby leader migrates to real budget group on load-budget', () => {
const leader = connectTab(coordinator);
sendInit(leader);
leader.postMessage.mockClear();
sendMsg(leader, {
id: 'lb-1',
name: 'load-budget',
args: { id: 'my-budget' },
});
const state = coordinator.getState();
expect(state.budgetGroups.has('__lobby')).toBe(false);
expect(state.budgetGroups.has('my-budget')).toBe(true);
expect(state.portToBudget.get(leader)).toBe('my-budget');
// Should have forwarded load-budget to the Worker
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({ name: 'load-budget' }),
}),
);
});
it('second tab joins existing budget as follower', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
follower.postMessage.mockClear();
sendMsg(follower, {
id: 'lb-2',
name: 'load-budget',
args: { id: 'budget-1' },
});
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'FOLLOWER',
budgetId: 'budget-1',
}),
);
// Should also get connect since backend is already running
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.has(follower)).toBe(true);
});
it('new tab on unloaded budget becomes leader for that budget', () => {
// Set up budget-1 so the lobby is consumed
setupBudgetGroup(coordinator, 'budget-1');
const tab2 = connectTab(coordinator);
sendInit(tab2);
tab2.postMessage.mockClear();
sendMsg(tab2, {
id: 'lb-3',
name: 'load-budget',
args: { id: 'budget-2' },
});
expect(tab2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: 'budget-2',
}),
);
const state = coordinator.getState();
expect(state.budgetGroups.has('budget-2')).toBe(true);
expect(state.budgetGroups.get('budget-2').leaderPort).toBe(tab2);
});
it('leader switching budgets pushes followers off old budget', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
// Add a follower
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
// Leader loads a different budget
sendMsg(leader, {
id: 'lb-switch',
name: 'load-budget',
args: { id: 'budget-2' },
});
// Follower should be pushed to show-budgets
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
});
});
// ── Close budget ────────────────────────────────────────────────────
describe('close-budget', () => {
it('follower gets synthetic reply and leaves group', () => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
sendMsg(follower, { id: 'cb-1', name: 'close-budget' });
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'reply', id: 'cb-1', data: {} }),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.has(follower)).toBe(false);
expect(coordinator.getState().unassignedPorts.has(follower)).toBe(true);
});
it('leader with followers transfers leadership', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
leader.postMessage.mockClear();
sendMsg(leader, { id: 'cb-leader', name: 'close-budget' });
// Leader should get __close-and-transfer
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: 'cb-leader',
}),
);
// Follower should be promoted to leader
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: 'budget-1',
}),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.leaderPort).toBe(follower);
});
it('leader with no followers forwards close to Worker', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
leader.postMessage.mockClear();
sendMsg(leader, { id: 'cb-solo', name: 'close-budget' });
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({ name: 'close-budget' }),
}),
);
});
it('close-budget reply from Worker cleans up group', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
sendMsg(leader, { id: 'cb-solo', name: 'close-budget' });
// Simulate Worker reply
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'reply', id: 'cb-solo', result: {} },
});
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
});
// ── Tab disconnection & failover ────────────────────────────────────
describe('tab disconnection', () => {
it('leader disconnect promotes follower', () => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
// Find current leader to disconnect it
const group = coordinator.getState().budgetGroups.get('budget-1');
const leader = group.leaderPort as MockPort;
// Leader closes tab
sendMsg(leader, { type: 'tab-closing' });
// Follower should be promoted
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
budgetId: 'budget-1',
}),
);
const updatedGroup = coordinator.getState().budgetGroups.get('budget-1');
expect(updatedGroup.leaderPort).toBe(follower);
});
it('last tab leaving removes budget group', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
sendMsg(leader, { type: 'tab-closing' });
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
it('follower disconnect cleans up group membership', () => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.has(follower)).toBe(true);
sendMsg(follower, { type: 'tab-closing' });
expect(group.followers.has(follower)).toBe(false);
});
});
// ── Heartbeat ───────────────────────────────────────────────────────
describe('heartbeat', () => {
it('sends heartbeat pings to all connected ports', () => {
const port1 = connectTab(coordinator);
sendInit(port1);
port1.postMessage.mockClear();
vi.advanceTimersByTime(10_000);
expect(port1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: '__heartbeat-ping' }),
);
});
it('removes ports that do not respond to heartbeat', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
// First heartbeat — marks port as pending
vi.advanceTimersByTime(10_000);
// Second heartbeat — port didn't respond, gets removed
vi.advanceTimersByTime(10_000);
expect(coordinator.getState().connectedPorts.includes(leader)).toBe(
false,
);
});
it('keeps ports that respond with pong', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
// First heartbeat
vi.advanceTimersByTime(10_000);
// Respond with pong
sendMsg(leader, { type: '__heartbeat-pong' });
// Second heartbeat — should NOT remove the port
vi.advanceTimersByTime(10_000);
expect(coordinator.getState().connectedPorts.includes(leader)).toBe(true);
});
});
// ── Worker message routing ──────────────────────────────────────────
describe('Worker message routing', () => {
it('routes reply to the port that sent the request', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
// Follower sends a request
follower.postMessage.mockClear();
sendMsg(follower, { id: 'req-1', name: 'get-budgets' });
// Worker replies
sendMsg(leader, {
type: '__from-worker',
msg: { type: 'reply', id: 'req-1', result: ['b1'] },
});
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'reply',
id: 'req-1',
result: ['b1'],
}),
);
});
it('broadcasts connect to entire group and unassigned ports', () => {
const leader = connectTab(coordinator);
sendInit(leader);
// Load budget (still in lobby migration)
sendMsg(leader, {
id: 'lb-1',
name: 'load-budget',
args: { id: 'budget-1' },
});
const unassigned = connectTab(coordinator);
sendInit(unassigned);
unassigned.postMessage.mockClear();
// Worker reports connected
simulateWorkerConnect(leader);
expect(unassigned.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'connect' }),
);
});
it('forwards fire-and-forget messages (no id) to Worker', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
leader.postMessage.mockClear();
// Fire-and-forget message (no id)
sendMsg(follower, { type: 'client-connected-to-backend' });
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({
type: 'client-connected-to-backend',
}),
}),
);
});
it('unassigned ports route to any connected group', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const unassigned = connectTab(coordinator);
sendInit(unassigned);
leader.postMessage.mockClear();
sendMsg(unassigned, { id: 'req-u', name: 'get-budgets' });
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__to-worker',
msg: expect.objectContaining({ name: 'get-budgets' }),
}),
);
});
});
// ── Budget-replacing operations ─────────────────────────────────────
describe('budget-replacing operations', () => {
it.each(['create-budget', 'import-budget', 'duplicate-budget'])(
'%s from follower gets own temporary Worker',
(opName: string) => {
setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
sendMsg(follower, { id: 'op-1', name: opName });
// Follower should be elected as leader for a temp group
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
}),
);
// The temp group should exist
const state = coordinator.getState();
const tempBudget = state.portToBudget.get(follower);
expect(tempBudget).toMatch(/^__creating-/);
},
);
it('create-budget from leader pushes followers off', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
sendMsg(leader, { id: 'cb-1', name: 'create-budget' });
expect(follower.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.followers.size).toBe(0);
});
it('create-demo-budget evicts existing _demo-budget group', () => {
const demoLeader = setupBudgetGroup(coordinator, '_demo-budget');
const tab2 = connectTab(coordinator);
sendInit(tab2);
tab2.postMessage.mockClear();
sendMsg(tab2, { id: 'cdb-1', name: 'create-demo-budget' });
// Old demo leader should have been evicted
expect(demoLeader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: null,
}),
);
expect(demoLeader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(coordinator.getState().budgetGroups.has('_demo-budget')).toBe(
false,
);
});
it('create-budget with testMode evicts existing _test-budget group', () => {
const testLeader = setupBudgetGroup(coordinator, '_test-budget');
const tab2 = connectTab(coordinator);
sendInit(tab2);
tab2.postMessage.mockClear();
sendMsg(tab2, {
id: 'ctb-1',
name: 'create-budget',
args: { testMode: true },
});
expect(testLeader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: null,
}),
);
});
it('load-prefs reply renames __creating- temp group to real budget ID', () => {
setupBudgetGroup(coordinator, 'budget-1');
const creator = connectTab(coordinator);
sendInit(creator);
sendMsg(creator, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
// Creator sends create-budget → gets temp Worker
sendMsg(creator, { id: 'cb-1', name: 'create-budget' });
const tempId = coordinator.getState().portToBudget.get(creator);
expect(tempId).toMatch(/^__creating-/);
// Simulate backend connect for the temp group
simulateWorkerConnect(creator);
// Track a load-prefs request
sendMsg(creator, { id: 'lp-1', name: 'load-prefs' });
// Worker replies with load-prefs containing the new budget ID
sendMsg(creator, {
type: '__from-worker',
msg: { type: 'reply', id: 'lp-1', result: { id: 'new-budget-123' } },
});
const state = coordinator.getState();
expect(state.budgetGroups.has(tempId)).toBe(false);
expect(state.budgetGroups.has('new-budget-123')).toBe(true);
expect(state.portToBudget.get(creator)).toBe('new-budget-123');
});
});
// ── Delete budget ───────────────────────────────────────────────────
describe('delete-budget', () => {
it('evicts the group running the deleted budget', () => {
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
leader1.postMessage.mockClear();
// Tab on budget-2 deletes budget-1
sendMsg(leader2, {
id: 'db-1',
name: 'delete-budget',
args: { id: 'budget-1' },
});
// budget-1 leader should be evicted
expect(leader1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__close-and-transfer',
requestId: null,
}),
);
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
it('spins up temp Worker when no connected group remains after eviction', () => {
setupBudgetGroup(coordinator, 'budget-1');
// A new unassigned tab tries to delete budget-1
const deleter = connectTab(coordinator);
sendInit(deleter);
deleter.postMessage.mockClear();
sendMsg(deleter, {
id: 'db-1',
name: 'delete-budget',
args: { id: 'budget-1' },
});
// After evicting budget-1, no connected group remains
// → deleter should get a temp Worker
const tempId = coordinator.getState().portToBudget.get(deleter);
expect(tempId).toMatch(/^__deleting-/);
expect(deleter.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: '__role-change',
role: 'LEADER',
}),
);
});
});
// ── Track restore ───────────────────────────────────────────────────
describe('__track-restore', () => {
it('registers a budget restore for reply routing', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
sendMsg(leader, {
type: '__track-restore',
requestId: 'restore-1',
budgetId: 'budget-1',
});
const group = coordinator.getState().budgetGroups.get('budget-1');
expect(group.requestToPort.get('restore-1')).toBe(leader);
expect(group.requestNames.get('restore-1')).toBe('load-budget');
expect(group.requestBudgetIds.get('restore-1')).toBe('budget-1');
});
});
// ── Multiple budgets ────────────────────────────────────────────────
describe('multiple budgets', () => {
it('supports multiple independent budget groups', () => {
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
const state = coordinator.getState();
expect(state.budgetGroups.size).toBe(2);
expect(state.budgetGroups.get('budget-1').leaderPort).toBe(leader1);
expect(state.budgetGroups.get('budget-2').leaderPort).toBe(leader2);
});
it('requests from one group do not leak into another', () => {
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
// Follower joins budget-1
const follower = connectTab(coordinator);
sendInit(follower);
sendMsg(follower, {
id: 'lb-f',
name: 'load-budget',
args: { id: 'budget-1' },
});
follower.postMessage.mockClear();
leader2.postMessage.mockClear();
// Follower sends request — should go to budget-1's leader only
sendMsg(follower, { id: 'req-1', name: 'some-action' });
expect(leader1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: '__to-worker' }),
);
// leader2 should NOT have received this
expect(leader2.postMessage).not.toHaveBeenCalledWith(
expect.objectContaining({ type: '__to-worker' }),
);
});
});
// ── Eviction ────────────────────────────────────────────────────────
describe('evictGroup', () => {
it('evicts followers and leader, sending them to show-budgets', () => {
const leader = setupBudgetGroup(coordinator, 'budget-1');
const f1 = connectTab(coordinator);
sendInit(f1);
sendMsg(f1, {
id: 'lb-f1',
name: 'load-budget',
args: { id: 'budget-1' },
});
const f2 = connectTab(coordinator);
sendInit(f2);
sendMsg(f2, {
id: 'lb-f2',
name: 'load-budget',
args: { id: 'budget-1' },
});
f1.postMessage.mockClear();
f2.postMessage.mockClear();
leader.postMessage.mockClear();
// Another tab triggers deletion of budget-1
const deleter = connectTab(coordinator);
sendInit(deleter);
// Set up a second budget so the deleter has a Worker
sendMsg(deleter, {
id: 'lb-del',
name: 'load-budget',
args: { id: 'budget-other' },
});
simulateWorkerConnect(deleter);
sendMsg(deleter, {
type: '__from-worker',
msg: { type: 'reply', id: 'lb-del', result: {} },
});
sendMsg(deleter, {
id: 'db-1',
name: 'delete-budget',
args: { id: 'budget-1' },
});
// All budget-1 tabs should be pushed to show-budgets
expect(f1.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(f2.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(leader.postMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
);
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
});
});
});

View File

@@ -0,0 +1,738 @@
// Core coordinator logic for multi-tab, multi-budget SharedWorker support.
//
// This module exports a factory so it can be tested independently.
// The SharedWorker entry point (shared-browser-server.js) calls
// createCoordinator() and wires the result to self.onconnect.
// ── Types ────────────────────────────────────────────────────────────────
type ConsoleLevel = 'log' | 'warn' | 'error' | 'info';
/** Minimal port interface (subset of MessagePort used by the coordinator). */
export type CoordinatorPort = {
postMessage(msg: unknown): void;
start(): void;
onmessage: ((event: { data: Record<string, unknown> }) => void) | null;
};
type BudgetGroup = {
leaderPort: CoordinatorPort;
followers: Set<CoordinatorPort>;
backendConnected: boolean;
requestToPort: Map<string, CoordinatorPort>;
requestNames: Map<string, string>;
requestBudgetIds: Map<string, string>;
};
type CoordinatorOptions = {
enableConsoleForwarding?: boolean;
};
// ── Factory ──────────────────────────────────────────────────────────────
export function createCoordinator({
enableConsoleForwarding = false,
}: CoordinatorOptions = {}) {
// ── State ──────────────────────────────────────────────────────────────
const connectedPorts: CoordinatorPort[] = [];
let cachedInitMsg: Record<string, unknown> | null = null;
let lastAppInitFailure: Record<string, unknown> | null = null;
const pendingPongs = new Set<CoordinatorPort>();
const budgetGroups = new Map<string, BudgetGroup>();
const portToBudget = new Map<CoordinatorPort, string>();
const unassignedPorts = new Set<CoordinatorPort>();
// ── Console forwarding ─────────────────────────────────────────────────
if (enableConsoleForwarding) {
const _originalConsole = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
info: console.info.bind(console),
};
function forwardConsole(level: ConsoleLevel, args: unknown[]) {
_originalConsole[level](...args);
const serialized = args.map(a => {
if (a instanceof Error) return a.stack || a.message;
if (typeof a === 'object') {
try {
return JSON.stringify(a);
} catch {
return String(a);
}
}
return String(a);
});
for (const port of connectedPorts) {
port.postMessage({
type: '__shared-worker-console',
level,
args: serialized,
});
}
}
console.log = (...args: unknown[]) => forwardConsole('log', args);
console.warn = (...args: unknown[]) => forwardConsole('warn', args);
console.error = (...args: unknown[]) => forwardConsole('error', args);
console.info = (...args: unknown[]) => forwardConsole('info', args);
}
// ── Helpers ────────────────────────────────────────────────────────────
function createBudgetGroup(leaderPort: CoordinatorPort): BudgetGroup {
return {
leaderPort,
followers: new Set(),
backendConnected: false,
requestToPort: new Map(),
requestNames: new Map(),
requestBudgetIds: new Map(),
};
}
function logState(action: string) {
const groups: string[] = [];
for (const [bid, g] of budgetGroups) {
groups.push(`"${bid}": leader + ${g.followers.size} follower(s)`);
}
console.log(
`[SharedWorker] ${action}${connectedPorts.length} tab(s), ${unassignedPorts.size} unassigned, groups: [${groups.join(', ') || 'none'}]`,
);
}
function broadcastToGroup(
budgetId: string,
msg: unknown,
excludePort: CoordinatorPort | null,
) {
const group = budgetGroups.get(budgetId);
if (!group) return;
if (group.leaderPort !== excludePort) {
group.leaderPort.postMessage(msg);
}
for (const p of group.followers) {
if (p !== excludePort) {
p.postMessage(msg);
}
}
}
function broadcastToAllInGroup(budgetId: string, msg: unknown) {
broadcastToGroup(budgetId, msg, null);
}
// ── Heartbeat ──────────────────────────────────────────────────────────
const heartbeatId = setInterval(() => {
for (const port of [...pendingPongs]) {
pendingPongs.delete(port);
removePort(port);
}
for (const port of connectedPorts) {
pendingPongs.add(port);
port.postMessage({ type: '__heartbeat-ping' });
}
}, 10_000);
// ── Port removal & leader failover ────────────────────────────────────
function removePort(port: CoordinatorPort) {
const idx = connectedPorts.indexOf(port);
if (idx !== -1) connectedPorts.splice(idx, 1);
unassignedPorts.delete(port);
const budgetId = portToBudget.get(port);
portToBudget.delete(port);
if (!budgetId) return;
const group = budgetGroups.get(budgetId);
if (!group) return;
if (port === group.leaderPort) {
if (group.followers.size > 0) {
const candidate = group.followers.values().next()
.value as CoordinatorPort;
group.followers.delete(candidate);
console.log(
`[SharedWorker] Leader left budget "${budgetId}" — promoting follower`,
);
electLeader(budgetId, candidate, budgetId);
} else {
console.log(
`[SharedWorker] Last tab left budget "${budgetId}" — removing group`,
);
budgetGroups.delete(budgetId);
}
} else {
group.followers.delete(port);
for (const [id, p] of group.requestToPort) {
if (p === port) {
group.requestToPort.delete(id);
group.requestNames.delete(id);
}
}
}
}
// ── Leader election ───────────────────────────────────────────────────
function electLeader(
budgetId: string,
port: CoordinatorPort,
budgetToRestore?: string | null,
pendingMsg?: Record<string, unknown> | null,
) {
let group = budgetGroups.get(budgetId);
if (!group) {
group = createBudgetGroup(port);
budgetGroups.set(budgetId, group);
} else {
group.leaderPort = port;
group.backendConnected = false;
group.requestToPort.clear();
group.requestNames.clear();
group.requestBudgetIds.clear();
}
const prevBudget = portToBudget.get(port);
if (prevBudget && prevBudget !== budgetId) {
removePortFromGroup(port, prevBudget);
}
portToBudget.set(port, budgetId);
unassignedPorts.delete(port);
console.log(
`[SharedWorker] Elected leader for "${budgetId}" (${group.followers.size} follower(s))`,
);
port.postMessage({
type: '__role-change',
role: 'LEADER',
budgetId,
});
if (cachedInitMsg) {
port.postMessage({
type: '__become-leader',
initMsg: cachedInitMsg,
budgetToRestore: budgetToRestore || null,
pendingMsg: pendingMsg || null,
});
}
}
function addFollower(budgetId: string, port: CoordinatorPort) {
const group = budgetGroups.get(budgetId);
if (!group) return;
const prevBudget = portToBudget.get(port);
if (prevBudget && prevBudget !== budgetId) {
removePortFromGroup(port, prevBudget);
}
group.followers.add(port);
portToBudget.set(port, budgetId);
unassignedPorts.delete(port);
port.postMessage({
type: '__role-change',
role: 'FOLLOWER',
budgetId,
});
if (group.backendConnected) {
port.postMessage({ type: 'connect' });
}
}
function removePortFromGroup(port: CoordinatorPort, budgetId: string) {
const group = budgetGroups.get(budgetId);
if (!group) return;
group.followers.delete(port);
for (const [id, p] of group.requestToPort) {
if (p === port) {
group.requestToPort.delete(id);
group.requestNames.delete(id);
}
}
}
function evictGroup(budgetId: string, excludePort: CoordinatorPort) {
const group = budgetGroups.get(budgetId);
if (!group) return;
const evicted: CoordinatorPort[] = [];
for (const p of group.followers) {
if (p !== excludePort) {
p.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(p);
unassignedPorts.add(p);
evicted.push(p);
}
}
group.followers.clear();
if (group.leaderPort && group.leaderPort !== excludePort) {
group.leaderPort.postMessage({
type: '__close-and-transfer',
requestId: null,
});
group.leaderPort.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(group.leaderPort);
unassignedPorts.add(group.leaderPort);
evicted.push(group.leaderPort);
}
budgetGroups.delete(budgetId);
if (evicted.length > 0) {
console.log(
`[SharedWorker] Evicted ${evicted.length} tab(s) from budget "${budgetId}"`,
);
}
}
// ── Budget lifecycle helpers ──────────────────────────────────────────
function handleBudgetLoaded(
leaderPort: CoordinatorPort,
oldGroupId: string,
newBudgetId: string,
) {
const oldGroup = budgetGroups.get(oldGroupId);
if (!oldGroup) return;
if (oldGroupId !== newBudgetId) {
const existingTarget = budgetGroups.get(newBudgetId);
if (existingTarget && existingTarget !== oldGroup) {
console.warn(
`[SharedWorker] handleBudgetLoaded: conflict — group "${newBudgetId}" already exists`,
);
return;
}
budgetGroups.delete(oldGroupId);
budgetGroups.set(newBudgetId, oldGroup);
portToBudget.set(leaderPort, newBudgetId);
for (const p of oldGroup.followers) {
portToBudget.set(p, newBudgetId);
}
console.log(
`[SharedWorker] Budget loaded: "${newBudgetId}" (leader + ${oldGroup.followers.size} follower(s))`,
);
}
logState(`Budget "${newBudgetId}" ready`);
}
function handleBudgetClosed(closingPort: CoordinatorPort, budgetId: string) {
const group = budgetGroups.get(budgetId);
if (!group) return;
if (closingPort === group.leaderPort && group.followers.size === 0) {
budgetGroups.delete(budgetId);
portToBudget.delete(closingPort);
unassignedPorts.add(closingPort);
logState(`Budget "${budgetId}" closed (no tabs remain)`);
}
}
function migrateLobbyLeader(
port: CoordinatorPort,
budgetId: string,
pendingMsg: Record<string, unknown>,
) {
const lobbyGroup = budgetGroups.get('__lobby');
if (lobbyGroup && port === lobbyGroup.leaderPort) {
budgetGroups.delete('__lobby');
budgetGroups.set(budgetId, lobbyGroup);
portToBudget.set(port, budgetId);
lobbyGroup.requestToPort.set(pendingMsg.id as string, port);
lobbyGroup.requestNames.set(
pendingMsg.id as string,
pendingMsg.name as string,
);
lobbyGroup.requestBudgetIds.set(pendingMsg.id as string, budgetId);
lobbyGroup.leaderPort.postMessage({
type: '__to-worker',
msg: pendingMsg,
});
port.postMessage({
type: '__role-change',
role: 'LEADER',
budgetId,
});
logState(`Lobby leader now on budget "${budgetId}"`);
}
}
// ── Connection handler ────────────────────────────────────────────────
function onconnect(e: { ports: CoordinatorPort[] }) {
const port = e.ports[0];
connectedPorts.push(port);
unassignedPorts.add(port);
logState('Tab connected');
port.onmessage = function (event: { data: Record<string, unknown> }) {
try {
const msg = event.data;
const portBudget = portToBudget.get(port);
const group = portBudget ? budgetGroups.get(portBudget) : null;
// ── Tab lifecycle ──────────────────────────────────────────
if (msg.type === 'tab-closing') {
pendingPongs.delete(port);
removePort(port);
logState('Tab closed');
return;
}
if (msg.type === '__heartbeat-pong') {
pendingPongs.delete(port);
return;
}
// ── Initialization ─────────────────────────────────────────
if (msg.type === 'init') {
cachedInitMsg = msg;
if (lastAppInitFailure) {
port.postMessage(lastAppInitFailure);
} else {
let anyConnected = false;
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
anyConnected = true;
break;
}
}
if (anyConnected) {
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
port.postMessage({ type: 'connect' });
} else if (budgetGroups.size > 0) {
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
} else {
electLeader('__lobby', port);
}
}
return;
}
// ── Leader tab forwarding Worker messages back ─────────────
if (msg.type === '__from-worker') {
if (!group || port !== group.leaderPort) return;
const workerMsg = msg.msg as Record<string, unknown>;
if (workerMsg.type === 'reply' || workerMsg.type === 'error') {
const targetPort = group.requestToPort.get(workerMsg.id as string);
if (targetPort) {
targetPort.postMessage(workerMsg);
const name = group.requestNames.get(workerMsg.id as string);
if (workerMsg.type === 'reply' && name === 'load-budget') {
const budgetId = group.requestBudgetIds.get(
workerMsg.id as string,
);
if (budgetId) {
group.requestBudgetIds.delete(workerMsg.id as string);
handleBudgetLoaded(port, portBudget!, budgetId);
}
}
if (workerMsg.type === 'reply' && name === 'close-budget') {
handleBudgetClosed(targetPort, portBudget!);
}
if (
workerMsg.type === 'reply' &&
name === 'load-prefs' &&
portBudget &&
portBudget.startsWith('__creating-') &&
workerMsg.result &&
(workerMsg.result as Record<string, unknown>).id
) {
handleBudgetLoaded(
port,
portBudget,
(workerMsg.result as Record<string, unknown>).id as string,
);
}
group.requestToPort.delete(workerMsg.id as string);
group.requestNames.delete(workerMsg.id as string);
}
} else if (workerMsg.type === 'connect') {
group.backendConnected = true;
broadcastToAllInGroup(portBudget!, workerMsg);
for (const p of unassignedPorts) {
p.postMessage(workerMsg);
}
} else if (workerMsg.type === 'app-init-failure') {
lastAppInitFailure = workerMsg;
broadcastToAllInGroup(portBudget!, workerMsg);
} else {
broadcastToAllInGroup(portBudget!, workerMsg);
}
return;
}
// ── Leader tab registering a budget restore ────────────────
if (msg.type === '__track-restore') {
if (group) {
group.requestToPort.set(msg.requestId as string, port);
group.requestNames.set(msg.requestId as string, 'load-budget');
group.requestBudgetIds.set(
msg.requestId as string,
msg.budgetId as string,
);
}
return;
}
// ── Request interception & routing ─────────────────────────
if (
msg.name === 'load-budget' &&
msg.args &&
(msg.args as Record<string, unknown>).id
) {
const budgetId = (msg.args as Record<string, unknown>).id as string;
const existingGroup = budgetGroups.get(budgetId);
if (existingGroup && existingGroup.backendConnected) {
addFollower(budgetId, port);
existingGroup.requestToPort.set(msg.id as string, port);
existingGroup.requestNames.set(
msg.id as string,
msg.name as string,
);
existingGroup.requestBudgetIds.set(msg.id as string, budgetId);
existingGroup.leaderPort.postMessage({
type: '__to-worker',
msg,
});
logState(`Tab joined budget "${budgetId}" as follower`);
return;
}
if (existingGroup && !existingGroup.backendConnected) {
addFollower(budgetId, port);
existingGroup.requestToPort.set(msg.id as string, port);
existingGroup.requestNames.set(
msg.id as string,
msg.name as string,
);
existingGroup.requestBudgetIds.set(msg.id as string, budgetId);
existingGroup.leaderPort.postMessage({
type: '__to-worker',
msg,
});
logState(
`Tab joined budget "${budgetId}" as follower (backend booting)`,
);
return;
}
if (portBudget === '__lobby') {
migrateLobbyLeader(port, budgetId, msg);
} else if (group && port === group.leaderPort) {
for (const p of group.followers) {
p.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(p);
unassignedPorts.add(p);
}
if (group.followers.size > 0) {
console.log(
`[SharedWorker] Leader switching budgets — pushed ${group.followers.size} follower(s) off "${portBudget}"`,
);
group.followers.clear();
}
group.requestToPort.set(msg.id as string, port);
group.requestNames.set(msg.id as string, msg.name as string);
group.requestBudgetIds.set(msg.id as string, budgetId);
group.leaderPort.postMessage({ type: '__to-worker', msg });
} else {
electLeader(budgetId, port, null, msg);
const newGroup = budgetGroups.get(budgetId);
if (newGroup) {
newGroup.requestToPort.set(msg.id as string, port);
newGroup.requestNames.set(msg.id as string, msg.name as string);
newGroup.requestBudgetIds.set(msg.id as string, budgetId);
}
logState(`Tab became leader for new budget "${budgetId}"`);
}
return;
}
// close-budget: handle leader vs follower
if (msg.name === 'close-budget' && group) {
if (port === group.leaderPort) {
if (group.followers.size > 0) {
const newLeader = group.followers.values().next()
.value as CoordinatorPort;
group.followers.delete(newLeader);
console.log(
`[SharedWorker] Leader closing budget "${portBudget}" but ${group.followers.size + 1} tab(s) remain — transferring`,
);
port.postMessage({
type: '__close-and-transfer',
requestId: msg.id,
});
electLeader(portBudget!, newLeader, portBudget);
portToBudget.delete(port);
unassignedPorts.add(port);
logState(`Leadership transferred for "${portBudget}"`);
return;
}
group.requestToPort.set(msg.id as string, port);
group.requestNames.set(msg.id as string, msg.name as string);
group.leaderPort.postMessage({ type: '__to-worker', msg });
return;
} else {
group.followers.delete(port);
portToBudget.delete(port);
unassignedPorts.add(port);
port.postMessage({ type: 'reply', id: msg.id, data: {} });
logState(`Follower left budget "${portBudget}"`);
return;
}
}
// delete-budget: if another group is running this budget, evict it
if (msg.name === 'delete-budget' && msg.args) {
const targetId = (msg.args as Record<string, unknown>).id as string;
if (targetId && budgetGroups.has(targetId)) {
evictGroup(targetId, port);
logState(`Evicted group for deleted budget "${targetId}"`);
}
let hasConnected = false;
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
hasConnected = true;
break;
}
}
if (!hasConnected) {
const tempId = '__deleting-' + Date.now();
electLeader(tempId, port, null, msg);
const newGroup = budgetGroups.get(tempId);
if (newGroup && msg.id) {
newGroup.requestToPort.set(msg.id as string, port);
newGroup.requestNames.set(msg.id as string, msg.name as string);
}
logState(`Tab became leader for budget deletion ("${tempId}")`);
return;
}
}
// Budget-replacing operations
if (
msg.name === 'create-budget' ||
msg.name === 'create-demo-budget' ||
msg.name === 'import-budget' ||
msg.name === 'duplicate-budget' ||
msg.name === 'delete-budget'
) {
if (msg.name === 'create-demo-budget') {
evictGroup('_demo-budget', port);
} else if (
msg.name === 'create-budget' &&
msg.args &&
(msg.args as Record<string, unknown>).testMode
) {
evictGroup('_test-budget', port);
}
if (group && port === group.leaderPort) {
for (const p of group.followers) {
p.postMessage({ type: 'push', name: 'show-budgets' });
portToBudget.delete(p);
unassignedPorts.add(p);
}
if (group.followers.size > 0) {
console.log(
`[SharedWorker] Budget-replacing "${msg.name}" — pushed ${group.followers.size} tab(s) off "${portBudget}"`,
);
group.followers.clear();
}
} else {
if (group) {
group.followers.delete(port);
portToBudget.delete(port);
unassignedPorts.add(port);
}
const tempId = '__creating-' + Date.now();
electLeader(tempId, port, null, msg);
const newGroup = budgetGroups.get(tempId);
if (newGroup && msg.id) {
newGroup.requestToPort.set(msg.id as string, port);
newGroup.requestNames.set(msg.id as string, msg.name as string);
}
logState(`Tab became leader for budget creation ("${tempId}")`);
return;
}
}
// ── Default: track and forward to leader ───────────────────
let targetGroup = group;
if (!targetGroup) {
for (const [, g] of budgetGroups) {
if (g.backendConnected) {
targetGroup = g;
break;
}
}
}
if (targetGroup) {
if (msg.id) {
targetGroup.requestToPort.set(msg.id as string, port);
if (msg.name) {
targetGroup.requestNames.set(
msg.id as string,
msg.name as string,
);
}
if (
msg.name === 'load-budget' &&
msg.args &&
(msg.args as Record<string, unknown>).id
) {
targetGroup.requestBudgetIds.set(
msg.id as string,
(msg.args as Record<string, unknown>).id as string,
);
}
}
targetGroup.leaderPort.postMessage({ type: '__to-worker', msg });
}
} catch (error) {
console.error('[SharedWorker] Error in message handler:', error);
}
};
port.start();
}
// ── Public API ────────────────────────────────────────────────────────
function destroy() {
clearInterval(heartbeatId);
}
function getState() {
return {
connectedPorts,
cachedInitMsg,
lastAppInitFailure,
budgetGroups,
portToBudget,
unassignedPorts,
};
}
return { onconnect, destroy, getState };
}

View File

@@ -0,0 +1,11 @@
// SharedWorker entry point for multi-tab, multi-budget support.
//
// All coordinator logic lives in shared-browser-server-core.ts
// This file simply creates a coordinator with console forwarding
// enabled and wires it to the SharedWorkerGlobalScope.
import { createCoordinator } from './shared-browser-server-core';
const coordinator = createCoordinator({ enableConsoleForwarding: true });
(self as unknown as { onconnect: typeof coordinator.onconnect }).onconnect =
coordinator.onconnect;

View File

@@ -1,4 +1,4 @@
// oxlint-disable-next-line eslint/no-restricted-imports -- fix me
// oxlint-disable-next-line eslint/no-restricted-imports, actual/no-cross-package-imports -- fix me
import { ConfigurationPage } from '@actual-app/web/e2e/page-models/configuration-page';
import { expect } from '@playwright/test';

View File

@@ -30,7 +30,7 @@
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.2",
"@types/react": "^19.2.5"
"@types/react": "^19.2.14"
},
"browserslist": {
"production": [

View File

@@ -12,5 +12,6 @@ module.exports = {
'prefer-const': require('./rules/prefer-const'),
'no-anchor-tag': require('./rules/no-anchor-tag'),
'no-react-default-import': require('./rules/no-react-default-import'),
'no-cross-package-imports': require('./rules/no-cross-package-imports'),
},
};

View File

@@ -0,0 +1,201 @@
import { runClassic } from 'eslint-vitest-rule-tester';
import * as rule from '../no-cross-package-imports';
void runClassic(
'no-cross-package-imports',
rule,
{
valid: [
// @actual-app/web can import @actual-app/core (declared dep)
{
code: 'import { something } from "@actual-app/core";',
filename: 'packages/desktop-client/src/components/Test.tsx',
},
// @actual-app/web can import @actual-app/components (declared dep)
{
code: 'import { Button } from "@actual-app/components";',
filename: 'packages/desktop-client/src/components/Test.tsx',
},
// External packages are always allowed
{
code: 'import React from "react";',
filename: 'packages/component-library/src/Button.tsx',
},
// Relative imports within same package are allowed
{
code: 'import { helper } from "./utils";',
filename: 'packages/component-library/src/Button.tsx',
},
// Relative import to parent within same package is allowed
{
code: 'import { helper } from "../../shared/utils";',
filename: 'packages/loot-core/src/server/deep/file.ts',
},
// Files outside packages/ are not checked
{
code: 'import { something } from "@actual-app/core";',
filename: 'scripts/build.js',
},
// @actual-app/api can import @actual-app/core (declared dep)
{
code: 'import { something } from "@actual-app/core";',
filename: 'packages/api/src/index.ts',
},
// @actual-app/api can import @actual-app/crdt (declared dep)
{
code: 'import { something } from "@actual-app/crdt";',
filename: 'packages/api/src/index.ts',
},
// require() with declared dep is allowed
{
code: 'const core = require("@actual-app/core");',
filename: 'packages/desktop-client/src/test.js',
},
// export { foo } from declared dep is allowed
{
code: 'export { something } from "@actual-app/core";',
filename: 'packages/desktop-client/src/index.ts',
},
// export * from declared dep is allowed
{
code: 'export * from "@actual-app/core";',
filename: 'packages/desktop-client/src/index.ts',
},
],
invalid: [
// @actual-app/components has no internal deps — cannot import @actual-app/core
{
code: 'import { something } from "@actual-app/core";',
filename: 'packages/component-library/src/Button.tsx',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/core',
},
},
],
},
// @actual-app/components cannot import @actual-app/web
{
code: 'import { Page } from "@actual-app/web";',
filename: 'packages/component-library/src/Button.tsx',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/web',
},
},
],
},
// @actual-app/core cannot import @actual-app/web (not in its deps)
{
code: 'import { Component } from "@actual-app/web";',
filename: 'packages/loot-core/src/server/main.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/core',
importedPackage: '@actual-app/web',
},
},
],
},
// @actual-app/core cannot import @actual-app/components
{
code: 'import { Button } from "@actual-app/components";',
filename: 'packages/loot-core/src/server/main.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/core',
importedPackage: '@actual-app/components',
},
},
],
},
// require() with undeclared dep is also blocked
{
code: 'const web = require("@actual-app/web");',
filename: 'packages/component-library/src/test.js',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/web',
},
},
],
},
// Relative import crossing into another package is blocked
{
code: 'import * as theme from "../../desktop-client/src/style/themes/dark";',
filename: 'packages/component-library/.storybook/preview.tsx',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/web',
},
},
],
},
// export * from undeclared dep is blocked
{
code: 'export * from "@actual-app/web";',
filename: 'packages/component-library/src/index.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/web',
},
},
],
},
// export { foo } from undeclared dep is blocked
{
code: 'export { Page } from "@actual-app/web";',
filename: 'packages/component-library/src/index.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/components',
importedPackage: '@actual-app/web',
},
},
],
},
// export * from @actual-app/web in loot-core is blocked (not in its deps)
{
code: 'export * from "@actual-app/web";',
filename: 'packages/loot-core/src/index.ts',
errors: [
{
messageId: 'noCrossPackageImport',
data: {
currentPackage: '@actual-app/core',
importedPackage: '@actual-app/web',
},
},
],
},
],
},
{
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
},
},
);

View File

@@ -0,0 +1,237 @@
const fs = require('fs');
const path = require('path');
// Module-level cache: packageDir -> { name: string, allowedDeps: Set<string>, dirName: string }
const packageCache = new Map();
// Reverse map: directory name -> package name (e.g. 'desktop-client' -> '@actual-app/web')
const dirToPackageName = new Map();
// Find monorepo root by walking up from this file's directory
let monorepoRoot = null;
function findMonorepoRoot() {
if (monorepoRoot !== null) return monorepoRoot;
let dir = __dirname;
while (dir !== path.dirname(dir)) {
if (
fs.existsSync(path.join(dir, 'packages')) &&
fs.existsSync(path.join(dir, 'package.json'))
) {
monorepoRoot = dir;
return dir;
}
dir = path.dirname(dir);
}
monorepoRoot = false;
return false;
}
/**
* Computes a monorepo-root-relative path for reliable package detection.
* This avoids false matches when the checkout itself is under a directory named "packages/".
*/
function toMonorepoRelative(filename) {
const monoRoot = findMonorepoRoot();
if (!monoRoot) return null;
const normalized = filename.replace(/\\/g, '/');
const normalizedRoot = monoRoot.replace(/\\/g, '/');
if (normalized.startsWith(normalizedRoot + '/')) {
return normalized.substring(normalizedRoot.length + 1);
}
// Resolve relative paths (e.g. from test harnesses) against monorepo root
const resolved = path.resolve(monoRoot, filename).replace(/\\/g, '/');
if (resolved.startsWith(normalizedRoot + '/')) {
return resolved.substring(normalizedRoot.length + 1);
}
return null;
}
/**
* Finds the package info for a given filename by locating the nearest
* packages/<dir>/package.json in the file path, anchored to the monorepo root.
*/
function getPackageInfo(filename) {
const relative = toMonorepoRelative(filename);
if (!relative) return null;
const match = relative.match(/^packages\/([^/]+)\//);
if (!match) return null;
const packageDir = match[1];
if (packageCache.has(packageDir)) return packageCache.get(packageDir);
const monoRoot = findMonorepoRoot();
const pkgJsonPath = path.join(
monoRoot,
'packages',
packageDir,
'package.json',
);
try {
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
const allowed = new Set();
for (const depType of [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
]) {
if (pkgJson[depType]) {
for (const dep of Object.keys(pkgJson[depType])) {
if (dep.startsWith('@actual-app/')) {
allowed.add(dep);
}
}
}
}
const info = {
name: pkgJson.name,
allowedDeps: allowed,
dirName: packageDir,
};
packageCache.set(packageDir, info);
dirToPackageName.set(packageDir, pkgJson.name);
return info;
} catch {
return null;
}
}
/**
* Extracts the @actual-app/<name> package name from an import source.
* Returns null if the source is not an @actual-app/ import.
*/
function extractActualPackageName(importSource) {
const match = importSource.match(/^(@actual-app\/[^/]+)/);
return match ? match[1] : null;
}
/**
* For a relative import, resolves which packages/<dir> it lands in.
* Returns the target directory name if it crosses into a different package, null otherwise.
*/
function resolveRelativeCrossPackage(importSource, filename, currentDirName) {
if (!importSource.startsWith('.')) return null;
const relative = toMonorepoRelative(filename);
if (!relative) return null;
const fileDir = path.posix.dirname(relative);
const resolved = path.posix.normalize(path.posix.join(fileDir, importSource));
const match = resolved.match(/^packages\/([^/]+)\//);
if (!match) return null;
const targetDir = match[1];
if (targetDir === currentDirName) return null;
return targetDir;
}
/**
* Gets the package name for a directory, loading its package.json if needed.
*/
function getPackageNameForDir(targetDir) {
if (dirToPackageName.has(targetDir)) return dirToPackageName.get(targetDir);
// Force loading the package info which populates dirToPackageName
const info = getPackageInfo(`packages/${targetDir}/dummy.ts`);
return info ? info.name : null;
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Disallow importing from other packages unless declared as a dependency in package.json',
},
fixable: null,
schema: [],
messages: {
noCrossPackageImport:
'Package "{{currentPackage}}" does not declare a dependency on "{{importedPackage}}". Add it to dependencies in package.json or remove the import.',
},
},
create(context) {
const filename = context.getFilename();
const pkgInfo = getPackageInfo(filename);
// Not inside a recognized package — nothing to check
if (!pkgInfo) return {};
function checkImportSource(node, source) {
if (typeof source !== 'string') return;
// Resolve the target package name from either @actual-app/* or relative imports
let importedPackage = extractActualPackageName(source);
if (!importedPackage) {
const targetDir = resolveRelativeCrossPackage(
source,
filename,
pkgInfo.dirName,
);
if (!targetDir) return;
importedPackage = getPackageNameForDir(targetDir) || targetDir;
}
if (importedPackage === pkgInfo.name) return;
if (!pkgInfo.allowedDeps.has(importedPackage)) {
context.report({
node,
messageId: 'noCrossPackageImport',
data: {
currentPackage: pkgInfo.name,
importedPackage,
},
});
}
}
return {
ImportDeclaration(node) {
checkImportSource(node, node.source.value);
},
// require() calls
CallExpression(node) {
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'require' &&
node.arguments.length > 0 &&
node.arguments[0].type === 'Literal'
) {
checkImportSource(node, node.arguments[0].value);
}
},
// Dynamic import()
ImportExpression(node) {
if (node.source.type === 'Literal') {
checkImportSource(node, node.source.value);
}
},
// export { foo } from '...'
ExportNamedDeclaration(node) {
if (node.source) {
checkImportSource(node, node.source.value);
}
},
// export * from '...'
ExportAllDeclaration(node) {
if (node.source) {
checkImportSource(node, node.source.value);
}
},
};
},
};

View File

@@ -83,7 +83,7 @@
"@rschedule/json-tools": "^1.5.0",
"@rschedule/standard-date-adapter": "^1.5.0",
"absurd-sql": "0.0.54",
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
"adm-zip": "^0.5.16",
"better-sqlite3": "^12.6.2",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",

View File

@@ -806,20 +806,24 @@ handlers['api/payee-rules-get'] = async function ({ id }) {
handlers['api/rule-create'] = withMutation(async function ({ rule }) {
checkFileOpen();
try {
return await handlers['rule-add'](rule);
} catch (error) {
throw APIError('Failed creating a new rule', error);
const addedRule = await handlers['rule-add'](rule);
if ('error' in addedRule) {
throw APIError('Failed creating a new rule', addedRule.error);
}
return addedRule;
});
handlers['api/rule-update'] = withMutation(async function ({ rule }) {
checkFileOpen();
try {
return await handlers['rule-update'](rule);
} catch (error) {
throw APIError('Failed updating the rule', error);
const updatedRule = await handlers['rule-update'](rule);
if ('error' in updatedRule) {
throw APIError('Failed updating the rule', updatedRule.error);
}
return updatedRule;
});
handlers['api/rule-delete'] = withMutation(async function (id) {

View File

@@ -408,6 +408,11 @@ async function createBudget({
id = testBudgetId || TEST_BUDGET_ID;
if (await fs.exists(fs.getBudgetDir(id))) {
// Close the budget first if it's currently loaded, so the
// database is properly shut down before we remove its files.
if (prefs.getPrefs()?.id === id) {
await closeBudget();
}
await fs.removeDirRecursively(fs.getBudgetDir(id));
}
} else {

View File

@@ -1,7 +1,6 @@
// @ts-strict-ignore
import { logger } from '../../platform/server/log';
import type {
PayeeEntity,
RuleActionEntity,
RuleEntity,
TransactionEntity,
@@ -77,9 +76,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
@@ -92,9 +91,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>,
@@ -103,20 +102,24 @@ async function ruleValidate(
return { error };
}
async function addRule(rule: Omit<RuleEntity, 'id'>): Promise<RuleEntity> {
async function addRule(
rule: Omit<RuleEntity, 'id'>,
): Promise<{ error: ValidationError } | RuleEntity> {
const error = validateRule(rule);
if (error) {
throw error;
return { error };
}
const id = await rules.insertRule(rule);
return { id, ...rule };
}
async function updateRule(rule: RuleEntity): Promise<RuleEntity> {
async function updateRule(
rule: RuleEntity,
): Promise<{ error: ValidationError } | RuleEntity> {
const error = validateRule(rule);
if (error) {
throw error;
return { error };
}
await rules.updateRule(rule);
@@ -124,32 +127,24 @@ async function updateRule(rule: RuleEntity): Promise<RuleEntity> {
}
async function deleteRule(id: RuleEntity['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;
return rules.deleteRule(id);
}
async function deleteAllRules(ids: Array<RuleEntity['id']>): Promise<void> {
const failedIds: Array<RuleEntity['id']> = [];
async function deleteAllRules(
ids: Array<RuleEntity['id']>,
): Promise<{ someDeletionsFailed: boolean }> {
let someDeletionsFailed = false;
await batchMessages(async () => {
for (const id of ids) {
const isSuccess = await rules.deleteRule(id);
if (!isSuccess) {
failedIds.push(id);
const res = await rules.deleteRule(id);
if (res === false) {
someDeletionsFailed = true;
}
}
});
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.`,
);
}
return { someDeletionsFailed };
}
async function applyRuleActions({
@@ -170,8 +165,8 @@ async function addRulePayeeRename({
fromNames,
to,
}: {
fromNames: Array<PayeeEntity['name']>;
to: PayeeEntity['id'];
fromNames: string[];
to: string;
}): Promise<string> {
return rules.updatePayeeRenameRule(fromNames, to);
}

View File

@@ -17,7 +17,6 @@ import {
recurConfigToRSchedule,
} from '../../shared/schedules';
import type { RuleConditionEntity, ScheduleEntity } from '../../types/models';
import type { WithRequired } from '../../types/util';
import { addTransactions } from '../accounts/sync';
import { createApp } from '../app';
import { aqlQuery } from '../aql';
@@ -253,13 +252,10 @@ async function checkIfScheduleExists(name, scheduleId) {
}
export async function createSchedule({
schedule = {},
schedule = null,
conditions = [],
}: {
schedule?: Partial<Omit<ScheduleEntity, 'id'>>;
conditions?: RuleConditionEntity[];
}): Promise<ScheduleEntity['id']> {
const scheduleId = uuidv4();
} = {}): Promise<ScheduleEntity['id']> {
const scheduleId = schedule?.id || uuidv4();
const { date: dateCond } = extractScheduleConds(conditions);
if (dateCond == null) {
@@ -271,12 +267,14 @@ export async function createSchedule({
const nextDate = getNextDate(dateCond);
const nextDateRepr = nextDate ? toDateRepr(nextDate) : null;
if (schedule.name) {
if (await checkIfScheduleExists(schedule.name, scheduleId)) {
throw new Error('Cannot create schedules with the same name');
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;
}
} else {
schedule.name = null;
}
// Create the rule here based on the info
@@ -312,7 +310,7 @@ export async function updateSchedule({
conditions,
resetNextDate,
}: {
schedule: WithRequired<Partial<ScheduleEntity>, 'id'>;
schedule: Partial<ScheduleEntity> & Pick<ScheduleEntity, 'id'>;
conditions?: RuleConditionEntity[];
resetNextDate?: boolean;
}) {

View File

@@ -284,6 +284,59 @@ describe('Merging success', () => {
});
});
it('preserves schedule link from dropped transaction when kept transaction has none', async () => {
const t1 = await db.insertTransaction({
account: 'one',
amount: 5,
date: '2025-01-01',
imported_id: 'imported_1',
});
const t2 = await db.insertTransaction({
...transaction2,
schedule: 'schedule-1',
});
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t1);
const transactions = await getAllTransactions();
expect(transactions.length).toBe(1);
expect(transactions[0].schedule).toBe('schedule-1');
});
it('preserves schedule link from kept transaction when both have schedules', async () => {
const t1 = await db.insertTransaction({
...transaction1,
imported_id: 'imported_1',
schedule: 'schedule-keep',
});
const t2 = await db.insertTransaction({
...transaction2,
schedule: 'schedule-drop',
});
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t1);
const transactions = await getAllTransactions();
expect(transactions.length).toBe(1);
expect(transactions[0].schedule).toBe('schedule-keep');
});
it('preserves schedule link when merging manual scheduled with banksynced', async () => {
// Manual transaction linked to a schedule
const t1 = await db.insertTransaction({
...transaction1,
schedule: 'schedule-1',
});
// Bank-synced transaction (kept due to imported_id priority)
const t2 = await db.insertTransaction({
...transaction2,
imported_id: 'imported_2',
});
expect(await mergeTransactions([{ id: t1 }, { id: t2 }])).toBe(t2);
const transactions = await getAllTransactions();
expect(transactions.length).toBe(1);
expect(transactions[0].schedule).toBe('schedule-1');
});
it('preserves split categories when merging split transaction with uncategorized imported transaction', async () => {
// Create a manual transaction with splits
const manualParent = await db.insertTransaction({

View File

@@ -81,6 +81,7 @@ export async function mergeTransactions(
notes: keep.notes || drop.notes,
cleared: keep.cleared || drop.cleared,
reconciled: keep.reconciled || drop.reconciled,
schedule: keep.schedule || drop.schedule,
} as unknown as TransactionEntity);
} else {
// Normal merge without subtransactions
@@ -91,6 +92,7 @@ export async function mergeTransactions(
notes: keep.notes || drop.notes,
cleared: keep.cleared || drop.cleared,
reconciled: keep.reconciled || drop.reconciled,
schedule: keep.schedule || drop.schedule,
} as TransactionEntity);
}

View File

@@ -13,7 +13,6 @@ import { getApproxNumberThreshold, sortNumbers } from '../../shared/rules';
import { ungroupTransaction } from '../../shared/transactions';
import { fastSetMerge, partitionByField } from '../../shared/util';
import type {
PayeeEntity,
RuleActionEntity,
RuleEntity,
TransactionEntity,
@@ -785,10 +784,7 @@ function* getOneOfSetterRules(
return null;
}
export async function updatePayeeRenameRule(
fromNames: Array<PayeeEntity['name']>,
to: PayeeEntity['id'],
) {
export async function updatePayeeRenameRule(fromNames: string[], to: string) {
const renameRule = getOneOfSetterRules('pre', 'imported_payee', 'payee', {
actionValue: to,
}).next().value;

View File

@@ -1,12 +1,7 @@
// @ts-strict-ignore
import { t } from 'i18next';
import type {
FieldValueTypes,
RuleActionEntity,
RuleConditionEntity,
RuleConditionOp,
} from '../types/models';
import type { FieldValueTypes, RuleConditionOp } from '../types/models';
// For now, this info is duplicated from the backend. Figure out how
// to share it later.
@@ -95,11 +90,11 @@ const FIELD_INFO = {
const fieldInfo: FieldInfoConstraint = FIELD_INFO;
export const FIELD_TYPES = new Map(
Object.entries(FIELD_INFO).map(
([field, info]) =>
[field as unknown as keyof FieldValueTypes, info.type] as const,
),
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 function isValidOp(field: keyof FieldValueTypes, op: RuleConditionOp) {
@@ -109,7 +104,6 @@ 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)
);
}
@@ -298,21 +292,24 @@ export function sortNumbers(num1, num2) {
return [num2, num1];
}
export function parseConditions(
item: RuleConditionEntity,
): RuleConditionEntity & { error?: string | null } {
export function parse(item) {
if (item.op === 'set-split-amount') {
if (item.options.method === 'fixed-amount') {
return { ...item };
}
return item;
}
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:
@@ -321,74 +318,7 @@ export function parseConditions(
return { ...item, error: null };
}
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 {
export function unparse({ error: _error, inputKey: _inputKey, ...item }) {
if (item.op === 'set-split-amount') {
if (item.options.method === 'fixed-amount') {
return {
@@ -398,27 +328,25 @@ export function unparseActions({
if (item.options.method === 'fixed-percent') {
return {
...item,
value: item.value && parseFloat(`${item.value}`),
value: item.value && parseFloat(item.value),
};
}
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:
switch (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:
}
return item;

View File

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

View File

@@ -121,7 +121,16 @@ app.post('/change-password', (req, res) => {
const session = validateSession(req, res);
if (!session) return;
if (getActiveLoginMethod() !== 'password') {
if (!isAdmin(session.user_id)) {
res.status(403).send({
status: 'error',
reason: 'forbidden',
details: 'permission-not-found',
});
return;
}
if (session.auth_method !== 'password') {
res.status(403).send({
status: 'error',
reason: 'forbidden',

View File

@@ -21,10 +21,10 @@ const deleteUser = userId => {
getAccountDb().mutate('DELETE FROM users WHERE id = ?', [userId]);
};
const createSession = (userId, sessionToken) => {
const createSession = (userId, sessionToken, authMethod = null) => {
getAccountDb().mutate(
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
[sessionToken, userId, Math.floor(Date.now() / 1000) + 60 * 60], // Expire in 1 hour (stored in seconds)
'INSERT INTO sessions (token, user_id, expires_at, auth_method) VALUES (?, ?, ?, ?)',
[sessionToken, userId, Math.floor(Date.now() / 1000) + 60 * 60, authMethod], // Expire in 1 hour (stored in seconds)
);
};
@@ -46,17 +46,28 @@ const clearAuth = () => {
};
describe('/change-password', () => {
let userId, sessionToken;
let adminUserId,
basicUserId,
adminPasswordToken,
adminOpenidToken,
basicPasswordToken;
beforeEach(() => {
userId = uuidv4();
sessionToken = generateSessionToken();
createUser(userId, 'testuser', ADMIN_ROLE);
createSession(userId, sessionToken);
adminUserId = uuidv4();
basicUserId = uuidv4();
adminPasswordToken = generateSessionToken();
adminOpenidToken = generateSessionToken();
basicPasswordToken = generateSessionToken();
createUser(adminUserId, 'admin', ADMIN_ROLE);
createUser(basicUserId, 'basic', BASIC_ROLE);
createSession(adminUserId, adminPasswordToken, 'password');
createSession(adminUserId, adminOpenidToken, 'openid');
createSession(basicUserId, basicPasswordToken, 'password');
});
afterEach(() => {
deleteUser(userId);
deleteUser(adminUserId);
deleteUser(basicUserId);
clearAuth();
});
@@ -70,12 +81,28 @@ describe('/change-password', () => {
expect(res.body).toHaveProperty('reason', 'unauthorized');
});
it('should return 403 when active auth method is openid', async () => {
insertAuthRow('openid', 1);
it('should return 403 when user is not an admin', async () => {
bootstrapPassword('oldpassword');
const res = await request(app)
.post('/change-password')
.set('x-actual-token', sessionToken)
.set('x-actual-token', basicPasswordToken)
.send({ password: 'newpassword' });
expect(res.statusCode).toEqual(403);
expect(res.body).toEqual({
status: 'error',
reason: 'forbidden',
details: 'permission-not-found',
});
});
it('should return 403 when admin session uses openid auth method', async () => {
bootstrapPassword('oldpassword');
const res = await request(app)
.post('/change-password')
.set('x-actual-token', adminOpenidToken)
.send({ password: 'newpassword' });
expect(res.statusCode).toEqual(403);
@@ -86,24 +113,24 @@ describe('/change-password', () => {
});
});
it('should return 400 when active method is password but password is empty', async () => {
it('should return 400 when admin password-auth session sends empty password', async () => {
bootstrapPassword('oldpassword');
const res = await request(app)
.post('/change-password')
.set('x-actual-token', sessionToken)
.set('x-actual-token', adminPasswordToken)
.send({ password: '' });
expect(res.statusCode).toEqual(400);
expect(res.body).toEqual({ status: 'error', reason: 'invalid-password' });
});
it('should return 200 when active method is password and new password is valid', async () => {
it('should return 200 when admin with password-auth session sends valid password', async () => {
bootstrapPassword('oldpassword');
const res = await request(app)
.post('/change-password')
.set('x-actual-token', sessionToken)
.set('x-actual-token', adminPasswordToken)
.send({ password: 'newpassword' });
expect(res.statusCode).toEqual(200);

View File

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

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [MikesGlitch]
---
Fix multi-tab sync by sharing a single backend via SharedWorker

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [okxint]
---
Fix schedule link being lost when merging transactions

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Add admin and password authentication requirements for changing passwords in sessions.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Fix adm-zip dependency resolution in `@actual-app/core`

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Bump react types

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Add `no-cross-package-imports` ESLint rule to enforce package dependency boundaries in the monorepo.

View File

@@ -59,7 +59,7 @@ __metadata:
"@storybook/addon-docs": "npm:^10.2.7"
"@storybook/react-vite": "npm:^10.2.7"
"@svgr/cli": "npm:^8.1.0"
"@types/react": "npm:^19.2.5"
"@types/react": "npm:^19.2.14"
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
"@vitejs/plugin-react": "npm:^6.0.0"
eslint-plugin-storybook: "npm:^10.2.7"
@@ -95,7 +95,7 @@ __metadata:
"@types/pegjs": "npm:^0.10.6"
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
absurd-sql: "npm:0.0.54"
adm-zip: "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch"
adm-zip: "npm:^0.5.16"
assert: "npm:^2.1.0"
better-sqlite3: "npm:^12.6.2"
browserify-zlib: "npm:^0.2.0"
@@ -238,7 +238,7 @@ __metadata:
"@types/lodash": "npm:^4"
"@types/pikaday": "npm:^1.7.10"
"@types/promise-retry": "npm:^1.1.6"
"@types/react": "npm:^19.2.5"
"@types/react": "npm:^19.2.14"
"@types/react-dom": "npm:^19.2.3"
"@types/react-modal": "npm:^3.16.3"
"@typescript/native-preview": "npm:^7.0.0-dev.20260309.1"
@@ -10197,7 +10197,7 @@ __metadata:
languageName: node
linkType: hard
"@types/react@npm:*, @types/react@npm:^19.2.5":
"@types/react@npm:*":
version: 19.2.5
resolution: "@types/react@npm:19.2.5"
dependencies:
@@ -10206,6 +10206,15 @@ __metadata:
languageName: node
linkType: hard
"@types/react@npm:^19.2.14":
version: 19.2.14
resolution: "@types/react@npm:19.2.14"
dependencies:
csstype: "npm:^3.2.2"
checksum: 10/fbff239089ee64b6bd9b00543594db498278b06de527ef1b0f71bb0eb09cc4445a71b5dd3c0d3d0257255c4eed94406be40a74ad4a987ade8a8d5dd65c82bc5f
languageName: node
linkType: hard
"@types/resolve@npm:1.20.2":
version: 1.20.2
resolution: "@types/resolve@npm:1.20.2"
@@ -11343,13 +11352,6 @@ __metadata:
languageName: node
linkType: hard
"adm-zip@npm:0.5.10":
version: 0.5.10
resolution: "adm-zip@npm:0.5.10"
checksum: 10/c5ab79b77114d8277f0cbfd6cca830198d6c7ee4971f6960f48e08cd2375953b11dc71729b7f396abd51d2d6cce8c862fad185ea90cb2c84ab5161c37ed1b099
languageName: node
linkType: hard
"adm-zip@npm:0.5.16":
version: 0.5.16
resolution: "adm-zip@npm:0.5.16"
@@ -14280,6 +14282,13 @@ __metadata:
languageName: node
linkType: hard
"csstype@npm:^3.2.2":
version: 3.2.3
resolution: "csstype@npm:3.2.3"
checksum: 10/ad41baf7e2ffac65ab544d79107bf7cd1a4bb9bab9ac3302f59ab4ba655d5e30942a8ae46e10ba160c6f4ecea464cc95b975ca2fefbdeeacd6ac63f12f99fe1f
languageName: node
linkType: hard
"csv-parse@npm:^6.1.0":
version: 6.1.0
resolution: "csv-parse@npm:6.1.0"
@@ -15187,7 +15196,7 @@ __metadata:
"@easyops-cn/docusaurus-search-local": "npm:^0.52.3"
"@mdx-js/react": "npm:^3.1.1"
"@r74tech/docusaurus-plugin-panzoom": "npm:^2.4.0"
"@types/react": "npm:^19.2.5"
"@types/react": "npm:^19.2.14"
clsx: "npm:^2.1.1"
prism-react-renderer: "npm:^2.4.1"
react: "npm:^19.2.4"