mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-06 15:12:35 -05:00
Compare commits
12 Commits
master
...
matiss/crd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8393a65d7a | ||
|
|
6b351eafc7 | ||
|
|
43fba254b5 | ||
|
|
6e8ac07846 | ||
|
|
99682268cc | ||
|
|
749aee4f44 | ||
|
|
531b1a1914 | ||
|
|
f08490052f | ||
|
|
145868f9da | ||
|
|
9513c1e160 | ||
|
|
e661951753 | ||
|
|
fc5e836a02 |
7
.github/workflows/vrt-update-generate.yml
vendored
7
.github/workflows/vrt-update-generate.yml
vendored
@@ -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"
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly"
|
||||
"FS": "readonly",
|
||||
"__APP_VERSION__": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// Import sorting
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
73
packages/sync-server/vite.config.mts
Normal file
73
packages/sync-server/vite.config.mts
Normal 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')],
|
||||
});
|
||||
@@ -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.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Fix schedules not appearing on the mobile view when the date is changed by rules
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Fix VRT update workflow failing
|
||||
6
upcoming-release-notes/7702.md
Normal file
6
upcoming-release-notes/7702.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Refactor module resolution to load `@actual-app/crdt` from source during development.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [alecbakholdin]
|
||||
---
|
||||
|
||||
Fixed cannot read properties of null in throwIfNot200 (reading 'toLowerCase')
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Duplicated transactions are marked as uncleared and unlocked
|
||||
Reference in New Issue
Block a user