Compare commits

..

12 Commits

Author SHA1 Message Date
github-actions[bot]
8393a65d7a [AI] Fix applyAppUpdate hanging in dev mode
In dev mode browser-preload's updateSW was () => undefined, so
applyAppUpdate() — which calls updateSW() and then awaits a
deliberately never-resolving promise (waiting for the SW-driven page
reload) — hung the renderer instead of refreshing. In prod the page
is replaced by the new service worker, so the never-resolving await is
fine. The dev path now triggers a plain window.location.reload() so
the page reloads and the never-settling await is irrelevant, matching
prod's effective behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:52:50 +01:00
github-actions[bot]
6b351eafc7 [AI] Restore bank-factory glob to ./banks/*_*.{ts,js}
Re-apply the glob widening originally added in 145868f9d. It was
reverted in 531b1a191 because the desktop e2e was failing — that
failure is now traced to the rebuild-electron breakage (fixed in
6e8ac0784), not to this glob. Mirroring migrations.ts so future TS
bank handlers are picked up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:32:46 +01:00
github-actions[bot]
43fba254b5 [AI] Restore CSP unsafe-eval comment
Bring back the explanatory comment that was stripped diagnostically in
99682268c. Now that the desktop e2e regression is traced to
rebuild-electron and not to anything in this branch, we can keep the
documentation noting why 'unsafe-eval' is retained in both CSP branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:29:24 +01:00
github-actions[bot]
6e8ac07846 [AI] Make rebuild-electron actually rebuild better-sqlite3
PR #7712 simplified rebuild-electron to just `electron-rebuild -f -o
better-sqlite3,bcrypt` from the repo root. Two problems with that:

  1. Without `-m`, electron-rebuild scans the root workspace's package.json
     for native deps. better-sqlite3 isn't a direct root dep — it lives
     under packages/sync-server/ — so the scan returns no candidates and
     the rebuild silently no-ops.
  2. Without --build-from-source, electron-rebuild defers to
     prebuild-install, which downloads a stale prebuilt binary keyed off
     better-sqlite3's package.json (ABI 127) instead of recompiling
     against Electron 39's bundled Node ABI 140. The download succeeds
     and "Rebuild Complete" prints, but the resulting `better_sqlite3.node`
     can't `dlopen` inside Electron's utility process — sync-server
     crashes immediately on db init, the renderer's startSyncServer IPC
     never resolves, and the e2e test hangs on "Configure your server".

Point -m at packages/desktop-electron (which transitively pulls in
better-sqlite3 and bcrypt via @actual-app/sync-server) and force a real
compile via --build-from-source. Verified locally: better-sqlite3
rebuilds to darwin-arm64-140 and the desktop e2e onboarding test passes
in 6s instead of hanging for 60s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 22:23:46 +01:00
github-actions[bot]
99682268cc [AI] Strip CSP comment to restore identical state to 9513c1e16
The desktop e2e has been failing despite my prior commits being a strict
revert (only difference was a 2-line comment, which can't change runtime).
Removing even the comment so the branch matches 9513c1e16's relevant
files exactly, to isolate whether the failure is from the master merge
or from CI-environment drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:55:53 +01:00
Matiss Janis Aboltins
749aee4f44 Merge branch 'master' into matiss/crdt-source-loading 2026-05-05 21:29:45 +01:00
github-actions[bot]
531b1a1914 [AI] Revert bank-factory glob change
Widening the glob to ./banks/*_*.{ts,js} broke the desktop e2e tests in
CI even though every current handler is .js and the brace expansion
matches no .ts files locally. Reverting to ./banks/*_*.js — the change
had no behavioural benefit since there are no TS handlers, so the
nitpick isn't worth chasing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:23:32 +01:00
github-actions[bot]
f08490052f [AI] Restore 'unsafe-eval' in production CSP for Electron
The Electron app needs `'unsafe-eval'` at runtime, so revert the dev-only
restriction and keep `'unsafe-eval'` in both branches. Comment updated to
record the actual reason instead of marking it as removable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:10:48 +01:00
github-actions[bot]
145868f9da [AI] Address review feedback
- sync-server CSP: drop 'unsafe-eval' from the production script-src;
  the bundle has no genuine eval/new Function usage (only a defensive
  branch in setimmediate's polyfill that's never hit). Keep it on the
  dev branch where Vite's HMR runtime relies on it. Add a comment so
  it's obvious which branch needs it and why.
- bank-factory: widen the loader glob to ./banks/*_*.{ts,js} so
  TypeScript handlers are discovered too, mirroring migrations.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:04:34 +01:00
github-actions[bot]
9513c1e160 [AI] Restructure sync-server to build with Vite
Replace the hand-rolled tsgo + add-import-extensions + copy-static-assets
+ runtime loader pipeline with a single Vite SSR build. Bundles every
entry (app, bin/actual-server, scripts/*) and inlines @actual-app/crdt
source so Node never has to resolve TS at runtime — the
MODULE_TYPELESS_PACKAGE_JSON warning that surfaced via crdt's source
exports is gone. Migrations and bank handlers move from readdir-based
dynamic imports to import.meta.glob; messages.sql becomes a ?raw import.

Drop loader.mjs, register-loader.mjs, start.mjs, and
bin/add-import-extensions.mjs. Electron's startSyncServer() forks
build/app.js directly. publishConfig.imports goes away (subpath imports
are resolved at build time and don't appear in the bundle).

In dev (start:server-dev) sync-server proxies to Vite, so loosen the CSP
to allow Vite's inline preamble script and HMR websocket — production
CSP is unchanged. desktop-client skips registerSW() in dev (and disables
vite-plugin-pwa's devOptions) so stale cached assets don't override
edits between page loads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:32:22 +01:00
github-actions[bot]
e661951753 Add release notes for PR #7702 2026-05-04 13:44:21 +01:00
github-actions[bot]
fc5e836a02 [AI] Load @actual-app/crdt from source in dev, only bundle for publish
@actual-app/crdt's local exports now point at src/index.ts so consumers
(sync-server, loot-core, desktop-client) never see a stale Vite bundle.
publishConfig keeps the dist/ mapping for npm consumers. crdt's
tsconfig switches to bundler module resolution to match the rest of
the workspace (no extensions in source imports).

Sync-server's existing extension-resolution loader is extended to also
handle directory-index imports (./crdt → ./crdt/index.ts), and the
standalone `start` / `start-monitor` scripts now invoke Node with
--import ./register-loader.mjs so the loader is in place before crdt's
source resolves.

Electron's utilityProcess.fork accepts execArgv but doesn't actually
preload --import modules, so a new packages/sync-server/start.mjs
bootstrap entry registers the loader imperatively and then dynamic-
imports build/app.js. desktop-electron's startSyncServer() points the
fork at start.mjs. sync-server's "files" array now ships start.mjs,
register-loader.mjs and loader.mjs so packaged Electron / npm
consumers actually receive them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:32:18 +01:00
42 changed files with 252 additions and 615 deletions

View File

@@ -65,10 +65,6 @@ jobs:
ref: ${{ steps.pr.outputs.head_sha }}
persist-credentials: false
- name: Trust workspace directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
shell: bash
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -91,6 +87,9 @@ jobs:
- name: Create patch with PNG changes only
id: create-patch
run: |
# Trust the repository directory (required for container environments)
git config --global --add safe.directory "$GITHUB_WORKSPACE"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

View File

@@ -15,7 +15,8 @@
"vi": "readonly",
"backend": "readonly",
"importScripts": "readonly",
"FS": "readonly"
"FS": "readonly",
"__APP_VERSION__": "readonly"
},
"rules": {
// Import sorting

View File

@@ -52,7 +52,7 @@
"playwright": "yarn workspace @actual-app/web run playwright",
"vrt": "yarn workspace @actual-app/web run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -o better-sqlite3,bcrypt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
"rebuild-node": "yarn workspace @actual-app/core rebuild",
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",

View File

@@ -10,14 +10,10 @@
"!dist/**/*.spec.d.ts",
"!dist/**/*.spec.d.ts.map"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"default": "./dist/index.js"
}
".": "./src/index.ts"
},
"publishConfig": {
"exports": {
@@ -25,7 +21,9 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
},
"main": "dist/index.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build:node": "vite build",

View File

@@ -4,8 +4,8 @@
"rootDir": "./src",
"composite": true,
"target": "ES2021",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ES2022",
"moduleResolution": "bundler",
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,

View File

@@ -335,10 +335,17 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
resolve(true);
};
});
const updateSW = registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
// Skip SW registration in dev so stale cached assets don't override edits
// between page loads. Plugin code that needs a SW can register one itself.
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
// SW lifecycle to swap the page — fall back to a plain reload so callers
// don't hang on the never-resolving promise inside applyAppUpdate.
const updateSW = IS_DEV
? () => window.location.reload()
: registerSW({
immediate: true,
onNeedRefresh: markUpdateReadyForDownload,
});
global.Actual = {
IS_DEV,

View File

@@ -647,13 +647,6 @@ type ApplyBudgetActionPayload =
args: {
category: CategoryEntity['id'];
};
}
| {
type: 'copy-until-year-end';
month: string;
args: {
category: CategoryEntity['id'];
};
};
export function useBudgetActions() {
@@ -783,12 +776,6 @@ export function useBudgetActions() {
category: args.category,
});
return null;
case 'copy-until-year-end':
await send('budget/copy-until-year-end', {
month,
category: args.category,
});
return null;
default:
throw new Error(`Unknown budget action type: ${String(type)}`);
}

