Compare commits
22 Commits
matiss/uni
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a9cb5192 | ||
|
|
33610fee78 | ||
|
|
9d94e1268c | ||
|
|
52d013cb86 | ||
|
|
e0afbcfd96 | ||
|
|
9ceb74cf6e | ||
|
|
ba00a25c85 | ||
|
|
3df3b5e145 | ||
|
|
c17fa45692 | ||
|
|
7613de013e | ||
|
|
25034ff639 | ||
|
|
41490e1831 | ||
|
|
cf28d05ba7 | ||
|
|
f212559eec | ||
|
|
c25e8c2df5 | ||
|
|
e8c4489657 | ||
|
|
ac07adfff6 | ||
|
|
434ba5221d | ||
|
|
280965f9a0 | ||
|
|
e659ed0554 | ||
|
|
d6a7a892f0 | ||
|
|
2ee1a61689 |
2
.github/actions/docs-spelling/expect.txt
vendored
@@ -108,6 +108,7 @@ prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
pwa
|
||||
Qatari
|
||||
QNTOFRP
|
||||
QONTO
|
||||
@@ -133,6 +134,7 @@ SWEDNOKK
|
||||
Synology
|
||||
systemctl
|
||||
tada
|
||||
taskbar
|
||||
templating
|
||||
THB
|
||||
touchscreen
|
||||
|
||||
32
.github/workflows/build.yml
vendored
@@ -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:
|
||||
|
||||
63
.github/workflows/electron-pr.yml
vendored
@@ -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
|
||||
|
||||
131
.github/workflows/publish-nightly-electron.yml
vendored
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
"loot-core/*": ["./@types/loot-core/loot-core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
|
||||
@@ -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 . .",
|
||||
|
||||
11
packages/component-library/src/Modal.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type CSSProperties } from 'react';
|
||||
|
||||
export type BasicModalProps = {
|
||||
isLoading?: boolean;
|
||||
noAnimation?: boolean;
|
||||
style?: CSSProperties;
|
||||
onClose?: () => void;
|
||||
containerProps?: {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
};
|
||||
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
|
||||
height: 28,
|
||||
backgroundColor: theme.pillBorderDark,
|
||||
marginRight: 10,
|
||||
marginLeft: 10,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

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

|
||||

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

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

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

|
||||
|
||||

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

|
||||
|
||||

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

|
||||
|
||||

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

|
||||
|
||||
|
||||

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

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

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

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

|
||||
|
||||

|
||||
The tool tells us that we have a gain of 3,500.
|
||||
|
||||

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

|
||||
|
||||

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

|
||||
|
||||
|
||||
@@ -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 won’t necessarily result in a better experience_
|
||||
_Your browser does most of Actual's computation,_ so purchasing more resources for the server won’t 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.
|
||||
|
||||

|
||||

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

|
||||

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

|
||||

|
||||
|
||||
|
||||
## Login screen
|
||||
|
||||
Enter the email address and password you registered yourself with.
|
||||
Enter _your_ registration email address and password.
|
||||
|
||||

|
||||

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

|
||||
In 2), you decide which region your Pod should run - choose the most suitable region.
|
||||
|
||||

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

|
||||

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

|
||||

|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
:::
|
||||
|
||||
|
||||

|
||||

|
||||
<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.
|
||||
|
||||
|
||||
BIN
packages/docs/static/img/reconcile/reconcile-10-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-11-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-12.png
vendored
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-2-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-3-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-3a-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-4-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-5-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-6-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-8-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
packages/docs/static/img/reconcile/reconcile-9-2025.png
vendored
Normal file
|
After Width: | Height: | Size: 93 KiB |
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"allowJs": false,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
67
packages/plugins-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
230
packages/plugins-core/src/BasicModalComponents.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
packages/plugins-core/src/client.ts
Normal 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';
|
||||
13
packages/plugins-core/src/index.ts
Normal 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';
|
||||
101
packages/plugins-core/src/middleware.ts
Normal 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;
|
||||
}
|
||||
62
packages/plugins-core/src/server.ts
Normal 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';
|
||||
241
packages/plugins-core/src/types/actualPlugin.ts
Normal 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;
|
||||
}
|
||||
5
packages/plugins-core/src/types/actualPluginEntry.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ActualPluginInitialized } from './actualPlugin';
|
||||
|
||||
export type ActualPluginEntry = () => ActualPluginInitialized;
|
||||
11
packages/plugins-core/src/types/actualPluginManifest.ts
Normal 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;
|
||||
}
|
||||
18
packages/plugins-core/src/types/plugin-files.ts
Normal 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[];
|
||||
7
packages/plugins-core/src/types/toolkit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ActualPluginToolkitFunctions = {
|
||||
pushModal: (modalName: string, options?: unknown) => void;
|
||||
};
|
||||
|
||||
export type ActualPluginToolkit = {
|
||||
functions: ActualPluginToolkitFunctions;
|
||||
};
|
||||
24
packages/plugins-core/src/utils.ts
Normal 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;
|
||||
}
|
||||
14
packages/plugins-core/tsconfig.json
Normal 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"]
|
||||
}
|
||||
50
packages/plugins-core/vite.config.mts
Normal 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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
24
packages/query/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
27
packages/query/src/aql-result.ts
Normal 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>;
|
||||
}
|
||||
51
packages/query/src/database.ts
Normal 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
@@ -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] };
|
||||
}
|
||||
12
packages/query/tsconfig.json
Normal 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"]
|
||||
}
|
||||
21
packages/shared-types/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/shared-types/src/index.ts
Normal 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';
|
||||
25
packages/shared-types/src/models/account.ts
Normal 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';
|
||||
11
packages/shared-types/src/models/category-group.ts
Normal 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[];
|
||||
}
|
||||
7
packages/shared-types/src/models/category-views.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CategoryEntity } from './category';
|
||||
import { CategoryGroupEntity } from './category-group';
|
||||
|
||||
export interface CategoryViews {
|
||||
grouped: CategoryGroupEntity[];
|
||||
list: CategoryEntity[];
|
||||
}
|
||||
13
packages/shared-types/src/models/category.ts
Normal 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;
|
||||
}
|
||||
7
packages/shared-types/src/models/index.ts
Normal 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';
|
||||
10
packages/shared-types/src/models/payee.ts
Normal 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;
|
||||
}
|
||||
169
packages/shared-types/src/models/rule.ts
Normal 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;
|
||||
}
|
||||
49
packages/shared-types/src/models/schedule.ts
Normal 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'];
|
||||
};
|
||||
54
packages/shared-types/src/query.ts
Normal 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;
|
||||
127
packages/shared-types/src/spreadsheet.ts
Normal 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;
|
||||
5
packages/shared-types/src/util.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Utility types for plugins-core
|
||||
*/
|
||||
|
||||
export type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
9
packages/shared-types/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "build",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
7
upcoming-release-notes/5786.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Add support for frontend plugins with a new core package for enhanced functionality.
|
||||
|
||||
@@ -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
|
||||
|
||||
6
upcoming-release-notes/6241.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Fix mobile running balance on accounts with many transactions
|
||||
6
upcoming-release-notes/6242.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Add a wokflow for building a nightly signed desktop app
|
||||
6
upcoming-release-notes/6246.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Nightly & PR builds now provide individual desktop app installers for smaller, faster downloads.
|
||||
6
upcoming-release-notes/6252.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [gopstr]
|
||||
---
|
||||
|
||||
Add Belarusian Ruble currency
|
||||
6
upcoming-release-notes/6260.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Bump `node-forge` to 1.3.2
|
||||
6
upcoming-release-notes/6261.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [Juulz]
|
||||
---
|
||||
|
||||
Add left margin to separator between "Average" and "Filter" buttons in Spending report.
|
||||