Compare commits

...

22 Commits

Author SHA1 Message Date
lelemm
18a9cb5192 Merge branch 'master' into feat/plugins/plugins-core-package 2025-11-29 01:16:17 -03:00
dependabot[bot]
33610fee78 Bump node-forge from 1.3.1 to 1.3.2 (#6260)
* Bump node-forge from 1.3.1 to 1.3.2

Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.1 to 1.3.2.
- [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md)
- [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: node-forge
  dependency-version: 1.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* note

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-11-27 09:44:18 +00:00
Juulz
9d94e1268c Update Spending.tsx - add left margin to separator between Average and Filter - Fixes #6003 (#6261)
* Update Spending.tsx - add margin to avg button

* Add left margin to separator after Average button in Spending report.
2025-11-27 07:45:56 +00:00
Juulz
52d013cb86 Update Reconciliation document to current UI - follows #6220 (#6225)
* Revise reconciliation instructions for clarity

Updated the reconciliation documentation to clarify the process and improve readability.

* Update button text for reconciling action

Changes wording of 'Done reconciling' button to 'Exit reconciling'.

* Modify button text for reconciliation process

Updates button wording based on reconciliation status.

* Update button wording in release notes

* Refine language in reconciliation documentation

Updated wording for clarity and consistency throughout the reconciliation documentation.

* Add files via upload

* Update reconciliation screenshots

* Update reconciliation images for 2025

* Add files via upload

* Clarify exit option in reconciliation process

Added a note about exiting reconciliation without locking transactions.

* Fix cleared transactions total in reconciliation guide

Corrected the cleared transactions total from 324.82 to -82.60 in the reconciliation instructions.

* Update reconciliation.md

* Update reconciliation.md to resolve conflicts

* Add files via upload

* Update reconciliation.md unlocking transactions

* Update 6220.md

* Update 6220.md
2025-11-26 19:54:56 +00:00
gopstr
e0afbcfd96 Add Belarusian Ruble currency (#6252)
* Add Belarusian Ruble currency

* Fix upcoming release notes filename

---------

Co-authored-by: pstribuk <pstribuk@ibagroup.eu>
2025-11-26 13:54:31 +00:00
Michael Clark
9ceb74cf6e :electron: Make it easier to download the desktop app installers (#6246)
* make it easier to download the desktop app artifacts

* release notes

* naming the artifacts *.zip becuase zips are mandatory

* Revert "naming the artifacts *.zip becuase zips are mandatory"

This reverts commit 02f9fcaa69.
2025-11-25 20:30:23 +00:00
youngcw
ba00a25c85 🐛 fix the running balance on mobile for large accounts (#6241)
* fix the running balance on mobile for large accounts

* typecheck

* don't calculate if not needed
2025-11-25 17:59:46 +00:00
Michael Clark
3df3b5e145 :electron: Add workflow for nightly signed desktop app (#6242)
* add workflow for nightly signed desktop app build

* release ntoes
2025-11-24 22:22:36 +00:00
Juulz
c17fa45692 Add PWA install instructions and links to Pikapods doc - fixes #6191 (#6215)
* Update pikapods.md

* Update pikapods.md

Added info and links to PWA installation

* Update pikapods.md

* Update pikapods.md

A couple of formatting changes

* Update pikapods.md

* Update notification method for PikaPods

* Update packages/docs/docs/install/pikapods.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* Update pikapods.md

* Update pikapods.md

* Update expect.txt - add 'taskbar'

* Update expect.txt

* Update pikapods.md

* Update packages/docs/docs/install/pikapods.md

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>

* Update pikapods.md

* Update pikapods.md

* Update pikapods.md

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
2025-11-24 13:39:40 +00:00
Yaroslav Halchenko
7613de013e Two typos codespell found (#6237) 2025-11-24 11:15:12 +00:00
lelemm
25034ff639 update yarn.lock after merge 2025-11-23 01:09:20 -03:00
lelemm
41490e1831 merge 2025-11-23 01:07:59 -03:00
lelemm
cf28d05ba7 Merge remote-tracking branch 'origin/master' into feat/plugins/plugins-core-package 2025-11-23 01:07:48 -03:00
lelemm
f212559eec code review 2025-11-23 01:02:16 -03:00
lelemm
c25e8c2df5 update yarn.lock 2025-10-24 11:21:05 -03:00
github-actions[bot]
e8c4489657 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5786
2025-10-24 11:19:06 -03:00
github-actions[bot]
ac07adfff6 Update VRT screenshots
Auto-generated by VRT workflow

PR: #5786
2025-10-24 11:19:06 -03:00
lelemm
434ba5221d Code review 2025-10-24 11:19:06 -03:00
lelemm
280965f9a0 Code Review suggestion
Co-authored-by: Julian Dominguez-Schatz <julian.dominguezschatz@gmail.com>
2025-10-24 11:19:06 -03:00
lelemm
e659ed0554 Change on workflow 2025-10-24 11:19:06 -03:00
lelemm
d6a7a892f0 Code review 2025-10-24 11:19:06 -03:00
lelemm
2ee1a61689 Plugins-core 2025-10-24 11:19:06 -03:00
86 changed files with 2836 additions and 527 deletions

View File

@@ -108,6 +108,7 @@ prefs
Primoco
Priotecs
proactively
pwa
Qatari
QNTOFRP
QONTO
@@ -133,6 +134,7 @@ SWEDNOKK
Synology
systemctl
tada
taskbar
templating
THB
touchscreen

View File

@@ -62,6 +62,38 @@ jobs:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
plugins-core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Plugins Core
run: yarn workspace @actual-app/plugins-core build
- name: Create package tgz
run: yarn workspace @actual-app/plugins-core pack --filename actual-plugins-core.tgz
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: actual-plugins-core
path: packages/plugins-core/actual-plugins-core.tgz
plugins-service:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Plugins Service
run: yarn workspace plugins-service build
- name: Create package tgz
run: yarn workspace plugins-service pack --filename actual-plugins-service.tgz
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: actual-plugins-service
path: packages/plugins-service/actual-plugins-service.tgz
web:
runs-on: ubuntu-latest
steps:

View File

@@ -46,16 +46,63 @@ jobs:
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron
- name: Upload Build
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}
path: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Linux x64 flatpak
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-x86_64.flatpak
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0

View File

@@ -0,0 +1,131 @@
name: Publish nightly desktop app
# Publish nightly version of desktop app - Runs every day at midnight
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
defaults:
run:
shell: bash
env:
CI: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
strategy:
matrix:
os:
- ubuntu-22.04
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
run: |
mkdir .venv
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install setuptools
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: |
sudo apt-get update
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo flatpak install org.freedesktop.Sdk//24.08 -y
sudo flatpak install org.freedesktop.Platform//24.08 -y
sudo flatpak install org.electronjs.Electron2.BaseApp//24.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
# Set package version
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
- name: Build Electron for Mac
if: ${{ startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
env:
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-x86_64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
- name: Upload Linux arm64 AppImage
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-linux-arm64.AppImage
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
- name: Upload Windows x32 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-ia32.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
- name: Upload Windows x64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-x64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-x64.exe
- name: Upload Windows arm64 exe
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-windows-arm64.exe
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
- name: Upload Mac x64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-x64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
- name: Upload Mac arm64 dmg
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
path: packages/desktop-electron/dist/Actual-mac-arm64.dmg
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx

View File

@@ -97,6 +97,8 @@ export default defineConfig(
'packages/loot-core/**/proto/*',
'packages/sync-server/user-files/',
'packages/sync-server/server-files/',
'packages/plugins-core/build/',
'packages/plugins-core/node_modules/',
'.yarn/*',
'.github/*',
'**/build/',
@@ -664,7 +666,7 @@ export default defineConfig(
'warn',
{
types: {
// forbid FC as superflous
// forbid FC as superfluous
FunctionComponent: {
message:
'Type the props argument and let TS infer or use ComponentType for a component prop',

View File

@@ -40,6 +40,7 @@
"build:plugins-service": "yarn workspace plugins-service build",
"build:api": "yarn workspace @actual-app/api build",
"build:docs": "yarn workspace docs build",
"build:plugins-core": "yarn workspace @actual-app/plugins-core build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "lage test --continue",

View File

@@ -11,7 +11,7 @@
"outDir": "dist",
"declarationDir": "@types",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
"loot-core/*": ["./@types/loot-core/loot-core/src/*"]
}
},
"include": ["."],

View File

@@ -48,7 +48,8 @@
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",
"./view": "./src/View.tsx",
"./color-picker": "./src/ColorPicker.tsx"
"./color-picker": "./src/ColorPicker.tsx",
"./modal": "./src/Modal.ts"
},
"scripts": {
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",

View File

@@ -0,0 +1,11 @@
import { type CSSProperties } from 'react';
export type BasicModalProps = {
isLoading?: boolean;
noAnimation?: boolean;
style?: CSSProperties;
onClose?: () => void;
containerProps?: {
style?: CSSProperties;
};
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -18,8 +18,12 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import {
useTransactions,
calculateRunningBalancesTopDown,
} from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
@@ -73,6 +77,15 @@ function TransactionListWithPreviews({
const shouldCalculateRunningBalances =
showRunningBalances === 'true' && !!account?.id && !isSearching;
const accountBalanceValue = useSheetValue<
'account',
'balance' | 'accounts-balance'
>(
account?.id
? bindings.accountBalance(account?.id)
: bindings.allAccountBalance(),
);
const {
transactions,
runningBalances,
@@ -83,7 +96,10 @@ function TransactionListWithPreviews({
} = useTransactions({
query: transactionsQuery,
options: {
calculateRunningBalances: shouldCalculateRunningBalances,
calculateRunningBalances: shouldCalculateRunningBalances
? calculateRunningBalancesTopDown
: shouldCalculateRunningBalances,
startingBalance: accountBalanceValue || 0,
},
});

View File

@@ -363,6 +363,7 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
height: 28,
backgroundColor: theme.pillBorderDark,
marginRight: 10,
marginLeft: 10,
}}
/>

View File

@@ -25,6 +25,7 @@ export function CurrencySettings() {
['ARS', t('Argentinian Peso')],
['AUD', t('Australian Dollar')],
['BRL', t('Brazilian Real')],
['BYN', t('Belarusian Ruble')],
['CAD', t('Canadian Dollar')],
['CHF', t('Swiss Franc')],
['CNY', t('Yuan Renminbi')],

View File

@@ -1,12 +1,11 @@
import { q } from 'loot-core/shared/query';
import type { AccountEntity, CategoryEntity } from 'loot-core/types/models';
import {
parametrizedField,
type SheetFields,
type Binding,
type SheetNames,
} from '.';
} from '@actual-app/shared-types/spreadsheet';
import { uncategorizedTransactions } from '@desktop-client/queries';

View File

@@ -1,119 +1,8 @@
import { type Query } from 'loot-core/shared/query';
export type Spreadsheets = {
account: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Account fields
balance: number;
[key: `balance-${string}-cleared`]: number | null;
'accounts-balance': number;
'onbudget-accounts-balance': number;
'offbudget-accounts-balance': number;
'closed-accounts-balance': number;
balanceCleared: number;
balanceUncleared: number;
lastReconciled: string | null;
};
category: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
balance: number;
balanceCleared: number;
balanceUncleared: number;
};
'envelope-budget': {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Envelope budget fields
'available-funds': number;
'last-month-overspent': number;
buffered: number;
'buffered-auto': number;
'buffered-selected': number;
'to-budget': number | null;
'from-last-month': number;
'total-budgeted': number;
'total-income': number;
'total-spent': number;
'total-leftover': number;
'group-sum-amount': number;
'group-budget': number;
'group-leftover': number;
budget: number;
'sum-amount': number;
leftover: number;
carryover: number;
goal: number;
'long-goal': number;
};
'tracking-budget': {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Tracking budget fields
'total-budgeted': number;
'total-budget-income': number;
'total-saved': number;
'total-income': number;
'total-spent': number;
'real-saved': number;
'total-leftover': number;
'group-sum-amount': number;
'group-budget': number;
'group-leftover': number;
budget: number;
'sum-amount': number;
leftover: number;
carryover: number;
goal: number;
'long-goal': number;
};
[`balance`]: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Balance fields
[key: `balance-query-${string}`]: number;
[key: `selected-transactions-${string}`]: Array<{ id: string }>;
[key: `selected-balance-${string}`]: number;
};
};
export type SheetNames = keyof Spreadsheets & string;
export type SheetFields<SheetName extends SheetNames> =
keyof Spreadsheets[SheetName] & string;
export type BindingObject<
SheetName extends SheetNames,
SheetFieldName extends SheetFields<SheetName>,
> = {
name: SheetFieldName;
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
query?: Query | undefined;
};
export type Binding<
SheetName extends SheetNames,
SheetFieldName extends SheetFields<SheetName>,
> =
| SheetFieldName
| {
name: SheetFieldName;
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
query?: Query | undefined;
};
export const parametrizedField =
<SheetName extends SheetNames>() =>
<SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>
(id?: string): SheetFieldName =>
`${field}-${id}` as SheetFieldName;
export type {
Spreadsheets,
SheetNames,
SheetFields,
BindingObject,
Binding,
} from '@actual-app/shared-types/spreadsheet';
export { parametrizedField } from '@actual-app/shared-types/spreadsheet';

View File

@@ -1,52 +1,77 @@
# Reconciliation
Keeping your Actual account ledgers consistent with your bank ledgers is important to maintain a healthy budget and know exactly how much currency is available to spend. Some choose to reconcile monthly, weekly, or even daily. Actual provides the Reconciliation tool to help manage this process.
Keeping your Actual account ledgers consistent with your bank ledgers is important to maintain a healthy budget and know exactly how much currency is available to spend. Some choose to reconcile monthly, weekly, or even daily. Actual provides a Reconciliation tool to help manage this process.
## Work Flow
When you reconcile, you will be comparing your bank statement, print or online, against Actual's ledger for that account. If you have made transactions against the budget that have not been verified against your account the **cleared** flag will be shown in gray. If the transaction has been moved out of the pending section of your (online) bank statement, click the gray circle to turn it green. This is a visual indication that the transaction is in both your budget and in your account statement, and they match.
When you reconcile, you compare your bank statement, print or online, against Actual's ledger for that account. If you have transactions in Actual that have not been verified against your bank account the **cleared** flag will be shown in gray (pending). If the transaction is finalized (cleared) on your bank statement, click the gray circle to turn it green. This is a visual indication that the transaction is in both your budget and in your account statement, and they match.
By clicking on the green balance in the header of the account view, two more category balances will come into view. The **cleared total** only includes transactions that have been cleared while the **uncleared total** will represent the transactions you have entered but may not have entered the bank statement yet.
Click on the green balance in the header of the account view to bring two more category balances into view. The **cleared total** only includes transactions that have been matched to the bank while the **Uncleared total** shows the sum of the transactions that aren't yet matched to the bank statement.
![](/img/reconcile/reconcile-1.png)
![Image of all totals in account header](/img/reconcile/reconcile-1.png)
## Starting the Reconciliation Tool
Click the 🔒 lock icon in the top right-hand corner of the account ledger.
Click the 🔒 lock icon in the top right-hand corner of the account ledger. Notice that the last date the account was reconciled is shown on hover.
![](/img/reconcile/reconcile-2.png)
![Image of lock icon](/img/reconcile/reconcile-2-2025.png)
Let's imagine that we have just checked our account balance for our Ally Savings account and the current balance is 3012.13. We enter the balance we want to match into the Reconciliation tool and press **Reconcile**.
Let's imagine that you checked your account balance for Chase Amazon and the current balance is -310.80. The tool will default to the **Cleared total**. Enter the balance you want to match into the Reconciliation tool and click **Reconcile**.
![](/img/reconcile/reconcile-3.png)
:::tip
The tool will tell us exactly how much is different than the budget ledger. Now we can check the Actual ledger against the bank ledger and watch the difference come closer to 0 as you change the cleared status of transactions. In this case, we're looking for transactions that add up to 324.82. This is much easier by looking at the Actual ledger and bank statement side by side to match the transactions.
Remember to use a negative number for the balance on credit or loan accounts
![](/img/reconcile/reconcile-4.png)
:::
When the cleared amount of the Actual account ledger and the value you entered into the Reconciliation tool are the same, the tool will let you know that you are reconciled and you can press done.
![Image of reconcile box with no bank sync](/img/reconcile/reconcile-3-2025.png)
![](/img/reconcile/reconcile-5.png)
Or, if you use certain bank sync providers, the last synced balance will be shown. You can use that balance by clicking the **Use last synced total** button.
Upon pressing done, all cleared transactions will receive a lock icon. This represents the fact that they were included in a reconciliation and makes it harder to accidentally change.
![Image of reconcile box with bank sync](/img/reconcile/reconcile-3a-2025.png)
![](/img/reconcile/reconcile-6.png)
The tool will tell you exactly what the difference is between the bank statement and your Actual ledger. Click the gray circles as you match each Actual transaction to the bank ledger and watch the difference come closer to 0 as you change the status of each transaction to green (cleared). In the example, the cleared transactions need to add up to -82.60 This is much easier if you look at the Actual ledger and bank statement side by side to match the transactions.
Of course, there's always the possibility you need to change a locked transaction. You can click on the lock of any transaction to open a dialog to unlock it.
![Image of tool with nonmatching balance](/img/reconcile/reconcile-4-2025.png)
![](/img/reconcile/reconcile-7.png)
When the cleared amount of the Actual account ledger and the value entered into the Reconciliation tool are the same, the tool will let you know that you are _All reconciled!_ Click on the **Lock transactions** button to complete the reconciliation and lock the cleared transactions. If you want to leave reconciling for a later time, click **Exit reconciliation** to cancel.
:::warning
If you fail to click the **Lock transactions** button after the _All reconciled!_ message appears, you will not change the status of the cleared transactions to locked.
:::
![Image of All reconciled](/img/reconcile/reconcile-5-2025.png)
![Image of newly locked transactions](/img/reconcile/reconcile-6-2025.png)
If you ever need to unlock a transaction. Click on the lock of any transaction to open a dialog to unlock it. After you unlock a transaction this way, the status will revert to "Cleared". A reconcile will need to be performed to re-lock the transaction.
![Image of unlock transaction box](/img/reconcile/reconcile-7.png)
If you try to edit a locked transaction, the following warning will appear. Click **Confirm** to proceed. Once confirmed, the edit will be made and the transaction will remain locked.
![Image of unlock transaction box](/img/reconcile/reconcile-12.png)
## Using the Reconciliation Tool for Off Budget Asset Tracking
Some people use off budget accounts to track values of assets such as vehicles, real estate, retirement accounts, or other investment accounts or property. The reconciliation tool is useful to update these values as well. Let's say we have a house that was valued at 367,800 but with current changes in the market it is now valued at 385,000. We go to the ledger for the house, choose the reconciliation tool, and enter 385,000 as the new value into the tool.
Off budget accounts can easily be used to track values of assets such as vehicles, real estate, retirement accounts, or other investment accounts or property. The reconciliation tool is useful to update these values using the **Create reconciliation transaction** button.
![](/img/reconcile/reconcile-8.png)
For example, on 10-Nov-2025, my house was valued at 231,100 then some houses sold in my neighborhood and a few days later it was valued at 234,600. Go to the House Asset account, choose the reconciliation tool, and enter 234600 as the new value into the tool.
The tool tells us that we have a gain of 17,200.
![Image of asset reconcile box](/img/reconcile/reconcile-8-2025.png)
![](/img/reconcile/reconcile-9.png)
The tool tells us that we have a gain of 3,500.
![Image of Create transaction](/img/reconcile/reconcile-9-2025.png)
Click on the **Create reconciliation transaction** button to easily create a new transaction that automatically brings the value of the asset in line with the new valuation. Now the reconciliation tool reports that it is _All reconciled!_ Click the **Lock transactions** button to complete the task.
![Image of created transaction All reconciled](/img/reconcile/reconcile-10-2025.png)
![Image of created transaction locked](/img/reconcile/reconcile-11-2025.png)
We can use the "Create reconciliation transaction" button to easily create a new transaction that brings the value of the account in line with the new valuation. You should now have a reconciliation tool that is reporting that it is finished and you can enjoy a new higher net worth.
![](/img/reconcile/reconcile-10.png)

View File

@@ -3,19 +3,22 @@ title: 'PikaPods'
---
[PikaPods](https://www.pikapods.com/) offers one click "instant open source app hosting", allowing
you to run Actual for less than $ 1.50 per month (as of July 2024).
[PikaPods](https://www.pikapods.com/) offers one click "instant open source app hosting", allowing you to run Actual for about $ 1.50 per month (as of November 2025).
Using PikaPods is also a simple way to support the development of Actual Budget, as PikaPods will
share some of its revenues with Actual for customers using their Actual Budget pods.
Using PikaPods is also a simple way to support the development of Actual Budget, as PikaPods will share some of its revenues with Actual for customers using their Actual Budget Pods.
You get a $ 5.00 credit when you sign up, which means that you should be able to run Actual for
over 3 months before your credit runs out.
You get a $ 5.00 credit when you sign up, which means that you should be able to run Actual for 3 months before your credit runs out.
For web clients, PikaPods will automatically update anywhere from a couple of days to a week after the latest monthly release is deployed. This is because PikaPods tests each new release before deploying it.
For web clients, PikaPods will automatically update about a week or so after the latest monthly release is deployed. PikaPods tests each new release before deploying it. If you have the Notification setting in PikaPods checked, you will get an email notifying you that the server has been updated.
For desktop clients, you will need to reinstall the desktop client to update to the latest version.
We suggest first checking on the web client if PikaPods has updated, then reinstalling the desktop client.
First check on the web client to see if PikaPods has updated, then reinstall the desktop client.
:::note[Using Actual on Pikapods]
We suggest [using a PWA](#using-a-PWA) desktop client with PikaPods so you don't run into version issues.
:::
## Deploying Actual on PikaPods
@@ -23,12 +26,11 @@ If you are technically inclined, just [Click here to create an account and run A
You can leave the resources at their lowest setting (although you will need a non-zero amount of storage for your budget files).
_Actual does almost all of its computation in your browser, so purchasing more resources for the server wont necessarily result in a better experience_
_Your browser does most of Actual's computation,_ so purchasing more resources for the server wont necessarily result in a better experience.
After you have set up your Pod, head over to our [Starting Fresh](/docs/getting-started/starting-fresh) guide to get started with
After setting up your Pod, head over to our [Starting Fresh](/docs/getting-started/starting-fresh) guide to get started with
Actual Budget.
## A step by step guide to setting up Actual Budget with PikaPods
[Click here to go to PikaPods setup for Actual](https://www.pikapods.com/pods?run=actual).
@@ -36,95 +38,111 @@ Actual Budget.
You will be greeted with the following screen.
![](/img/pikapods-setup/pikapods-1-register-login.png)
![image PikaPods register](/img/pikapods-setup/pikapods-1-register-login.png)
Click on the **register** link inside the blue banner, which will take you to the user registration screen.
## The user registration screen
This screen is self-explanatory, but a kind reminder is to use a password only you know.
You will need to use a working email address, as you will receive an email with a link you need
to click on to complete the signup process.
A working email address is required, as PikaPods will send an email with a link you need to click on to complete the signup process.
![](/img/pikapods-setup/pikapods-2-register-name.png)
![Image Pikapods email](/img/pikapods-setup/pikapods-2-register-name.png)
## Verification email
By clicking the green button saying **Activate and Login**, you are now registered as
a PikaPods customer. You will be returned to the login screen.
Click the green button **Activate and Login**. You are now registered as a PikaPods customer. You will be returned to the login screen.
![](/img/pikapods-setup/pikapods-4-email-activation.png)
![Image Pikapods email registration](/img/pikapods-setup/pikapods-4-email-activation.png)
## Login screen
Enter the email address and password you registered yourself with.
Enter _your_ registration email address and password.
![](/img/pikapods-setup/pikapods-5-login-screen.png)
![Image Pikapods login](/img/pikapods-setup/pikapods-5-login-screen.png)
## Naming your Pod
A simplistic explanation of a Pod in layperson's terms is that *a Pod is a very tiny computer running in the cloud*.
Typically, a Pod only runs one application - like Actual Budget.
In 1), you enter a name for your Pod. This name really does not matter unless you plan to run several different Pods.
In 2), you decide which region your Pod should run - choose the most suitable region.
Simply put, *a Pod is a very tiny computer running in the cloud*. Typically, a Pod only runs one application - like Actual Budget Server.
:::info
One Pod running Actual can have multiple budgets available at the same time. You do not need to set up a new Pod
for each budget you want to set up. The number of budgets is only limited by the storage capacity you assign to your
Pod.
Multiple budgets can reside in one Pod running Actual. You do not need to set up a new Pod for each budget you create. The number of budgets is limited only by the storage capacity you assign to your Pod.
If you [connect to your bank](/docs/advanced/bank-sync.md), note that all budgets in the same Pod share a single bank sync key.
:::
In 1), you enter a name for your Pod. This name really does not matter unless you plan to run several different Pods.
![](/img/pikapods-setup/pikapods-6-add-pod-basics.png)
In 2), you decide which region your Pod should run - choose the most suitable region.
![Image pikapods basic](/img/pikapods-setup/pikapods-6-add-pod-basics.png)
## Assigning storage to your Pod
The minimum storage you can assign to your Pod is 10 GB (gigabytes). We promise you
that this is more than enough for your budget purposes.
The minimum storage you can assign to your Pod is 10 GB (gigabytes). We promise you that this is more than enough for your budget purposes.
Example: With around 1,200 transactions, 18 months of budgeting, and approximately 200 rules and payees,
it takes around 33 megabytes of storage. 10 gigabytes equals 10,000 megabytes, equivalent to 303 18-month budgets.
Example: It takes about 33 megabytes of storage for about 1,200 transactions, 18 months of budgeting, and approximately 200 rules and payees. 10 gigabytes equals 10,000 megabytes, equivalent to 303 18-month budgets.
Your Pod will be created when you click on the green **ADD POD* button. This step takes less than one minute.
Your Pod will be created when you click on the green **ADD POD** button. This step takes less than one minute.
![](/img/pikapods-setup/pikapods-7-add-pod-resoruces.png)
![Image pikapods add resources](/img/pikapods-setup/pikapods-7-add-pod-resoruces.png)
## Your pod is now ready to be used
## Your Pod is now ready to be used
When you click on the green **OPEN POD** button you will be taken to your Pod.
Click on the green **OPEN POD** button to be taken to your Pod.
The address for your Actual Budget is found in the Domain field. In the screenshot example, this is
`https://berserk-bullmastiff.pikapod.net/budget/`. Yours will be something completely different.
The address for your Actual Budget is found in the Domain field. In the screenshot example, this is `https://berserk-bullmastiff.pikapod.net/budget/`. Yours will be something completely different.
![](/img/pikapods-setup/pikapods-8-running-pod.png)
![Image pikapod pod url](/img/pikapods-setup/pikapods-8-running-pod.png)
## Setting a password for your Actual Budget
Before you can start using Actual, you need to set a password for your server. This password is used
next time you log into your server - and should never be the same as your PikaPods account password.
Before you can start using Actual, you need to set a password for your server. This password is used the next time you log into your server - and should never be the same as your PikaPods account password.
Keep this password safe, as it cannot be retrieved. If you forget your server password, you will not
be able to retrieve your budget.
:::warning
Keep your Actual Budget password safe, as it cannot be retrieved. If you forget your server password, you will not be able to retrieve your budget.
:::
![](/img/a-tour-of-actual/server-connecting-first-time.png)
![Image connecting to Actual](/img/a-tour-of-actual/server-connecting-first-time.png)
<br />
<br />
## Using a PWA (Progressive Web App) {#using-a-PWA}
When using Actual Server over the internet, we suggest using a PWA web client. After you login and open Actual Budget, it's easy to set up a PWA from your browser of choice.
Here's some help with a few common desktop browsers.
:::note
Browser version and OS/browser combination may affect how to install a PWA. Please refer to your browsers documentation for definitive guidance.
:::
- Chrome: There may be an "app available" icon on the right side of the URL or use the browser menu. See [Chrome's documentation](https://support.google.com/chrome/answer/9658361?hl=en&co=GENIE.Platform%3DDesktop).
- Firefox: In supported OS, there should be an "add tab to taskbar" icon on the right side of the URL. You may need to add a PWA extension as described in [Mozilla's documentation](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Installing).
- Safari: There should be a share icon in the Safari toolbar or use the browser menu. See [Safari's documentation](https://support.apple.com/en-mide/104996).
For other browsers or browser/OS combinations, most search engines or the browser's documentation will describe how to install a PWA. PC Magazine had an [article in March 2025](https://www.pcmag.com/explainers/how-to-use-progessive-web-apps) with some good information.
## Getting started with using Actual Budget
## Getting started with Actual Budget
Go to our [Starting Fresh](/docs/getting-started/starting-fresh) guide to get started with
Actual Budget.
Go to our [Starting Fresh](/docs/getting-started/starting-fresh) guide to get started with Actual Budget.

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -25,6 +25,7 @@ export const currencies: Currency[] = [
{ code: 'ARS', name: 'Argentinian Peso', symbol: 'Arg$', decimalPlaces: 2, numberFormat: 'dot-comma', symbolFirst: true },
{ code: 'AUD', name: 'Australian Dollar', symbol: 'A$', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true },
{ code: 'BRL', name: 'Brazilian Real', symbol: 'R$', decimalPlaces: 2, numberFormat: 'dot-comma', symbolFirst: true },
{ code: 'BYN', name: 'Belarusian Ruble', symbol: 'Br', decimalPlaces: 2, numberFormat: 'space-comma', symbolFirst: false },
{ code: 'CAD', name: 'Canadian Dollar', symbol: 'CA$', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true },
{ code: 'CHF', name: 'Swiss Franc', symbol: 'Fr.', decimalPlaces: 2, numberFormat: 'apostrophe-dot', symbolFirst: true },
{ code: 'CNY', name: 'Yuan Renminbi', symbol: '¥', decimalPlaces: 2, numberFormat: 'comma-dot', symbolFirst: true },

View File

@@ -1,176 +1,7 @@
import { WithRequired } from '../types/util';
type ObjectExpression = {
[key: string]: ObjectExpression | unknown;
};
export type QueryState = {
get table(): string;
get tableOptions(): Readonly<Record<string, unknown>>;
get filterExpressions(): ReadonlyArray<ObjectExpression>;
get selectExpressions(): ReadonlyArray<ObjectExpression | string | '*'>;
get groupExpressions(): ReadonlyArray<ObjectExpression | string>;
get orderExpressions(): ReadonlyArray<ObjectExpression | string>;
get calculation(): boolean;
get rawMode(): boolean;
get withDead(): boolean;
get validateRefs(): boolean;
get limit(): number | null;
get offset(): number | null;
};
export class Query {
state: QueryState;
constructor(state: WithRequired<Partial<QueryState>, 'table'>) {
this.state = {
tableOptions: state.tableOptions || {},
filterExpressions: state.filterExpressions || [],
selectExpressions: state.selectExpressions || [],
groupExpressions: state.groupExpressions || [],
orderExpressions: state.orderExpressions || [],
calculation: false,
rawMode: false,
withDead: false,
validateRefs: true,
limit: null,
offset: null,
...state,
};
}
filter(expr: ObjectExpression) {
return new Query({
...this.state,
filterExpressions: [...this.state.filterExpressions, expr],
});
}
unfilter(exprs?: Array<keyof ObjectExpression>) {
// Remove all filters if no arguments are passed
if (!exprs) {
return new Query({
...this.state,
filterExpressions: [],
});
}
const exprSet = new Set(exprs);
return new Query({
...this.state,
filterExpressions: this.state.filterExpressions.filter(
expr => !exprSet.has(Object.keys(expr)[0]),
),
});
}
select(
exprs:
| Array<ObjectExpression | string>
| ObjectExpression
| string
| '*'
| ['*'] = [],
) {
if (!Array.isArray(exprs)) {
exprs = [exprs];
}
return new Query({
...this.state,
selectExpressions: exprs,
calculation: false,
});
}
calculate(expr: ObjectExpression | string) {
return new Query({
...this.state,
selectExpressions: [{ result: expr }],
calculation: true,
});
}
groupBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
if (!Array.isArray(exprs)) {
exprs = [exprs];
}
return new Query({
...this.state,
groupExpressions: [...this.state.groupExpressions, ...exprs],
});
}
orderBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
if (!Array.isArray(exprs)) {
exprs = [exprs];
}
return new Query({
...this.state,
orderExpressions: [...this.state.orderExpressions, ...exprs],
});
}
limit(num: number) {
return new Query({ ...this.state, limit: num });
}
offset(num: number) {
return new Query({ ...this.state, offset: num });
}
raw() {
return new Query({ ...this.state, rawMode: true });
}
withDead() {
return new Query({ ...this.state, withDead: true });
}
withoutValidatedRefs() {
return new Query({ ...this.state, validateRefs: false });
}
options(opts: Record<string, unknown>) {
return new Query({ ...this.state, tableOptions: opts });
}
reset() {
return q(this.state.table);
}
serialize() {
return this.state;
}
serializeAsString() {
return JSON.stringify(this.serialize());
}
}
export function getPrimaryOrderBy(
query: Query,
defaultOrderBy: ObjectExpression | null,
) {
const orderExprs = query.serialize().orderExpressions;
if (orderExprs.length === 0) {
if (defaultOrderBy) {
return { order: 'asc', ...defaultOrderBy };
}
return null;
}
const firstOrder = orderExprs[0];
if (typeof firstOrder === 'string') {
return { field: firstOrder, order: 'asc' };
}
// Handle this form: { field: 'desc' }
const [field] = Object.keys(firstOrder);
return { field, order: firstOrder[field] };
}
export function q(table: QueryState['table']) {
return new Query({ table });
}
// Re-export query types and functions from @actual-app/query
export type {
ObjectExpression,
QueryState,
QueryBuilder,
} from '@actual-app/query';
export { Query, q, getPrimaryOrderBy } from '@actual-app/query';

View File

@@ -1,25 +1,5 @@
export type AccountEntity = {
id: string;
name: string;
offbudget: 0 | 1;
closed: 0 | 1;
sort_order: number;
last_reconciled: string | null;
tombstone: 0 | 1;
} & (_SyncFields<true> | _SyncFields<false>);
export type _SyncFields<T> = {
account_id: T extends true ? string : null;
bank: T extends true ? string : null;
bankName: T extends true ? string : null;
bankId: T extends true ? number : null;
mask: T extends true ? string : null; // end of bank account number
official_name: T extends true ? string : null;
balance_current: T extends true ? number : null;
balance_available: T extends true ? number : null;
balance_limit: T extends true ? number : null;
account_sync_source: T extends true ? AccountSyncSource : null;
last_sync: T extends true ? string : null;
};
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
export type {
AccountEntity,
_SyncFields,
AccountSyncSource,
} from '@actual-app/shared-types/models/account';

View File

@@ -1,11 +1 @@
import { CategoryEntity } from './category';
export interface CategoryGroupEntity {
id: string;
name: string;
is_income?: boolean;
sort_order?: number;
tombstone?: boolean;
hidden?: boolean;
categories?: CategoryEntity[];
}
export type { CategoryGroupEntity } from '@actual-app/shared-types/models/category-group';

View File

@@ -1,13 +1 @@
import { CategoryGroupEntity } from './category-group';
export interface CategoryEntity {
id: string;
name: string;
is_income?: boolean;
group: CategoryGroupEntity['id'];
goal_def?: string;
template_settings?: { source: 'notes' | 'ui' };
sort_order?: number;
tombstone?: boolean;
hidden?: boolean;
}
export type { CategoryEntity } from '@actual-app/shared-types/models/category';

View File

@@ -1,10 +1 @@
import { AccountEntity } from './account';
export interface PayeeEntity {
id: string;
name: string;
transfer_acct?: AccountEntity['id'];
favorite?: boolean;
learn_categories?: boolean;
tombstone?: boolean;
}
export type { PayeeEntity } from '@actual-app/shared-types/models/payee';

View File

@@ -12,7 +12,24 @@ export interface RuleEntity extends NewRuleEntity {
id: string;
}
export type RuleConditionOp = RuleConditionEntity['op'];
export type RuleConditionOp =
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'isapprox'
| 'isbetween'
| 'gt'
| 'gte'
| 'lt'
| 'lte'
| 'contains'
| 'doesNotContain'
| 'hasTags'
| 'and'
| 'matches'
| 'onBudget'
| 'offBudget';
export type FieldValueTypes = {
account: string;

View File

@@ -1,49 +1,6 @@
import type { AccountEntity } from './account';
import type { PayeeEntity } from './payee';
import type { RuleConditionEntity, RuleEntity } from './rule';
export interface RecurPattern {
value: number;
type: 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'day';
}
export interface RecurConfig {
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
interval?: number;
patterns?: RecurPattern[];
skipWeekend?: boolean;
start: string;
endMode?: 'never' | 'after_n_occurrences' | 'on_date';
endOccurrences?: number;
endDate?: string;
weekendSolveMode?: 'before' | 'after';
}
export interface ScheduleEntity {
id: string;
name?: string;
rule: RuleEntity['id'];
next_date: string;
completed: boolean;
posts_transaction: boolean;
tombstone: boolean;
// These are special fields that are actually pulled from the
// underlying rule
_payee: PayeeEntity['id'];
_account: AccountEntity['id'];
_amount: number | { num1: number; num2: number };
_amountOp: string;
_date: RecurConfig | string;
_conditions: RuleConditionEntity[];
_actions: Array<{ op: unknown }>;
}
export type DiscoverScheduleEntity = {
id: ScheduleEntity['id'];
account: AccountEntity['id'];
payee: PayeeEntity['id'];
date: RecurConfig;
amount: ScheduleEntity['_amount'];
_conditions: ScheduleEntity['_conditions'];
};
export type {
RecurPattern,
RecurConfig,
ScheduleEntity,
DiscoverScheduleEntity,
} from '@actual-app/shared-types/models/schedule';

View File

@@ -8,7 +8,7 @@ export type EverythingButIdOptional<T extends { id: unknown }> = {
id: T['id'];
} & Partial<Omit<T, 'id'>>;
export type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type { WithRequired } from '@actual-app/shared-types';
// Allows use of object literals inside child elements of `Trans` tags
// see https://github.com/i18next/react-i18next/issues/1483

View File

@@ -1,7 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"declaration": true,
"emitDeclarationOnly": true,
"allowJs": false,

View File

@@ -52,6 +52,14 @@ export default defineConfig(({ mode }) => {
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve(crdtDir, 'src') + '$1',
},
{
find: '@actual-app/query',
replacement: path.resolve(__dirname, '../query/src'),
},
{
find: '@actual-app/shared-types',
replacement: path.resolve(__dirname, '../shared-types/src'),
},
],
},
plugins: [

View File

@@ -75,6 +75,14 @@ export default defineConfig(({ mode }) => {
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve(crdtDir, 'src') + '$1',
},
{
find: '@actual-app/query',
replacement: path.resolve(__dirname, '../query/src'),
},
{
find: '@actual-app/shared-types',
replacement: path.resolve(__dirname, '../shared-types/src'),
},
],
},
define: {

View File

@@ -50,6 +50,14 @@ export default defineConfig(({ mode }) => {
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve(crdtDir, 'src') + '$1',
},
{
find: '@actual-app/query',
replacement: path.resolve(__dirname, '../query/src'),
},
{
find: '@actual-app/shared-types',
replacement: path.resolve(__dirname, '../shared-types/src'),
},
],
},
plugins: [

View File

@@ -0,0 +1,67 @@
{
"name": "@actual-app/plugins-core",
"private": true,
"version": "0.1.0",
"description": "Core plugin system for Actual Budget",
"main": "build/client.js",
"types": "src/client.ts",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src/ tests/ --ext .ts,.tsx",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.4",
"typescript": "^5.5.4",
"typescript-eslint": "^8.18.1",
"typescript-strict-plugin": "^2.4.4",
"vite": "^6.2.0",
"vite-plugin-dts": "^4.5.3"
},
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"import": "./build/client.js",
"require": "./build/client.cjs"
},
"./server": {
"types": "./src/server.ts",
"development": "./src/server.ts",
"import": "./build/server.js",
"require": "./build/server.cjs"
},
"./client": {
"types": "./src/client.ts",
"development": "./src/client.ts",
"import": "./build/client.js",
"require": "./build/client.cjs"
},
"./BasicModalComponents": {
"types": "./src/BasicModalComponents.tsx",
"import": "./build/BasicModalComponents.js",
"development": "./src/BasicModalComponents.tsx"
},
"./types/*": {
"types": "./src/types/*.ts",
"development": "./src/types/*.ts"
},
"./query": {
"types": "./src/query/index.ts",
"development": "./src/query/index.ts"
},
"./src/*": "./src/*"
},
"peerDependencies": {
"@emotion/css": "^11.13.5",
"i18next": "^25.2.1",
"react": "19.1.0",
"react-aria-components": "^1.7.1",
"react-dom": "19.1.0",
"react-i18next": "^16.0.0",
"usehooks-ts": "^3.1.1"
}
}

View File

@@ -0,0 +1,230 @@
import React, {
CSSProperties,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { SvgDelete } from '@actual-app/components/icons/v0';
import { View } from '@actual-app/components/view';
import { Button } from '@actual-app/components/button';
import { Input } from '@actual-app/components/input';
import { styles } from '@actual-app/components/styles';
import { SvgLogo } from '@actual-app/components/icons/logo';
type ModalButtonsProps = {
style?: CSSProperties;
leftContent?: ReactNode;
focusButton?: boolean;
children: ReactNode;
};
export const ModalButtons = ({
style,
leftContent,
focusButton = false,
children,
}: ModalButtonsProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focusButton && containerRef.current) {
const button = containerRef.current.querySelector<HTMLButtonElement>(
'button:not([data-hidden])',
);
if (button) {
button.focus();
}
}
}, [focusButton]);
return (
<View
innerRef={containerRef}
style={{
flexDirection: 'row',
marginTop: 30,
...style,
}}
>
{leftContent}
<View style={{ flex: 1 }} />
{children}
</View>
);
};
type ModalHeaderProps = {
leftContent?: ReactNode;
showLogo?: boolean;
title?: ReactNode;
rightContent?: ReactNode;
};
export function ModalHeader({
leftContent,
showLogo,
title,
rightContent,
}: ModalHeaderProps) {
return (
<View
role="heading"
style={{
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
height: 60,
flex: 'none',
}}
>
<View
style={{
position: 'absolute',
left: 0,
}}
>
{leftContent}
</View>
{(title || showLogo) && (
<View
style={{
textAlign: 'center',
// We need to force a width for the text-overflow
// ellipses to work because we are aligning center.
width: 'calc(100% - 60px)',
}}
>
{showLogo && (
<SvgLogo
width={30}
height={30}
style={{ justifyContent: 'center', alignSelf: 'center' }}
/>
)}
{title &&
(typeof title === 'string' || typeof title === 'number' ? (
<ModalTitle title={`${title}`} />
) : (
title
))}
</View>
)}
{rightContent && (
<View
style={{
position: 'absolute',
right: 0,
}}
>
{rightContent}
</View>
)}
</View>
);
}
type ModalTitleProps = {
title: string;
isEditable?: boolean;
getStyle?: (isEditing: boolean) => CSSProperties;
onEdit?: (isEditing: boolean) => void;
onTitleUpdate?: (newName: string) => void;
};
export function ModalTitle({
title,
isEditable,
getStyle,
onTitleUpdate,
}: ModalTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const onTitleClick = () => {
if (isEditable) {
setIsEditing(true);
}
};
const _onTitleUpdate = (newTitle: string) => {
if (newTitle !== title) {
onTitleUpdate?.(newTitle);
}
setIsEditing(false);
};
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
if (inputRef.current) {
inputRef.current.scrollLeft = 0;
}
}
}, [isEditing]);
const style = getStyle?.(isEditing);
return isEditing ? (
<Input
ref={inputRef}
style={{
fontSize: 25,
fontWeight: 700,
textAlign: 'center',
...style,
}}
defaultValue={title}
onUpdate={_onTitleUpdate}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
_onTitleUpdate?.(e.currentTarget.value);
}
}}
/>
) : (
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
}}
>
<span
onClick={onTitleClick}
style={{
fontSize: 25,
fontWeight: 700,
textAlign: 'center',
...(isEditable && styles.underlinedText),
...style,
}}
>
{title}
</span>
</View>
);
}
type ModalCloseButtonProps = {
onPress?: () => void;
style?: CSSProperties;
};
export function ModalCloseButton({ onPress, style }: ModalCloseButtonProps) {
const { t } = useTranslation();
return (
<Button
variant="bare"
onPress={onPress}
aria-label={t('Close')}
style={{ padding: '10px 10px' }}
>
<SvgDelete width={10} style={style} />
</Button>
);
}

View File

@@ -0,0 +1,31 @@
// Modal Components (client-side only)
export {
ModalTitle,
ModalButtons,
ModalHeader,
ModalCloseButton,
} from './BasicModalComponents';
// Client-side middleware
export { initializePlugin } from './middleware';
// Client-side hooks (React hooks)
export { useReport } from './utils';
// Query System (also needed on client-side for components)
export * from '@actual-app/query';
// Spreadsheet types and utilities (client-side only)
export * from '@actual-app/shared-types/spreadsheet';
// Client-side plugin types
export type {
ActualPlugin,
ActualPluginInitialized,
HostContext,
} from './types/actualPlugin';
export type {
ActualPluginToolkit,
ActualPluginToolkitFunctions,
} from './types/toolkit';

View File

@@ -0,0 +1,13 @@
/**
* Main Entry Point for @actual-app/plugins-core
*
* Re-exports everything from both server and client exports.
* `server` must be used in `loot-core`
* `client` must be used in `desktop-client`
*/
// Re-export all server-safe exports
export * from './server';
// Re-export all client-only exports
export * from './client';

View File

@@ -0,0 +1,101 @@
import React, { ReactElement } from 'react';
import { initReactI18next } from 'react-i18next';
import type { BasicModalProps } from '@actual-app/components/modal';
import ReactDOM from 'react-dom/client';
import {
ActualPlugin,
ActualPluginInitialized,
SlotLocations,
} from './types/actualPlugin';
const containerRoots = new WeakMap<HTMLElement, ReactDOM.Root>();
function getOrCreateRoot(container: HTMLElement) {
let root = containerRoots.get(container);
if (!root) {
root = ReactDOM.createRoot(container);
containerRoots.set(container, root);
}
return root;
}
export function initializePlugin(
plugin: ActualPlugin,
): ActualPluginInitialized {
const originalActivate = plugin.activate;
const newPlugin: ActualPluginInitialized = {
...plugin,
initialized: true,
activate: context => {
context.i18nInstance.use(initReactI18next);
const wrappedContext = {
...context,
// Database provided by host
db: context.db,
// Query builder passed through directly
q: context.q,
registerSlotContent(position: SlotLocations, element: ReactElement) {
return context.registerSlotContent(position, container => {
const root = getOrCreateRoot(container);
root.render(element);
return () => root.unmount();
});
},
pushModal(element: ReactElement, modalProps?: BasicModalProps) {
context.pushModal(container => {
const root = getOrCreateRoot(container);
root.render(element);
return () => root.unmount();
}, modalProps);
},
registerRoute(path: string, element: ReactElement) {
return context.registerRoute(path, container => {
const root = getOrCreateRoot(container);
root.render(element);
return () => root.unmount();
});
},
registerDashboardWidget(
widgetType: string,
displayName: string,
element: ReactElement,
options?: {
defaultWidth?: number;
defaultHeight?: number;
minWidth?: number;
minHeight?: number;
},
) {
return context.registerDashboardWidget(
widgetType,
displayName,
container => {
const root = getOrCreateRoot(container);
root.render(element);
return () => root.unmount();
},
options,
);
},
// Report and spreadsheet utilities - passed through from host context
createSpreadsheet: context.createSpreadsheet,
makeFilters: context.makeFilters,
};
originalActivate(wrappedContext);
},
};
return newPlugin;
}

View File

@@ -0,0 +1,62 @@
/**
* Server-Only Exports for @actual-app/plugins-core
*
* This file contains only types and utilities that can be used in server environments
* (Web Workers, Node.js) without any DOM dependencies or React components.
*/
// Database types
export type {
SqlParameter,
DatabaseQueryResult,
DatabaseRow,
DatabaseSelectResult,
DatabaseResult,
DatabaseOperation,
PluginMetadata,
} from '@actual-app/query/database';
// Plugin file types
export type { PluginFile, PluginFileCollection } from './types/plugin-files';
// AQL query result types
export type {
AQLQueryResult,
AQLQueryOptions,
} from '@actual-app/query/aql-result';
// Model types (server-safe)
export type {
AccountEntity,
CategoryEntity,
CategoryGroupEntity,
PayeeEntity,
ScheduleEntity,
} from '@actual-app/shared-types/models/index';
// Plugin types (server-safe ones)
export type {
PluginDatabase,
PluginSpreadsheet,
PluginBinding,
PluginCellValue,
PluginFilterCondition,
PluginFilterResult,
PluginConditionValue,
PluginMigration,
PluginContext,
ContextEvent,
} from './types/actualPlugin';
export type { ActualPluginEntry } from './types/actualPluginEntry';
export type { ActualPluginManifest } from './types/actualPluginManifest';
// Query System (server-safe)
export {
Query,
q,
getPrimaryOrderBy,
type QueryState,
type QueryBuilder,
type ObjectExpression,
} from '@actual-app/query';

View File

@@ -0,0 +1,241 @@
import type { ReactElement } from 'react';
import type { BasicModalProps } from '@actual-app/components/modal';
import type { Query, QueryBuilder } from '@actual-app/query';
import type {
AccountEntity,
CategoryEntity,
CategoryGroupEntity,
PayeeEntity,
ScheduleEntity,
} from '@actual-app/shared-types';
import type { i18n } from 'i18next';
export type SlotLocations =
| 'sidebar-main-menu'
| 'sidebar-more-menu'
| 'sidebar-before-accounts'
| 'sidebar-after-accounts'
| 'topbar';
// Define condition value types for filtering
export type PluginConditionValue =
| string
| number
| boolean
| null
| Array<string | number>
| { num1: number; num2: number };
export type PluginFilterCondition = {
field: string;
op: string;
value: PluginConditionValue;
type?: string;
customName?: string;
};
export type PluginFilterResult = {
filters: Record<string, unknown>;
};
// Simple color mapping type for theme methods
export interface PluginDatabase {
runQuery<T = unknown>(
sql: string,
params?: (string | number)[],
fetchAll?: boolean,
): Promise<T[] | { changes: number; insertId?: number }>;
execQuery(sql: string): void;
transaction(fn: () => void): void;
getMigrationState(): Promise<string[]>;
setMetadata(key: string, value: string): Promise<void>;
getMetadata(key: string): Promise<string | null>;
/**
* Execute an AQL (Actual Query Language) query.
* This provides a higher-level abstraction over SQL that's consistent with Actual Budget's query system.
*
* @param query - The AQL query (can be a PluginQuery object or serialized PluginQueryState)
* @param options - Optional parameters for the query
* @param options.target - Target database: 'plugin' for plugin tables, 'host' for main app tables. Defaults to 'plugin'
* @param options.params - Named parameters for the query
* @returns Promise that resolves to the query result with data and dependencies
*/
aql(
query: Query,
options?: {
target?: 'plugin' | 'host';
params?: Record<string, unknown>;
},
): Promise<{ data: unknown; dependencies: string[] }>;
}
export type PluginBinding = string | { name: string; query?: Query };
export type PluginCellValue = { name: string; value: unknown | null };
export interface PluginSpreadsheet {
/**
* Bind to a cell and observe changes
* @param sheetName - Name of the sheet (optional, defaults to global)
* @param binding - Cell binding (string name or object with name and optional query)
* @param callback - Function called when cell value changes
* @returns Cleanup function to stop observing
*/
bind(
sheetName: string | undefined,
binding: PluginBinding,
callback: (node: PluginCellValue) => void,
): () => void;
/**
* Get a cell value directly
* @param sheetName - Name of the sheet
* @param name - Cell name
* @returns Promise that resolves to the cell value
*/
get(sheetName: string, name: string): Promise<PluginCellValue>;
/**
* Get all cell names in a sheet
* @param sheetName - Name of the sheet
* @returns Promise that resolves to array of cell names
*/
getCellNames(sheetName: string): Promise<string[]>;
/**
* Create a query in a sheet
* @param sheetName - Name of the sheet
* @param name - Query name
* @param query - The query to create
* @returns Promise that resolves when query is created
*/
createQuery(sheetName: string, name: string, query: Query): Promise<void>;
}
export type PluginMigration = [
timestamp: number,
name: string,
upCommand: string,
downCommand: string,
];
// Plugin context type for easier reuse
export type PluginContext = Omit<
HostContext,
| 'registerSlotContent'
| 'pushModal'
| 'registerRoute'
| 'registerDashboardWidget'
> & {
registerSlotContent: (
location: SlotLocations,
element: ReactElement,
) => () => void;
pushModal: (element: ReactElement, modalProps?: BasicModalProps) => void;
registerRoute: (path: string, routeElement: ReactElement) => () => void;
// Dashboard widget registration - wrapped for JSX elements
registerDashboardWidget: (
widgetType: string,
displayName: string,
element: ReactElement,
options?: {
defaultWidth?: number;
defaultHeight?: number;
minWidth?: number;
minHeight?: number;
},
) => () => void;
db?: PluginDatabase;
q: QueryBuilder;
// Report and spreadsheet utilities
createSpreadsheet: () => PluginSpreadsheet;
makeFilters: (
conditions: Array<PluginFilterCondition>,
) => Promise<PluginFilterResult>;
};
export interface ActualPlugin {
name: string;
version: string;
install: (
oldVersion: string,
newVersion: string,
context: PluginContext,
) => void;
uninstall: (context: PluginContext) => void;
migrations?: () => PluginMigration[];
activate: (context: PluginContext) => void;
deactivate: (context: PluginContext) => void;
}
export type ActualPluginInitialized = Omit<ActualPlugin, 'activate'> & {
initialized: true;
activate: (context: HostContext & { db: PluginDatabase }) => void;
};
export interface ContextEvent {
payees: { payees: PayeeEntity[] };
categories: { categories: CategoryEntity[]; groups: CategoryGroupEntity[] };
accounts: { accounts: AccountEntity[] };
schedules: { schedules: ScheduleEntity[] };
}
export interface HostContext {
navigate: (routePath: string) => void;
pushModal: (
parameter: (container: HTMLDivElement) => void | (() => void),
modalProps?: BasicModalProps,
) => void;
popModal: () => void;
registerRoute: (
path: string,
routeElement: (container: HTMLDivElement) => void | (() => void),
) => () => void;
registerSlotContent: (
location: SlotLocations,
parameter: (container: HTMLDivElement) => void | (() => void),
) => () => void;
on: <K extends keyof ContextEvent>(
eventType: K,
callback: (data: ContextEvent[K]) => void,
) => void;
// Dashboard widget methods
registerDashboardWidget: (
widgetType: string,
displayName: string,
renderWidget: (container: HTMLDivElement) => void | (() => void),
options?: {
defaultWidth?: number;
defaultHeight?: number;
minWidth?: number;
minHeight?: number;
},
) => () => void;
// Query builder provided by host (loot-core's q function)
q: QueryBuilder;
// Report and spreadsheet utilities for dashboard widgets
createSpreadsheet: () => PluginSpreadsheet;
makeFilters: (
conditions: Array<PluginFilterCondition>,
) => Promise<PluginFilterResult>;
i18nInstance: i18n;
}

View File

@@ -0,0 +1,5 @@
import React from 'react';
import { ActualPluginInitialized } from './actualPlugin';
export type ActualPluginEntry = () => ActualPluginInitialized;

View File

@@ -0,0 +1,11 @@
export interface ActualPluginManifest {
url: string;
name: string;
version: string;
enabled?: boolean;
description?: string;
pluginType: 'server' | 'client';
minimumActualVersion: string;
author: string;
plugin?: Blob;
}

View File

@@ -0,0 +1,18 @@
/**
* Plugin File Types
*
* Types for plugin file operations and storage.
*/
/**
* Represents a single file within a plugin package
*/
export interface PluginFile {
name: string;
content: string;
}
/**
* Collection of files that make up a plugin
*/
export type PluginFileCollection = PluginFile[];

View File

@@ -0,0 +1,7 @@
export type ActualPluginToolkitFunctions = {
pushModal: (modalName: string, options?: unknown) => void;
};
export type ActualPluginToolkit = {
functions: ActualPluginToolkitFunctions;
};

View File

@@ -0,0 +1,24 @@
import { useState, useEffect } from 'react';
import { PluginSpreadsheet } from './types/actualPlugin';
/**
* useReport hook for plugins to manage spreadsheet data
* This is similar to the useReport hook in desktop-client but adapted for plugins
*/
export function useReport<T>(
sheetName: string,
getData: (
spreadsheet: PluginSpreadsheet,
setData: (results: T) => void,
) => Promise<void>,
spreadsheet: PluginSpreadsheet,
): T | null {
const [results, setResults] = useState<T | null>(null);
useEffect(() => {
getData(spreadsheet, results => setResults(results));
}, [getData, spreadsheet]);
return results;
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": "."
},
"include": ["src"]
}

View File

@@ -0,0 +1,50 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';
export default defineConfig({
resolve: {
alias: {},
},
build: {
outDir: 'build',
lib: {
entry: {
server: 'src/server.ts',
client: 'src/client.ts',
},
name: '@actual-app/plugins-core',
fileName: (format, entryName) =>
format === 'es' ? `${entryName}.js` : `${entryName}.cjs`,
formats: ['es', 'cjs'],
},
rollupOptions: {
external: [
'react',
'react-dom',
'i18next',
'react-i18next',
'react-aria-components',
'@emotion/css',
'usehooks-ts',
],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
i18next: 'i18next',
},
},
},
},
plugins: [
dts({
insertTypesEntry: true,
outDir: 'build',
include: ['src/**/*'],
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
rollupTypes: false,
copyDtsFiles: false,
}),
],
});