View File

@@ -13,13 +13,11 @@ type BudgetMenuProps = Omit<
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyUntilYearEnd: () => void;
};
export function BudgetMenu({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyUntilYearEnd,
...props
}: BudgetMenuProps) {
const { t } = useTranslation();
@@ -41,9 +39,6 @@ export function BudgetMenu({
case 'apply-single-category-template':
onApplyBudgetTemplate?.();
break;
case 'copy-until-year-end':
onCopyUntilYearEnd?.();
break;
default:
throw new Error(`Unrecognized menu item: ${name}`);
}
@@ -70,10 +65,6 @@ export function BudgetMenu({
name: 'set-single-12-avg',
text: t('Set to yearly average'),
},
{
name: 'copy-until-year-end',
text: t('Copy until year end'),
},
...(isGoalTemplatesEnabled
? [
{

View File

@@ -344,14 +344,6 @@ export const CategoryMonth = memo(function CategoryMonth({
message: t(`Budget template applied.`),
});
}}
onCopyUntilYearEnd={() => {
onMenuAction(month, 'copy-until-year-end', {
category: category.id,
});
showUndoNotification({
message: t(`Budget copied until year end.`),
});
}}
/>
</Popover>
</View>

View File

@@ -79,84 +79,61 @@ export function BudgetCell<
);
const onOpenCategoryBudgetMenu = useCallback(() => {
const sharedOptions = {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: (amount: number) => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to last month's budgeted amount.`,
});
},
onSetMonthsAverage: (numberOfMonths: number) => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes ?? undefined,
});
},
};
if (budgetType === 'envelope') {
dispatch(
pushModal({
modal: {
name: 'envelope-budget-menu',
options: sharedOptions,
},
}),
);
} else {
dispatch(
pushModal({
modal: {
name: 'tracking-budget-menu',
options: {
...sharedOptions,
onCopyUntilYearEnd: () => {
onBudgetAction(month, 'copy-until-year-end', {
category: category.id,
});
showUndoNotification({
message: t('{{categoryName}} budget copied until year end.', {
categoryName: category.name,
}),
});
},
const modalBudgetType = budgetType === 'envelope' ? 'envelope' : 'tracking';
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const;
dispatch(
pushModal({
modal: {
name: categoryBudgetMenuModal,
options: {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
amount,
});
showUndoNotification({
message: `${category.name} budget has been updated to ${format(amount, 'financial')}.`,
});
},
onCopyLastMonthAverage: () => {
onBudgetAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to last month's budgeted amount.`,
});
},
onSetMonthsAverage: numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
onBudgetAction(month, `set-single-${numberOfMonths}-avg`, {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget has been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
});
},
onApplyBudgetTemplate: () => {
onBudgetAction(month, 'apply-single-category-template', {
category: category.id,
});
showUndoNotification({
message: `${category.name} budget templates have been applied.`,
pre: categoryNotes ?? undefined,
});
},
},
}),
);
}
},
}),
);
}, [
budgetType,
category.id,
@@ -168,7 +145,6 @@ export function BudgetCell<
showUndoNotification,
onEditNotes,
format,
t,
]);
return (

View File

@@ -73,6 +73,21 @@ export function ConfirmTransactionEditModal({
out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciledTransfer' ? (
<Block>
<Trans>
This transfer has a linked transaction in another account that
is reconciled. Duplicating it may bring that account's
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'batchDuplicateWithReconciled' ? (
<Block>
<Trans>
Duplicating reconciled transactions may bring your
reconciliation out of balance.
</Trans>
</Block>
) : confirmReason === 'editReconciled' ? (
<Block>
<Trans>

View File

@@ -42,7 +42,6 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onCopyUntilYearEnd,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
@@ -201,7 +200,6 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
onCopyUntilYearEnd={onCopyUntilYearEnd}
/>
)}
</>

View File

@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { send } from '@actual-app/core/platform/client/connection';
import * as monthUtils from '@actual-app/core/shared/months';
import { computeSchedulePreviewTransactions } from '@actual-app/core/shared/schedules';
import { ungroupTransactions } from '@actual-app/core/shared/transactions';
import type { IntegerAmount } from '@actual-app/core/shared/util';
@@ -101,13 +100,6 @@ export function usePreviewTransactions({
),
}));
// re-sort in case rule actions have changed the dates
withDefaults.sort(
(a, b) =>
monthUtils.parseDate(b.date).getTime() -
monthUtils.parseDate(a.date).getTime() || a.amount - b.amount,
);
const ungroupedTransactions = ungroupTransactions(withDefaults);
setPreviewTransactions(ungroupedTransactions);