View File

@@ -0,0 +1,24 @@
{
"name": "@actual-app/query",
"version": "0.0.1",
"private": true,
"description": "Query system and database utilities for Actual Budget",
"license": "MIT",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./database": "./src/database.ts",
"./aql-result": "./src/aql-result.ts",
"./spreadsheet": "./src/spreadsheet.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.5.4"
},
"dependencies": {
"@actual-app/shared-types": "workspace:^"
}
}

View File

@@ -0,0 +1,27 @@
/**
* AQL Query Result Types
*
* Types for AQL (Advanced Query Language) query results.
*/
import { DatabaseRow } from './database';
/**
* Result of an AQL query operation
*/
export interface AQLQueryResult {
/** The actual data returned by the query */
data: DatabaseRow[] | DatabaseRow | null;
/** List of table/field dependencies detected during query execution */
dependencies: string[];
}
/**
* Options for AQL query execution
*/
export interface AQLQueryOptions {
/** Target for the query - 'plugin' uses plugin DB, 'host' uses main DB */
target?: 'plugin' | 'host';
/** Parameters to be passed to the query */
params?: Record<string, string | number | boolean | null>;
}

View File

@@ -0,0 +1,51 @@
/**
* Plugin Database Types
*
* Shared types for database operations between plugins and the host application.
*/
/**
* Parameters that can be passed to SQL queries
*/
export type SqlParameter = string | number | boolean | null | Buffer;
/**
* Result of a database query operation (INSERT, UPDATE, DELETE)
*/
export interface DatabaseQueryResult {
changes: number;
insertId?: number;
}
/**
* Row returned from a database SELECT query
*/
export type DatabaseRow = Record<string, SqlParameter>;
/**
* Result of a database SELECT query
*/
export type DatabaseSelectResult = DatabaseRow[];
/**
* Union type for all possible database query results
*/
export type DatabaseResult = DatabaseQueryResult | DatabaseSelectResult;
/**
* Database transaction operation
*/
export interface DatabaseOperation {
type: 'exec' | 'query';
sql: string;
params?: SqlParameter[];
fetchAll?: boolean; // Only used for query operations
}
/**
* Plugin database metadata key-value pair
*/
export interface PluginMetadata {
key: string;
value: string;
}

179
packages/query/src/index.ts Normal file
View File

@@ -0,0 +1,179 @@
/**
* Query System - Single Source of Truth
*
* This is the main query implementation used by both loot-core and plugins.
* No more conversion functions needed!
*/
import {
ObjectExpression,
IQuery,
QueryState,
WithRequired,
} from '@actual-app/shared-types';
export type { ObjectExpression, IQuery, QueryState };
export class Query implements IQuery {
state: QueryState;
constructor(state: WithRequired<Partial<QueryState>, 'table'>) {
this.state = {
tableOptions: state.tableOptions || {},
filterExpressions: state.filterExpressions || [],
selectExpressions: state.selectExpressions || [],
groupExpressions: state.groupExpressions || [],
orderExpressions: state.orderExpressions || [],
calculation: false,
rawMode: false,
withDead: false,
validateRefs: true,
limit: null,
offset: null,
...state,
};
}
filter(expr: ObjectExpression) {
return new Query({
...this.state,
filterExpressions: [...this.state.filterExpressions, expr],
});
}
unfilter(exprs?: Array<keyof ObjectExpression>) {
// Remove all filters if no arguments are passed
if (!exprs) {
return new Query({
...this.state,
filterExpressions: [],
});
}
const exprSet = new Set(exprs);
return new Query({
...this.state,
filterExpressions: this.state.filterExpressions.filter(
expr => !exprSet.has(Object.keys(expr)[0]),
),
});
}
select(
exprs:
| Array<ObjectExpression | string>
| ObjectExpression
| string
| '*'
| ['*'] = [],
) {
if (!Array.isArray(exprs)) {
exprs = [exprs];
}
return new Query({
...this.state,
selectExpressions: exprs,
calculation: false,
});
}
calculate(expr: ObjectExpression | string) {
return new Query({
...this.state,
selectExpressions: [{ result: expr }],
calculation: true,
});
}
groupBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
if (!Array.isArray(exprs)) {
exprs = [exprs];
}
return new Query({
...this.state,
groupExpressions: [...this.state.groupExpressions, ...exprs],
});
}
orderBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
if (!Array.isArray(exprs)) {
exprs = [exprs];
}
return new Query({
...this.state,
orderExpressions: [...this.state.orderExpressions, ...exprs],
});
}
limit(num: number) {
return new Query({ ...this.state, limit: num });
}
offset(num: number) {
return new Query({ ...this.state, offset: num });
}
raw() {
return new Query({ ...this.state, rawMode: true });
}
withDead() {
return new Query({ ...this.state, withDead: true });
}
withoutValidatedRefs() {
return new Query({ ...this.state, validateRefs: false });
}
options(opts: Record<string, unknown>) {
return new Query({ ...this.state, tableOptions: opts });
}
reset() {
return q(this.state.table);
}
serialize() {
return this.state;
}
serializeAsString() {
return JSON.stringify(this.serialize());
}
}
/**
* Query builder function - creates a new Query for the given table
*/
export function q(table: QueryState['table']) {
return new Query({ table });
}
export type { QueryBuilder } from '@actual-app/shared-types';
/**
* Helper function to get primary order by clause
*/
export function getPrimaryOrderBy(
query: Query,
defaultOrderBy: ObjectExpression | null,
) {
const orderExprs = query.serialize().orderExpressions;
if (orderExprs.length === 0) {
if (defaultOrderBy) {
return { order: 'asc', ...defaultOrderBy };
}
return null;
}
const firstOrder = orderExprs[0];
if (typeof firstOrder === 'string') {
return { field: firstOrder, order: 'asc' };
}
// Handle this form: { field: 'desc' }
const [field] = Object.keys(firstOrder);
return { field, order: firstOrder[field] };
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2021",
"noEmit": false,
"declaration": true,
"outDir": "dist",
"declarationDir": "@types"
},
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
}

View File

@@ -0,0 +1,21 @@
{
"name": "@actual-app/shared-types",
"version": "0.0.1",
"private": true,
"description": "Shared type definitions for Actual Budget",
"license": "MIT",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./models/*": "./src/models/*.ts",
"./modalProps": "./src/modalProps.ts",
"./spreadsheet": "./src/spreadsheet.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,8 @@
// Re-export all model types
export type * from './models';
// Export query types
export type * from './query';
// Export utility types
export type * from './util';

View File

@@ -0,0 +1,25 @@
export type AccountEntity = {
id: string;
name: string;
offbudget: 0 | 1;
closed: 0 | 1;
sort_order: number;
last_reconciled: string | null;
tombstone: 0 | 1;
} & (_SyncFields<true> | _SyncFields<false>);
export type _SyncFields<T> = {
account_id: T extends true ? string : null;
bank: T extends true ? string : null;
bankName: T extends true ? string : null;
bankId: T extends true ? number : null;
mask: T extends true ? string : null; // end of bank account number
official_name: T extends true ? string : null;
balance_current: T extends true ? number : null;
balance_available: T extends true ? number : null;
balance_limit: T extends true ? number : null;
account_sync_source: T extends true ? AccountSyncSource : null;
last_sync: T extends true ? string : null;
};
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';

View File

@@ -0,0 +1,11 @@
import { CategoryEntity } from './category';
export interface CategoryGroupEntity {
id: string;
name: string;
is_income?: boolean;
sort_order?: number;
tombstone?: boolean;
hidden?: boolean;
categories?: CategoryEntity[];
}

View File

@@ -0,0 +1,7 @@
import { CategoryEntity } from './category';
import { CategoryGroupEntity } from './category-group';
export interface CategoryViews {
grouped: CategoryGroupEntity[];
list: CategoryEntity[];
}

View File

@@ -0,0 +1,13 @@
import { CategoryGroupEntity } from './category-group';
export interface CategoryEntity {
id: string;
name: string;
is_income?: boolean;
group: CategoryGroupEntity['id'];
goal_def?: string;
template_settings?: { source: 'notes' | 'ui' };
sort_order?: number;
tombstone?: boolean;
hidden?: boolean;
}

View File

@@ -0,0 +1,7 @@
export type * from './account';
export type * from './payee';
export type * from './category';
export type * from './category-group';
export type * from './category-views';
export type * from './rule';
export type * from './schedule';

View File

@@ -0,0 +1,10 @@
import { AccountEntity } from './account';
export interface PayeeEntity {
id: string;
name: string;
transfer_acct?: AccountEntity['id'];
favorite?: boolean;
learn_categories?: boolean;
tombstone?: boolean;
}

View File

@@ -0,0 +1,169 @@
import type { RecurConfig, ScheduleEntity } from './schedule';
export interface NewRuleEntity {
stage: 'pre' | null | 'post';
conditionsOp: 'or' | 'and';
conditions: RuleConditionEntity[];
actions: RuleActionEntity[];
tombstone?: boolean;
}
export interface RuleEntity extends NewRuleEntity {
id: string;
}
export type RuleConditionOp = RuleConditionEntity['op'];
export type FieldValueTypes = {
account: string;
amount: number;
category: string;
date: string | RecurConfig;
notes: string;
payee: string;
payee_name: string;
imported_payee: string;
saved: string;
transfer: boolean;
parent: boolean;
cleared: boolean;
reconciled: boolean;
};
type BaseConditionEntity<
Field extends keyof FieldValueTypes,
Op extends RuleConditionOp,
> = {
field: Field;
op: Op;
value: Op extends 'oneOf' | 'notOneOf'
? Array<FieldValueTypes[Field]>
: Op extends 'isbetween'
? { num1: number; num2: number }
: FieldValueTypes[Field];
options?: {
inflow?: boolean;
outflow?: boolean;
month?: boolean;
year?: boolean;
};
conditionsOp?: string;
type?: 'id' | 'boolean' | 'date' | 'number' | 'string';
customName?: string;
queryFilter?: Record<string, { $oneof: string[] }>;
};
export type RuleConditionEntity =
| BaseConditionEntity<
'account',
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'contains'
| 'doesNotContain'
| 'matches'
| 'onBudget'
| 'offBudget'
>
| BaseConditionEntity<
'category',
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'contains'
| 'doesNotContain'
| 'matches'
>
| BaseConditionEntity<
'amount',
'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte'
>
| BaseConditionEntity<
'date',
'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte'
>
| BaseConditionEntity<
'notes',
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'contains'
| 'doesNotContain'
| 'matches'
| 'hasTags'
>
| BaseConditionEntity<
'payee',
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'contains'
| 'doesNotContain'
| 'matches'
>
| BaseConditionEntity<
'imported_payee',
| 'is'
| 'isNot'
| 'oneOf'
| 'notOneOf'
| 'contains'
| 'doesNotContain'
| 'matches'
>
| BaseConditionEntity<'saved', 'is'>
| BaseConditionEntity<'cleared', 'is'>
| BaseConditionEntity<'reconciled', 'is'>;
export type RuleActionEntity =
| SetRuleActionEntity
| SetSplitAmountRuleActionEntity
| LinkScheduleRuleActionEntity
| PrependNoteRuleActionEntity
| AppendNoteRuleActionEntity
| DeleteTransactionRuleActionEntity;
export interface SetRuleActionEntity {
field: string;
op: 'set';
value: unknown;
options?: {
template?: string;
formula?: string;
splitIndex?: number;
};
type?: string;
}
export interface SetSplitAmountRuleActionEntity {
op: 'set-split-amount';
value: number;
options?: {
splitIndex?: number;
method: 'fixed-amount' | 'fixed-percent' | 'remainder';
};
}
export interface LinkScheduleRuleActionEntity {
op: 'link-schedule';
value: ScheduleEntity;
}
export interface PrependNoteRuleActionEntity {
op: 'prepend-notes';
value: string;
}
export interface AppendNoteRuleActionEntity {
op: 'append-notes';
value: string;
}
export interface DeleteTransactionRuleActionEntity {
op: 'delete-transaction';
value: string;
}

View File

@@ -0,0 +1,49 @@
import type { AccountEntity } from './account';
import type { PayeeEntity } from './payee';
import type { RuleConditionEntity, RuleEntity } from './rule';
export interface RecurPattern {
value: number;
type: 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'day';
}
export interface RecurConfig {
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
interval?: number;
patterns?: RecurPattern[];
skipWeekend?: boolean;
start: string;
endMode: 'never' | 'after_n_occurrences' | 'on_date';
endOccurrences?: number;
endDate?: string;
weekendSolveMode?: 'before' | 'after';
}
export interface ScheduleEntity {
id: string;
name?: string;
rule: RuleEntity['id'];
next_date: string;
completed: boolean;
posts_transaction: boolean;
tombstone: boolean;
// These are special fields that are actually pulled from the
// underlying rule
_payee: PayeeEntity['id'];
_account: AccountEntity['id'];
_amount: number | { num1: number; num2: number };
_amountOp: string;
_date: RecurConfig | string;
_conditions: RuleConditionEntity[];
_actions: Array<{ op: unknown }>;
}
export type DiscoverScheduleEntity = {
id: ScheduleEntity['id'];
account: AccountEntity['id'];
payee: PayeeEntity['id'];
date: RecurConfig;
amount: ScheduleEntity['_amount'];
_conditions: ScheduleEntity['_conditions'];
};

View File

@@ -0,0 +1,54 @@
/**
* Query System Types
*/
export type ObjectExpression = {
[key: string]: ObjectExpression | unknown;
};
export type QueryState = {
get table(): string;
get tableOptions(): Readonly<Record<string, unknown>>;
get filterExpressions(): ReadonlyArray<ObjectExpression>;
get selectExpressions(): ReadonlyArray<ObjectExpression | string | '*'>;
get groupExpressions(): ReadonlyArray<ObjectExpression | string>;
get orderExpressions(): ReadonlyArray<ObjectExpression | string>;
get calculation(): boolean;
get rawMode(): boolean;
get withDead(): boolean;
get validateRefs(): boolean;
get limit(): number | null;
get offset(): number | null;
};
export type IQuery = {
state: QueryState;
filter(expr: ObjectExpression): IQuery;
unfilter(exprs?: Array<keyof ObjectExpression>): IQuery;
select(
exprs:
| Array<ObjectExpression | string>
| ObjectExpression
| string
| '*'
| ['*'],
): IQuery;
calculate(expr: ObjectExpression | string): IQuery;
groupBy(
exprs: ObjectExpression | string | Array<ObjectExpression | string>,
): IQuery;
orderBy(
exprs: ObjectExpression | string | Array<ObjectExpression | string>,
): IQuery;
limit(num: number): IQuery;
offset(num: number): IQuery;
raw(): IQuery;
withDead(): IQuery;
withoutValidatedRefs(): IQuery;
options(opts: Record<string, unknown>): IQuery;
reset(): IQuery;
serialize(): QueryState;
serializeAsString(): string;
};
export type QueryBuilder = (table: string) => IQuery;

View File

@@ -0,0 +1,127 @@
/**
* Spreadsheet Types and Utilities
*
* Core spreadsheet schema definitions and binding utilities used across
* the application for managing financial data in sheet-like structures.
*/
import { IQuery } from './query';
export type Spreadsheets = {
account: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Account fields
balance: number;
[key: `balance-${string}-cleared`]: number | null;
'accounts-balance': number;
'onbudget-accounts-balance': number;
'offbudget-accounts-balance': number;
'closed-accounts-balance': number;
balanceCleared: number;
balanceUncleared: number;
lastReconciled: string | null;
};
category: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
balance: number;
balanceCleared: number;
balanceUncleared: number;
};
'envelope-budget': {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Envelope budget fields
'available-funds': number;
'last-month-overspent': number;
buffered: number;
'buffered-auto': number;
'buffered-selected': number;
'to-budget': number | null;
'from-last-month': number;
'total-budgeted': number;
'total-income': number;
'total-spent': number;
'total-leftover': number;
'group-sum-amount': number;
'group-budget': number;
'group-leftover': number;
budget: number;
'sum-amount': number;
leftover: number;
carryover: number;
goal: number;
'long-goal': number;
};
'tracking-budget': {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Tracking budget fields
'total-budgeted': number;
'total-budget-income': number;
'total-saved': number;
'total-income': number;
'total-spent': number;
'real-saved': number;
'total-leftover': number;
'group-sum-amount': number;
'group-budget': number;
'group-leftover': number;
budget: number;
'sum-amount': number;
leftover: number;
carryover: number;
goal: number;
'long-goal': number;
};
[`balance`]: {
// Common fields
'uncategorized-amount': number;
'uncategorized-balance': number;
// Balance fields
[key: `balance-query-${string}`]: number;
[key: `selected-transactions-${string}`]: Array<{ id: string }>;
[key: `selected-balance-${string}`]: number;
};
};
export type SheetNames = keyof Spreadsheets & string;
export type SheetFields<SheetName extends SheetNames> =
keyof Spreadsheets[SheetName] & string;
export type BindingObject<
SheetName extends SheetNames,
SheetFieldName extends SheetFields<SheetName>,
> = {
name: SheetFieldName;
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
query?: IQuery | undefined;
};
export type Binding<
SheetName extends SheetNames,
SheetFieldName extends SheetFields<SheetName>,
> =
| SheetFieldName
| {
name: SheetFieldName;
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
query?: IQuery | undefined;
};
export const parametrizedField =
<SheetName extends SheetNames>() =>
<SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>
(id?: string): SheetFieldName =>
`${field}-${id}` as SheetFieldName;

View File

@@ -0,0 +1,5 @@
/**
* Utility types for plugins-core
*/
export type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "build",
"composite": true
},
"include": ["src/**/*"]
}

View File

@@ -32,6 +32,7 @@
"paths": {
// TEMPORARY: Until we can fix the "exports" in the loot-core package.json
"loot-core/*": ["./packages/loot-core/src/*"],
"plugins-core/*": ["./packages/plugins-core/src/*"],
"@desktop-client/*": ["./packages/desktop-client/src/*"],
"@desktop-client/e2e/*": ["./packages/desktop-client/e2e/*"]
},
@@ -53,7 +54,8 @@
"**/lib-dist/*",
"**/test-results/*",
"**/playwright-report/*",
"**/service-worker/*"
"**/service-worker/*",
"packages/test-plugin/*"
],
"ts-node": {
"compilerOptions": {

View File

@@ -0,0 +1,7 @@
---
category: Features
authors: [lelemm]
---
Add support for frontend plugins with a new core package for enhanced functionality.

View File

@@ -3,4 +3,4 @@ category: Enhancements
authors: [matt-fidd]
---
Make bank sync accout linking modal mobile responsive
Make bank sync account linking modal mobile responsive

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [youngcw]
---
Fix mobile running balance on accounts with many transactions

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MikesGlitch]
---
Add a wokflow for building a nightly signed desktop app

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MikesGlitch]
---
Nightly & PR builds now provide individual desktop app installers for smaller, faster downloads.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [gopstr]
---
Add Belarusian Ruble currency

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Bump `node-forge` to 1.3.2

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [Juulz]
---
Add left margin to separator between "Average" and "Filter" buttons in Spending report.

694
yarn.lock

File diff suppressed because it is too large Load Diff