View File

@@ -297,11 +297,7 @@ export function useTransactionBatchActions() {
added: transactions.reduce(
(newTransactions: TransactionEntity[], trans: TransactionEntity) => {
return newTransactions.concat(
realizeTempTransactions(ungroupTransaction(trans)).map(t => ({
...t,
cleared: false,
reconciled: false,
})),
realizeTempTransactions(ungroupTransaction(trans)),
);
},
[],
@@ -313,7 +309,11 @@ export function useTransactionBatchActions() {
onSuccess?.(ids);
};
await onConfirmDuplicate(ids);
await checkForReconciledTransactions(
ids,
'batchDuplicateWithReconciled',
onConfirmDuplicate,
);
};
const onBatchDelete = async ({ ids, onSuccess }: BatchDeleteProps) => {
@@ -445,6 +445,7 @@ export function useTransactionBatchActions() {
> = {
batchDeleteWithReconciled: 'batchDeleteWithReconciledTransfer',
batchEditWithReconciled: 'batchEditWithReconciledTransfer',
batchDuplicateWithReconciled: 'batchDuplicateWithReconciledTransfer',
};
const checkForReconciledTransactions = async (

View File

@@ -32,6 +32,8 @@ export type ConfirmTransactionEditReason =
| 'batchDeleteWithReconciledTransfer'
| 'batchEditWithReconciled'
| 'batchEditWithReconciledTransfer'
| 'batchDuplicateWithReconciled'
| 'batchDuplicateWithReconciledTransfer'
| 'editReconciled'
| 'unlockReconciled'
| 'deleteReconciled';
@@ -354,7 +356,6 @@ export type Modal =
onCopyLastMonthAverage: () => void;
onSetMonthsAverage: (numberOfMonths: number) => void;
onApplyBudgetTemplate: () => void;
onCopyUntilYearEnd: () => void;
onEditNotes: (id: NoteEntity['id'], month: string) => void;
};
}

View File

@@ -376,7 +376,9 @@ export default defineConfig(async ({ mode, command }) => {
// swSrc: `service-worker/plugin-sw.js`,
// },
devOptions: {
enabled: true, // We need service worker in dev mode to work with plugins
// Disabled: caches stale assets across reloads in dev. Plugin
// code that explicitly needs a SW can register one itself.
enabled: false,
type: 'module',
},
workbox: {

View File

@@ -239,12 +239,11 @@ async function startSyncServer() {
),
};
const serverPath = path.join(
// require.resolve will recursively search up the workspace for the module
path.dirname(require.resolve('@actual-app/sync-server/package.json')),
'build',
'app.js',
// require.resolve will recursively search up the workspace for the module
const syncServerRoot = path.dirname(
require.resolve('@actual-app/sync-server/package.json'),
);
const serverPath = path.join(syncServerRoot, 'build/app.js');
const webRoot = path.join(
// require.resolve will recursively search up the workspace for the module

View File

@@ -59,7 +59,7 @@ This release introduces powerful new reporting capabilities as well as numerous
- [#7269](https://github.com/actualbudget/actual/pull/7269) Show confirmation dialog when editing/duplicating/deleting transfers where the other half is reconciled — thanks @matt-fidd
- [#7270](https://github.com/actualbudget/actual/pull/7270) Fix transaction quick search incorrectly treating "?" and "%" as wildcards, causing all transactions to be returned instead of only those matching the literal character — thanks @eduardopio03
- [#7283](https://github.com/actualbudget/actual/pull/7283) Standardise ledger scrolling when using keyboard shortcuts — thanks @JSkinnerUK
- [#7284](https://github.com/actualbudget/actual/pull/7284) Handle normalisation of some common non-latin diacritic characters. — thanks @JSkinnerUK
- [#7284](https://github.com/actualbudget/actual/pull/7284) Handle normalisation of some common non-latin diacritic characters, ł, ø, ß, œ. — thanks @JSkinnerUK
- [#7296](https://github.com/actualbudget/actual/pull/7296) Fix Net Worth graph showing a time-interval less than specified — thanks @emiltb
- [#7304](https://github.com/actualbudget/actual/pull/7304) Fix UUID showing when switching filter operators — thanks @sk10727-a11y
- [#7324](https://github.com/actualbudget/actual/pull/7324) Fixes transaction query by tag when tag starts with $ — thanks @gust0717

View File

@@ -13,7 +13,6 @@ for it to be added, your project must have a proper README file.
The following are implementations of bank syncing using the Actual API. For instructions on using them, see the respective repositories.
- **Akahu and Up bank sync to Actual Budget** - https://github.com/tim-smart/actualbudget-sync
- **Enable Actual: Import transactions from European banks using Enable Banking** - https://github.com/2manyvcos/enable-actual
- **ICS Cards Holland CVS exporter** - https://github.com/IeuanK/ICS-Exporter/
- **Lunch Flow: Import transactions from GoCardless, MX, Finicity, Finverse, and more** - https://github.com/lunchflow/actual-flow
- **MoneyMan an israel banks importer** - https://github.com/daniel-hauser/moneyman

View File

@@ -66,14 +66,6 @@ This will affect your spent totals as if the spending didn't happen.
The spending will only show up in the month that the rollover stops.
:::
## Copy Until Year End
The **Copy until year end** option in the per-category budget menu copies the current month's budgeted amount to every later month of the same calendar year, overwriting any existing value for that category in those months.
To use it, click the budget amount for a category to open the budget menu, then select **Copy until year end**. Months in subsequent calendar years are not affected.
This is useful when you add or change a recurring expense and want to update all future planned months without clicking through each one individually. For example, if you realise in March that your grocery budget should be $300 for the rest of the year, you can set it once and copy it to April through December in one click.
## Working With the Budget
All the non-budgeting features of Actual can be used with the **Tracking Budget** the same as the **Envelope Budget**.

View File

@@ -5,7 +5,6 @@ import * as db from '#server/db';
import * as sheet from '#server/sheet';
import {
copyUntilYearEnd,
coverOverbudgeted,
getSheetValue,
setBudget,
@@ -13,122 +12,6 @@ import {
} from './actions';
import * as budget from './base';
describe('copyUntilYearEnd', () => {
beforeEach(global.emptyDatabase());
afterEach(global.emptyDatabase());
async function setupDatabase() {
await db.insertCategoryGroup({
id: 'income-group',
name: 'Income',
is_income: 1,
});
await db.insertCategory({
id: 'income-cat',
name: 'Income',
cat_group: 'income-group',
is_income: 1,
});
await db.insertCategoryGroup({
id: 'group1',
name: 'group1',
is_income: 0,
});
await db.insertCategory({
id: 'cat1',
name: 'cat1',
cat_group: 'group1',
is_income: 0,
});
await sheet.loadSpreadsheet(db);
await budget.createBudget(['2024-01', '2024-02', '2024-03']);
}
it('copies the current month budget to all future months in the same year', async () => {
await setupDatabase();
await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 });
await setBudget({ category: 'cat1', month: '2024-02', amount: 1000 });
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
await sheet.waitOnSpreadsheet();
await copyUntilYearEnd({ month: '2024-01', category: 'cat1' });
await sheet.waitOnSpreadsheet();
expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
});
it('overwrites future months including those with zero budgets', async () => {
await setupDatabase();
await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 });
// 2024-02 intentionally left at 0
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
await sheet.waitOnSpreadsheet();
await copyUntilYearEnd({ month: '2024-01', category: 'cat1' });
await sheet.waitOnSpreadsheet();
expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
});
it('does not affect months before or equal to the current month', async () => {
await setupDatabase();
await setBudget({ category: 'cat1', month: '2024-01', amount: 1000 });
await setBudget({ category: 'cat1', month: '2024-02', amount: 5000 });
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
await sheet.waitOnSpreadsheet();
await copyUntilYearEnd({ month: '2024-02', category: 'cat1' });
await sheet.waitOnSpreadsheet();
expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(1000);
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
});
it('copies the current month budget to future months in tracking budget mode', async () => {
await setupDatabase();
db.runQuery(
`INSERT INTO preferences (id, value) VALUES ('budgetType', 'tracking')`,
);
await setBudget({ category: 'cat1', month: '2024-01', amount: 5000 });
await setBudget({ category: 'cat1', month: '2024-02', amount: 1000 });
await setBudget({ category: 'cat1', month: '2024-03', amount: 2000 });
await sheet.waitOnSpreadsheet();
await copyUntilYearEnd({ month: '2024-01', category: 'cat1' });
await sheet.waitOnSpreadsheet();
expect(await getSheetValue('budget202401', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202402', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202403', 'budget-cat1')).toBe(5000);
});
it('does not copy to months beyond the current calendar year', async () => {
await setupDatabase();
await budget.createBudget(['2024-11', '2024-12', '2025-01']);
await setBudget({ category: 'cat1', month: '2024-11', amount: 5000 });
await setBudget({ category: 'cat1', month: '2024-12', amount: 1000 });
await setBudget({ category: 'cat1', month: '2025-01', amount: 2000 });
await sheet.waitOnSpreadsheet();
await copyUntilYearEnd({ month: '2024-11', category: 'cat1' });
await sheet.waitOnSpreadsheet();
expect(await getSheetValue('budget202411', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202412', 'budget-cat1')).toBe(5000);
expect(await getSheetValue('budget202501', 'budget-cat1')).toBe(2000); // unchanged
});
});
describe('coverOverbudgeted', () => {
beforeEach(global.emptyDatabase());
afterEach(global.emptyDatabase());

View File

@@ -591,31 +591,6 @@ export async function transferCategory({
});
}
export async function copyUntilYearEnd({
month,
category,
}: {
month: string;
category: string;
}): Promise<void> {
const amount = await getSheetValue(
monthUtils.sheetForMonth(month),
'budget-' + category,
);
const yearEnd = monthUtils.getYearEnd(month);
const { createdMonths } = sheet.get().meta();
const futureMonths = [...(createdMonths as Set<string>)]
.filter(m => m > month && m <= yearEnd)
.sort();
await batchMessages(async () => {
for (const futureMonth of futureMonths) {
void setBudget({ category, month: futureMonth, amount });
}
});
}
export async function setCategoryCarryover({
startMonth,
category,

View File

@@ -39,7 +39,6 @@ export type BudgetHandlers = {
'budget/transfer-available': typeof actions.transferAvailable;
'budget/cover-overbudgeted': typeof actions.coverOverbudgeted;
'budget/transfer-category': typeof actions.transferCategory;
'budget/copy-until-year-end': typeof actions.copyUntilYearEnd;
'budget/set-carryover': typeof actions.setCategoryCarryover;
'budget/reset-income-carryover': typeof actions.resetIncomeCarryover;
'get-categories': typeof getCategories;
@@ -124,10 +123,6 @@ app.method(
'budget/transfer-category',
mutator(undoable(actions.transferCategory)),
);
app.method(
'budget/copy-until-year-end',
mutator(undoable(actions.copyUntilYearEnd)),
);
app.method(
'budget/set-carryover',
mutator(undoable(actions.setCategoryCarryover)),

View File

@@ -11,7 +11,7 @@ function throwIfNot200(res: Response, text: string) {
throw new PostError(res.status === 500 ? 'internal' : text);
}
const contentType = res.headers.get('Content-Type') ?? '';
const contentType = res.headers.get('Content-Type');
if (contentType.toLowerCase().indexOf('application/json') !== -1) {
const json = JSON.parse(text);
throw new PostError(json.reason);

View File

@@ -1,7 +1,6 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
const args = process.argv;
@@ -54,11 +53,7 @@ if (values.help) {
}
if (values.version) {
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageJsonPath = resolve(__dirname, '../../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
console.log('v' + packageJson.version);
console.log('v' + __APP_VERSION__);
process.exit();
}

View File

@@ -1,146 +0,0 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs';
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, extname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const buildDir = resolve(__dirname, '../build');
const packageRoot = resolve(__dirname, '..');
const packageJson = JSON.parse(
readFileSync(join(packageRoot, 'package.json'), 'utf-8'),
);
// publishConfig.imports already has ./build/src/ paths with .js extensions
const importsMap = packageJson.publishConfig?.imports || {};
// Sort wildcard patterns longest-prefix-first so more specific patterns
// (e.g. #app-gocardless/services/tests/*) match before broader ones (#app-gocardless/*)
const wildcardEntries = Object.entries(importsMap)
.filter(([p]) => p.includes('*'))
.sort(([a], [b]) => b.length - a.length);
async function getAllJsFiles(dir) {
const files = [];
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...(await getAllJsFiles(fullPath)));
} else if (entry.isFile() && extname(entry.name) === '.js') {
files.push(fullPath);
}
}
return files;
}
function resolveImportPath(importPath, fromFile) {
const baseDir = dirname(fromFile);
const resolvedPath = resolve(baseDir, importPath);
// Check if it's a file with .js extension
if (existsSync(`${resolvedPath}.js`)) {
return `${importPath}.js`;
}
// Check if it's a directory with index.js
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
return `${importPath}/index.js`;
}
// Verify the file exists before adding extension
if (!existsSync(`${resolvedPath}.js`)) {
console.warn(
`Warning: Could not resolve import '${importPath}' from ${relative(buildDir, fromFile)}`,
);
}
// Default: assume it's a file and add .js
return `${importPath}.js`;
}
function toRelativePath(target, fromFile) {
const absoluteTarget = resolve(packageRoot, target);
let rel = relative(dirname(fromFile), absoluteTarget);
if (!rel.startsWith('.')) rel = './' + rel;
return rel.split('\\').join('/');
}
function resolveSubpathImport(importPath, fromFile) {
if (importsMap[importPath]) {
return toRelativePath(importsMap[importPath], fromFile);
}
for (const [pattern, target] of wildcardEntries) {
const prefix = pattern.replaceAll('*', '');
if (importPath.startsWith(prefix)) {
const wildcard = importPath.slice(prefix.length);
return toRelativePath(target.replaceAll('*', wildcard), fromFile);
}
}
console.warn(
`Warning: Could not resolve subpath import '${importPath}' from ${relative(buildDir, fromFile)}`,
);
return null;
}
function addExtensionsToImports(content, filePath) {
const importRegex =
/(?:import\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*\s+from\s+)?|import\s*\(|require\s*\()['"]((\.\.?\/[^'"]+)|(#[^'"]+))['"]/g;
return content.replace(importRegex, (match, importPath) => {
if (!importPath || typeof importPath !== 'string') {
return match;
}
if (importPath.startsWith('#')) {
const resolved = resolveSubpathImport(importPath, filePath);
if (resolved) {
return match.replace(importPath, resolved);
}
return match;
}
// Skip if already has an extension
if (/\.(js|mjs|ts|mts|json)$/.test(importPath)) {
return match;
}
// Skip if ends with / (directory import that already has trailing slash)
if (importPath.endsWith('/')) {
return match;
}
const newImportPath = resolveImportPath(importPath, filePath);
return match.replace(importPath, newImportPath);
});
}
async function processFile(filePath) {
const content = await readFile(filePath, 'utf-8');
const newContent = addExtensionsToImports(content, filePath);
if (content !== newContent) {
await writeFile(filePath, newContent, 'utf-8');
const relativePath = relative(buildDir, filePath);
console.log(`Updated imports in ${relativePath}`);
}
}
async function main() {
try {
const files = await getAllJsFiles(buildDir);
await Promise.all(files.map(processFile));
console.log(`Processed ${files.length} files`);
} catch (error) {
console.error('Error processing files:', error);
process.exit(1);
}
}
void main();

View File

@@ -1,26 +0,0 @@
import { existsSync } from 'node:fs';
import { dirname, extname, resolve as nodeResolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const extensions = ['.ts', '.js', '.mts', '.mjs'];
export async function resolve(specifier, context, nextResolve) {
// Only handle relative imports without extensions
if (specifier.startsWith('.') && !extname(specifier)) {
const parentURL = context.parentURL;
if (parentURL) {
const parentPath = new URL(parentURL).pathname;
const parentDir = dirname(parentPath);
// Try extensions in order
for (const ext of extensions) {
const resolvedPath = nodeResolve(parentDir, `${specifier}${ext}`);
if (existsSync(resolvedPath)) {
return nextResolve(pathToFileURL(resolvedPath).href, context);
}
}
}
}
return nextResolve(specifier, context);
}

View File

@@ -41,47 +41,19 @@
"#util/title": "./src/util/title/index.js",
"#util/*": "./src/util/*.ts"
},
"publishConfig": {
"imports": {
"#db": "./build/src/db.js",
"#account-db": "./build/src/account-db.js",
"#load-config": "./build/src/load-config.js",
"#migrations": "./build/src/migrations.js",
"#accounts/*": "./build/src/accounts/*.js",
"#app-gocardless/banks/bank.interface": "./build/src/app-gocardless/banks/bank.interface.js",
"#app-gocardless/banks/*": "./build/src/app-gocardless/banks/*.js",
"#app-gocardless/errors": "./build/src/app-gocardless/errors.js",
"#app-gocardless/gocardless-node.types": "./build/src/app-gocardless/gocardless-node.types.js",
"#app-gocardless/gocardless.types": "./build/src/app-gocardless/gocardless.types.js",
"#app-gocardless/services/*": "./build/src/app-gocardless/services/*.js",
"#app-gocardless/services/tests/*": "./build/src/app-gocardless/services/tests/*.js",
"#app-gocardless/util/*": "./build/src/app-gocardless/util/*.js",
"#app-gocardless/*": "./build/src/app-gocardless/*.js",
"#app-pluggyai/*": "./build/src/app-pluggyai/*.js",
"#app-simplefin/*": "./build/src/app-simplefin/*.js",
"#app-sync/services/*": "./build/src/app-sync/services/*.js",
"#app-sync/*": "./build/src/app-sync/*.js",
"#scripts/*": "./build/src/scripts/*.js",
"#services/*": "./build/src/services/*.js",
"#util/title": "./build/src/util/title/index.js",
"#util/*": "./build/src/util/*.js"
}
},
"scripts": {
"start": "yarn build && node build/app",
"start-monitor": "nodemon --exec 'yarn build && node build/app' --ignore './build/**/*' --ext 'ts,js' build/app",
"build": "tsgo -b && yarn add-import-extensions && yarn copy-static-assets",
"start": "yarn build && node build/app.js",
"start-monitor": "nodemon --exec 'yarn build && node build/app.js' --ignore './build/**/*' --ext 'ts,js' build/app.js",
"build": "vite build",
"typecheck": "tsgo -b && tsc-strict",
"add-import-extensions": "node bin/add-import-extensions.mjs",
"copy-static-assets": "rm -rf build/src/sql && cp -r src/sql build/src/sql",
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --import ./register-loader.mjs --trace-warnings' vitest --run",
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js up",
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/src/scripts/run-migrations.js down",
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js up",
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/src/scripts/run-migrations.js down",
"reset-password": "yarn build && node build/src/scripts/reset-password.js",
"disable-openid": "yarn build && node build/src/scripts/disable-openid.js",
"health-check": "yarn build && node build/src/scripts/health-check.js"
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' vitest --run",
"db:migrate": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js up",
"db:downgrade": "yarn build && cross-env NODE_ENV=development node build/scripts/run-migrations.js down",
"db:test-migrate": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js up",
"db:test-downgrade": "yarn build && cross-env NODE_ENV=test node build/scripts/run-migrations.js down",
"reset-password": "yarn build && node build/scripts/reset-password.js",
"disable-openid": "yarn build && node build/scripts/disable-openid.js",
"health-check": "yarn build && node build/scripts/health-check.js"
},
"dependencies": {
"@actual-app/crdt": "workspace:*",
@@ -115,6 +87,7 @@
"nodemon": "^3.1.14",
"supertest": "^7.2.2",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.5",
"vitest": "^4.1.2"
}
}

View File

@@ -1,9 +0,0 @@
import { register } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const loaderPath = resolve(__dirname, 'loader.mjs');
register(pathToFileURL(loaderPath).href, pathToFileURL(__dirname));

View File

@@ -1,22 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import IntegrationBank from './banks/integration-bank';
const dirname = path.resolve(fileURLToPath(import.meta.url), '..');
const banksDir = path.resolve(dirname, 'banks');
// Filename convention: <name>_<bic>.{ts,js} (skips bank.interface,
// integration-bank, and any other helper without an underscore).
const bankLoaders = import.meta.glob('./banks/*_*.{ts,js}');
async function loadBanks() {
const bankHandlers = fs
.readdirSync(banksDir)
.filter(filename => filename.includes('_') && filename.endsWith('.js'));
const imports = await Promise.all(
bankHandlers.map(file => {
const fileUrlToBank = pathToFileURL(path.resolve(banksDir, file)); // pathToFileURL for ESM compatibility
return import(fileUrlToBank.toString()).then(handler => handler.default);
}),
Object.values(bankLoaders).map(loader =>
loader().then(handler => handler.default),
),
);
return imports;

View File

@@ -124,17 +124,32 @@ app.get('/metrics', (_req, res) => {
});
});
// The web frontend
// The web frontend.
// Dev mode proxies to Vite, which injects inline preamble scripts and uses
// a websocket for HMR. Loosen script-src and connect-src accordingly.
// `'unsafe-eval'` is required at runtime for the Electron app, so it is
// kept in both branches.
const isDev = process.env.NODE_ENV === 'development';
const scriptSrc = isDev
? "'self' 'unsafe-inline' 'unsafe-eval' blob:"
: "'self' 'unsafe-eval' blob:";
const connectSrc = isDev ? "'self' ws: wss: http: https:" : 'http: https:';
const csp = [
"default-src 'self' blob:",
"img-src 'self' blob: data:",
`script-src ${scriptSrc}`,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
`connect-src ${connectSrc}`,
].join('; ');
app.use((req, res, next) => {
res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
res.set(
'Content-Security-Policy',
"default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;",
);
res.set('Content-Security-Policy', csp);
next();
});
if (process.env.NODE_ENV === 'development') {
if (isDev) {
console.log(
'Running in development mode - Proxying frontend routes to React Dev Server',
);

View File

@@ -21,8 +21,6 @@ const defaultDataDir = process.env.ACTUAL_DATA_DIR
debug(`Project root: '${projectRoot}'`);
export const sqlDir = path.join(__dirname, 'sql');
const actualAppWebBuildPath = path.join(
path.dirname(require.resolve('@actual-app/web/package.json')),
'build',

View File

@@ -1,40 +1,34 @@
import { readdir } from 'node:fs/promises';
import path, { dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import { load } from 'migrate';
import { config } from './load-config';
type MigrationCallback = (err?: Error) => void;
type MigrationModule = {
up: (next?: MigrationCallback) => void;
down: (next?: MigrationCallback) => void;
};
// Vite resolves this glob at build time and inlines a static map of
// () => import('chunks/...js') calls. Each migration becomes its own chunk.
// Runtime fs reads against a migrations/ directory disappear.
const migrationsLoaders = import.meta.glob<MigrationModule>(
'../migrations/*.{ts,js}',
);
export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
console.log(
`Checking if there are any migrations to run for direction "${direction}"...`,
);
const __dirname = dirname(fileURLToPath(import.meta.url)); // this directory
const migrationsDir = path.join(__dirname, '../migrations');
try {
// Load all script files in the migrations directory
const files = await readdir(migrationsDir);
const migrationsModules: Record<
string,
{
up: (next?: MigrationCallback) => void;
down: (next?: MigrationCallback) => void;
}
> = {};
const sortedKeys = Object.keys(migrationsLoaders).sort();
const migrationsModules: Record<string, MigrationModule> = {};
for (const f of files
.filter(
f => (f.endsWith('.js') || f.endsWith('.ts')) && !f.endsWith('.d.ts'),
)
.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0))) {
migrationsModules[f] = await import(
pathToFileURL(path.join(migrationsDir, f)).href
);
for (const key of sortedKeys) {
const fileName = key.split('/').pop()!;
migrationsModules[fileName] = await migrationsLoaders[key]();
}
return new Promise<void>((resolve, reject) => {

View File

@@ -1,10 +1,9 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { existsSync } from 'node:fs';
import { merkle, SyncProtoBuf, Timestamp } from '@actual-app/crdt';
import { openDatabase } from './db';
import { sqlDir } from './load-config';
import messagesSql from './sql/messages.sql?raw';
import { getPathForGroupFile } from './util/paths';
function getGroupDb(groupId) {
@@ -14,8 +13,7 @@ function getGroupDb(groupId) {
const db = openDatabase(path);
if (needsInit) {
const sql = readFileSync(join(sqlDir, 'messages.sql'), 'utf8');
db.exec(sql);
db.exec(messagesSql);
}
return db;

View File

@@ -0,0 +1,73 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { defineConfig } from 'vite';
import type { Plugin } from 'vite';
const pkg = JSON.parse(
readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
);
const shebangPlugin = (entryFile: string): Plugin => ({
name: 'sync-server-shebang',
generateBundle(_options, bundle) {
const chunk = bundle[entryFile];
if (chunk?.type === 'chunk' && !chunk.code.startsWith('#!')) {
chunk.code = `#!/usr/bin/env node\n${chunk.code}`;
}
},
});
export default defineConfig({
ssr: {
target: 'node',
// Inline workspace deps that ship as TS source. Anything else
// (express, better-sqlite3, bcrypt, @actual-app/web, etc.) stays
// external so Node resolves it at runtime.
noExternal: ['@actual-app/crdt'],
},
build: {
ssr: true,
target: 'node22',
outDir: path.resolve(__dirname, 'build'),
emptyOutDir: true,
sourcemap: true,
minify: false,
rollupOptions: {
input: {
app: path.resolve(__dirname, 'app.ts'),
'bin/actual-server': path.resolve(__dirname, 'bin/actual-server.js'),
'scripts/run-migrations': path.resolve(
__dirname,
'src/scripts/run-migrations.js',
),
'scripts/reset-password': path.resolve(
__dirname,
'src/scripts/reset-password.js',
),
'scripts/disable-openid': path.resolve(
__dirname,
'src/scripts/disable-openid.js',
),
'scripts/enable-openid': path.resolve(
__dirname,
'src/scripts/enable-openid.js',
),
'scripts/health-check': path.resolve(
__dirname,
'src/scripts/health-check.js',
),
},
output: {
format: 'esm',
entryFileNames: '[name].js',
chunkFileNames: 'chunks/[name]-[hash].js',
},
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
assetsInclude: ['**/*.sql'],
plugins: [shebangPlugin('bin/actual-server.js')],
});

View File

@@ -1,6 +0,0 @@
---
category: Features
authors: [nikhilweee]
---
Add 'Copy until year end' option to the per-category budget menu in tracking budget mode, which copies the current month's budgeted amount to all remaining months of the same calendar year.

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [matt-fidd]
---
Fix schedules not appearing on the mobile view when the date is changed by rules

View File

@@ -1,6 +0,0 @@
---
category: Maintenance
authors: [matt-fidd]
---
Fix VRT update workflow failing

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactor module resolution to load `@actual-app/crdt` from source during development.

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [alecbakholdin]
---
Fixed cannot read properties of null in throwIfNot200 (reading 'toLowerCase')

View File

@@ -1,6 +0,0 @@
---
category: Bugfixes
authors: [youngcw]
---
Duplicated transactions are marked as uncleared and unlocked

View File

@@ -203,6 +203,7 @@ __metadata:
pluggy-sdk: "npm:^0.83.0"
supertest: "npm:^7.2.2"
typescript-strict-plugin: "npm:^2.4.4"
vite: "npm:^8.0.5"
vitest: "npm:^4.1.2"
winston: "npm:^3.19.0"
bin: