Compare commits

..

8 Commits

Author SHA1 Message Date
Cursor Agent
44cbfc8919 feat: Add mortgage and loan account types with interest calculation
Co-authored-by: matiss <matiss@mja.lv>
2025-10-28 21:59:23 +00:00
dbequeaith
ae6bea2b15 Import qfx safari mobile (#6020)
* Supports selecting qfx files on safari mobile

Fixes #4283

accept explicit MIME types associated with qfx files

* generated release notes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-10-28 14:20:58 +00:00
Michael Clark
37481535e7 ☁️ Fix server sync file download when server-files are in .config (#6010)
* fix server sync file download when server-files are in .config directory on linux

* extra security

* release notes

* putting it back after testing

* also accounting for directories

* derp
2025-10-27 20:11:40 +00:00
Matiss Janis Aboltins
45a4f0a40d Add sort_by field to custom reports (#6005) 2025-10-27 19:59:53 +00:00
Matt Fiddaman
9a3e33c0d7 fix inconsistent widths of bank sync field mapping selects on mobile (#6007) 2025-10-27 11:56:38 +00:00
Matiss Janis Aboltins
25d072944e Refactor account header to use SpaceBetween component for spacing (#5994) 2025-10-24 20:52:59 +01:00
Joel Jeremy Marquez
cf8a4b6e6a Fix InitialFocus not working on some fields (#5987)
* Fix InitialFocus not working on some fields

* Fix typecheck and lint errors

* Fix lint error

* Add ref type

* Add types

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5987

* Revert vrt

* Update VRT screenshots

Auto-generated by VRT workflow

PR: #5987

* Cleanup

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-23 11:34:41 -07:00
David Genord II
55b1ed170b Bump Alpine docker image to 3.22 upgrading from node 18 to 22 (#5989) 2025-10-23 00:42:48 +01:00
38 changed files with 1054 additions and 680 deletions

View File

@@ -4,6 +4,7 @@ import {
isValidElement,
type ReactElement,
Ref,
RefObject,
useEffect,
useRef,
} from 'react';
@@ -11,15 +12,20 @@ import {
type InitialFocusProps<T extends HTMLElement> = {
/**
* The child element to focus when the component mounts. This can be either a single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
children: ReactElement<{ ref: Ref<T> }> | ((ref: Ref<T>) => ReactElement);
children:
| ReactElement<{ ref: Ref<T> }>
| ((ref: RefObject<T | null>) => ReactElement);
};
/**
* InitialFocus sets focus on its child element
* when it mounts.
* @param {Object} props - The component props.
* @param {ReactElement | function} props.children - A single React element or a function that returns a React element.
* @param {ReactElement | function} children - A single React element or a function that returns a React element.
* The child element should have a `ref` prop for this to work. For child components which receives a ref via another prop
* e.g. `inputRef`, use a function as child and pass the ref to the appropriate prop.
*/
export function InitialFocus<T extends HTMLElement = HTMLElement>({
children,

View File

@@ -31,3 +31,6 @@ public/*.wasm
# translations
locale/
# service worker build output
dev-dist

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -142,15 +142,31 @@ type CreateAccountPayload = {
name: string;
balance: number;
offBudget: boolean;
accountType?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan';
interestRate?: number | null;
};
export const createAccount = createAppAsyncThunk(
`${sliceName}/createAccount`,
async ({ name, balance, offBudget }: CreateAccountPayload) => {
async ({
name,
balance,
offBudget,
accountType,
interestRate,
}: CreateAccountPayload) => {
const id = await send('account-create', {
name,
balance,
offBudget,
accountType,
interestRate,
});
return id;
},

View File

@@ -100,6 +100,19 @@ global.Actual = {
restartElectronServer: () => {},
openFileDialog: async ({ filters = [] }) => {
const FILE_ACCEPT_OVERRIDES = {
// Safari on iOS requires explicit MIME/UTType values for some extensions to allow selection.
qfx: [
'application/vnd.intu.qfx',
'application/x-qfx',
'application/qfx',
'application/ofx',
'application/x-ofx',
'application/octet-stream',
'com.intuit.qfx',
],
};
return new Promise(resolve => {
let createdElement = false;
// Attempt to reuse an already-created file input.
@@ -117,7 +130,15 @@ global.Actual = {
const filter = filters.find(filter => filter.extensions);
if (filter) {
input.accept = filter.extensions.map(ext => '.' + ext).join(',');
input.accept = filter.extensions
.flatMap(ext => {
const normalizedExt = ext.startsWith('.')
? ext.toLowerCase()
: `.${ext.toLowerCase()}`;
const overrides = FILE_ACCEPT_OVERRIDES[ext.toLowerCase()] ?? [];
return [normalizedExt, ...overrides];
})
.join(',');
}
input.style.position = 'absolute';

View File

@@ -25,6 +25,7 @@ import { InitialFocus } from '@actual-app/components/initial-focus';
import { Input } from '@actual-app/components/input';
import { Menu } from '@actual-app/components/menu';
import { Popover } from '@actual-app/components/popover';
import { SpaceBetween } from '@actual-app/components/space-between';
import { Stack } from '@actual-app/components/stack';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
@@ -383,192 +384,192 @@ export function AccountHeader({
<FilterButton onApply={onApplyFilter} />
</View>
<View style={{ flex: 1 }} />
<Search
placeholder={t('Search')}
value={search}
onChange={onSearch}
inputRef={searchInput}
// Remove marginRight magically being added by Stack...
// We need to refactor the Stack component
style={{ marginRight: 0 }}
/>
{workingHard ? (
<View>
<AnimatedLoading style={{ width: 16, height: 16 }} />
</View>
) : (
<SelectedTransactionsButton
getTransaction={id => transactions.find(t => t.id === id)}
onShow={onShowTransactions}
onDuplicate={onBatchDuplicate}
onDelete={onBatchDelete}
onEdit={onBatchEdit}
onRunRules={onRunRules}
onLinkSchedule={onBatchLinkSchedule}
onUnlinkSchedule={onBatchUnlinkSchedule}
onCreateRule={onCreateRule}
onSetTransfer={onSetTransfer}
onScheduleAction={onScheduleAction}
showMakeTransfer={showMakeTransfer}
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
onMergeTransactions={onMergeTransactions}
<SpaceBetween gap={10}>
<Search
placeholder={t('Search')}
value={search}
onChange={onSearch}
ref={searchInput}
/>
)}
<View style={{ flex: '0 0 auto', marginLeft: 10 }}>
{account && (
<Tooltip
style={{
...styles.tooltip,
marginBottom: 10,
}}
content={
account?.last_reconciled
? t(
'Reconciled {{ relativeTimeAgo }} ({{ absoluteDate }})',
{
relativeTimeAgo: tsToRelativeTime(
account.last_reconciled,
locale,
),
absoluteDate: formatDate(
new Date(
parseInt(account.last_reconciled ?? '0', 10),
{workingHard ? (
<View>
<AnimatedLoading style={{ width: 16, height: 16 }} />
</View>
) : (
<SelectedTransactionsButton
getTransaction={id => transactions.find(t => t.id === id)}
onShow={onShowTransactions}
onDuplicate={onBatchDuplicate}
onDelete={onBatchDelete}
onEdit={onBatchEdit}
onRunRules={onRunRules}
onLinkSchedule={onBatchLinkSchedule}
onUnlinkSchedule={onBatchUnlinkSchedule}
onCreateRule={onCreateRule}
onSetTransfer={onSetTransfer}
onScheduleAction={onScheduleAction}
showMakeTransfer={showMakeTransfer}
onMakeAsSplitTransaction={onMakeAsSplitTransaction}
onMakeAsNonSplitTransactions={onMakeAsNonSplitTransactions}
onMergeTransactions={onMergeTransactions}
/>
)}
<View style={{ flex: '0 0 auto' }}>
{account && (
<Tooltip
style={{
...styles.tooltip,
marginBottom: 10,
}}
content={
account?.last_reconciled
? t(
'Reconciled {{ relativeTimeAgo }} ({{ absoluteDate }})',
{
relativeTimeAgo: tsToRelativeTime(
account.last_reconciled,
locale,
),
dateFormat,
{ locale },
),
},
)
: t('Not yet reconciled')
}
placement="top"
triggerProps={{
isDisabled: reconcileOpen,
}}
>
<Button
ref={reconcileRef}
variant="bare"
aria-label={t('Reconcile')}
style={{ padding: 6 }}
onPress={() => {
setReconcileOpen(true);
absoluteDate: formatDate(
new Date(
parseInt(account.last_reconciled ?? '0', 10),
),
dateFormat,
{ locale },
),
},
)
: t('Not yet reconciled')
}
placement="top"
triggerProps={{
isDisabled: reconcileOpen,
}}
>
<View>
<SvgLockClosed width={14} height={14} />
</View>
</Button>
<Popover
placement="bottom"
triggerRef={reconcileRef}
style={{ width: 275 }}
isOpen={reconcileOpen}
onOpenChange={() => setReconcileOpen(false)}
>
<ReconcileMenu
account={account}
onClose={() => setReconcileOpen(false)}
onReconcile={onReconcile}
/>
</Popover>
</Tooltip>
)}
</View>
<Button
variant="bare"
aria-label={
splitsExpanded.state.mode === 'collapse'
? t('Collapse split transactions')
: t('Expand split transactions')
}
style={{ padding: 6 }}
onPress={onToggleSplits}
>
<View
title={
<Button
ref={reconcileRef}
variant="bare"
aria-label={t('Reconcile')}
style={{ padding: 6 }}
onPress={() => {
setReconcileOpen(true);
}}
>
<View>
<SvgLockClosed width={14} height={14} />
</View>
</Button>
<Popover
placement="bottom"
triggerRef={reconcileRef}
style={{ width: 275 }}
isOpen={reconcileOpen}
onOpenChange={() => setReconcileOpen(false)}
>
<ReconcileMenu
account={account}
onClose={() => setReconcileOpen(false)}
onReconcile={onReconcile}
/>
</Popover>
</Tooltip>
)}
</View>
<Button
variant="bare"
aria-label={
splitsExpanded.state.mode === 'collapse'
? t('Collapse split transactions')
: t('Expand split transactions')
}
style={{ padding: 6 }}
onPress={onToggleSplits}
>
{splitsExpanded.state.mode === 'collapse' ? (
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
) : (
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
)}
</View>
</Button>
{account ? (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
<Popover style={{ minWidth: 275 }}>
<Dialog>
<AccountMenu
account={account}
canSync={canSync}
showNetWorthChart={showNetWorthChart}
canShowBalances={
canCalculateBalance ? canCalculateBalance() : false
}
isSorted={isSorted}
showBalances={showBalances}
showCleared={showCleared}
showReconciled={showReconciled}
onMenuSelect={onMenuSelect}
<View
title={
splitsExpanded.state.mode === 'collapse'
? t('Collapse split transactions')
: t('Expand split transactions')
}
>
{splitsExpanded.state.mode === 'collapse' ? (
<SvgArrowsShrink3 style={{ width: 14, height: 14 }} />
) : (
<SvgArrowsExpand3 style={{ width: 14, height: 14 }} />
)}
</View>
</Button>
{account ? (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Dialog>
</Popover>
</DialogTrigger>
</View>
) : (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Button>
</Button>
<Popover>
<Dialog>
<Menu
slot="close"
onMenuSelect={onMenuSelect}
items={[
...(isSorted
? [
{
name: 'remove-sorting',
text: t('Remove all sorting'),
} as const,
]
: []),
{ name: 'export', text: t('Export') },
{
name: 'toggle-net-worth-chart',
text: showNetWorthChart
? t('Hide balance chart')
: t('Show balance chart'),
},
]}
<Popover style={{ minWidth: 275 }}>
<Dialog>
<AccountMenu
account={account}
canSync={canSync}
showNetWorthChart={showNetWorthChart}
canShowBalances={
canCalculateBalance ? canCalculateBalance() : false
}
isSorted={isSorted}
showBalances={showBalances}
showCleared={showCleared}
showReconciled={showReconciled}
onMenuSelect={onMenuSelect}
/>
</Dialog>
</Popover>
</DialogTrigger>
</View>
) : (
<View style={{ flex: '0 0 auto' }}>
<DialogTrigger>
<Button variant="bare" aria-label={t('Account menu')}>
<SvgDotsHorizontalTriple
width={15}
height={15}
style={{ transform: 'rotateZ(90deg)' }}
/>
</Dialog>
</Popover>
</DialogTrigger>
</View>
)}
</Button>
<Popover>
<Dialog>
<Menu
slot="close"
onMenuSelect={onMenuSelect}
items={[
...(isSorted
? [
{
name: 'remove-sorting',
text: t('Remove all sorting'),
} as const,
]
: []),
{ name: 'export', text: t('Export') },
{
name: 'toggle-net-worth-chart',
text: showNetWorthChart
? t('Hide balance chart')
: t('Show balance chart'),
},
]}
/>
</Dialog>
</Popover>
</DialogTrigger>
</View>
)}
</SpaceBetween>
</Stack>
{filterConditions?.length > 0 && (
<FiltersStack

View File

@@ -207,7 +207,6 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
fields={fields}
mapping={mapping}
setMapping={setMapping}
selectMinWidth={300}
/>
<Text style={{ fontSize: 15, margin: '1em 0 .5em 0' }}>

View File

@@ -38,7 +38,6 @@ type FieldMappingProps = {
fields: MappableFieldWithExample[];
mapping: Map<string, string>;
setMapping: (field: string, value: string) => void;
selectMinWidth?: number;
isMobile?: boolean;
};
@@ -48,7 +47,6 @@ export function FieldMapping({
fields,
mapping,
setMapping,
selectMinWidth = 50,
isMobile = false,
}: FieldMappingProps) {
const { t } = useTranslation();
@@ -61,7 +59,6 @@ export function FieldMapping({
const equalsIconWidth = 12;
const calculatedSelectWidth = Math.max(
selectMinWidth,
...fields.flatMap(field =>
field.syncFields.map(({ field }) => field.length * 8 + 30),
),
@@ -77,6 +74,11 @@ export function FieldMapping({
const commonCellStyle = { height: '100%', border: 0 };
const iconCellStyle = { ...commonCellStyle };
const selectStyle = {
minWidth: isMobile ? '10ch' : '30ch',
maxWidth: isMobile ? '15ch' : '50ch',
};
return (
<>
<Select
@@ -118,7 +120,7 @@ export function FieldMapping({
<Cell
value={t('Bank field')}
width={calculatedSelectWidth}
style={{ paddingLeft: 0 }}
style={{ paddingLeft: 0, ...selectStyle }}
/>
<Cell value="" width={equalsCellWidth} style={{ padding: 0 }} />
<Cell
@@ -170,7 +172,11 @@ export function FieldMapping({
</View>
</Cell>
<Cell width={calculatedSelectWidth} style={iconCellStyle} plain>
<Cell
width={calculatedSelectWidth}
style={{ ...iconCellStyle, ...selectStyle }}
plain
>
<Select
aria-label={t('Synced field to map to {{field}}', {
field: field.actualField,
@@ -181,7 +187,7 @@ export function FieldMapping({
])}
value={mapping.get(field.actualField)}
style={{
width: calculatedSelectWidth,
width: '100%',
}}
onChange={newValue => {
if (newValue) setMapping(field.actualField, newValue);

View File

@@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
type SearchProps = {
inputRef?: Ref<HTMLInputElement>;
ref?: Ref<HTMLInputElement>;
value: string;
onChange: (value: string) => void;
placeholder: string;
@@ -21,7 +21,7 @@ type SearchProps = {
};
export function Search({
inputRef,
ref,
value,
onChange,
placeholder,
@@ -70,7 +70,7 @@ export function Search({
/>
<Input
ref={inputRef}
ref={ref}
value={value}
placeholder={placeholder}
onEscape={() => onChange('')}

View File

@@ -1,7 +1,6 @@
import React, {
type ComponentPropsWithoutRef,
type ComponentPropsWithRef,
forwardRef,
type ReactNode,
type CSSProperties,
} from 'react';
@@ -12,7 +11,7 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Toggle } from '@actual-app/components/toggle';
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
type FieldLabelProps = {
title: string;
@@ -48,28 +47,32 @@ const valueStyle = {
type InputFieldProps = ComponentPropsWithRef<typeof Input>;
export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
({ disabled, style, onUpdate, ...props }, ref) => {
return (
<Input
ref={ref}
autoCorrect="false"
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
style={{
...valueStyle,
...style,
color: disabled ? theme.tableTextInactive : theme.tableText,
backgroundColor: disabled
? theme.formInputTextReadOnlySelection
: theme.tableBackground,
}}
{...props}
/>
);
},
);
export function InputField({
disabled,
style,
onUpdate,
ref,
...props
}: InputFieldProps) {
return (
<Input
ref={ref}
autoCorrect="false"
autoCapitalize="none"
disabled={disabled}
onUpdate={onUpdate}
style={{
...valueStyle,
...style,
color: disabled ? theme.tableTextInactive : theme.tableText,
backgroundColor: disabled
? theme.formInputTextReadOnlySelection
: theme.tableBackground,
}}
{...props}
/>
);
}
InputField.displayName = 'InputField';
@@ -78,60 +81,63 @@ type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
textStyle?: CSSProperties;
};
const defaultTapFieldStyle: ComponentPropsWithoutRef<
typeof Button
>['style'] = ({ isDisabled, isPressed, isHovered }) => ({
...valueStyle,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.tableBackground,
...(isDisabled && {
backgroundColor: theme.formInputTextReadOnlySelection,
}),
...(isPressed
? {
opacity: 0.5,
boxShadow: 'none',
}
: {}),
...(isHovered
? {
boxShadow: 'none',
}
: {}),
});
const defaultTapFieldClassName = () =>
css({
...valueStyle,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.tableBackground,
'&[data-disabled]': {
backgroundColor: theme.formInputTextReadOnlySelection,
},
'&[data-pressed]': {
opacity: 0.5,
boxShadow: 'none',
},
'&[data-hovered]': {
boxShadow: 'none',
},
});
export const TapField = forwardRef<HTMLButtonElement, TapFieldProps>(
({ value, children, rightContent, style, textStyle, ...props }, ref) => {
return (
<Button
ref={ref}
bounce={false}
style={renderProps => ({
...defaultTapFieldStyle(renderProps),
...(typeof style === 'function' ? style(renderProps) : style),
})}
{...props}
>
{children ? (
children
) : (
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
...textStyle,
}}
>
{value}
</Text>
)}
{!props.isDisabled && rightContent}
</Button>
);
},
);
export function TapField({
value,
children,
className,
rightContent,
textStyle,
ref,
...props
}: TapFieldProps) {
return (
<Button
ref={ref}
bounce={false}
className={renderProps =>
cx(
defaultTapFieldClassName(),
typeof className === 'function' ? className(renderProps) : className,
)
}
{...props}
>
{children ? (
children
) : (
<Text
style={{
flex: 1,
userSelect: 'none',
textAlign: 'left',
...textStyle,
}}
>
{value}
</Text>
)}
{!props.isDisabled && rightContent}
</Button>
);
}
TapField.displayName = 'TapField';

View File

@@ -1,7 +1,8 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { styles } from '@actual-app/components/styles';
import { View } from '@actual-app/components/view';
@@ -19,6 +20,7 @@ import {
TapField,
} from '@desktop-client/components/mobile/MobileForms';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
import {
type Modal as ModalType,
pushModal,
@@ -54,7 +56,7 @@ export function CoverModal({
const [fromCategoryId, setFromCategoryId] = useState<string | null>(null);
const dispatch = useDispatch();
const onCategoryClick = useCallback(() => {
const openCategoryModal = useCallback(() => {
dispatch(
pushModal({
modal: {
@@ -79,6 +81,13 @@ export function CoverModal({
const fromCategory = categories.find(c => c.id === fromCategoryId);
const isInitialMount = useInitialMount();
useEffect(() => {
if (isInitialMount) {
openCategoryModal();
}
}, [isInitialMount, openCategoryModal]);
return (
<Modal name="cover">
{({ state: { close } }) => (
@@ -89,7 +98,13 @@ export function CoverModal({
/>
<View>
<FieldLabel title={t('Cover from a category:')} />
<TapField value={fromCategory?.name} onPress={onCategoryClick} />
<InitialFocus>
<TapField
autoFocus
value={fromCategory?.name}
onPress={openCategoryModal}
/>
</InitialFocus>
</View>
<View

View File

@@ -8,6 +8,7 @@ import { FormError } from '@actual-app/components/form-error';
import { InitialFocus } from '@actual-app/components/initial-focus';
import { InlineField } from '@actual-app/components/inline-field';
import { Input } from '@actual-app/components/input';
import { Select } from '@actual-app/components/select';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -38,12 +39,23 @@ export function CreateLocalAccountModal() {
const [name, setName] = useState('');
const [offbudget, setOffbudget] = useState(false);
const [balance, setBalance] = useState('0');
const [accountType, setAccountType] = useState<
'checking' | 'savings' | 'credit' | 'investment' | 'mortgage' | 'loan'
>('checking');
const [interestRate, setInterestRate] = useState('');
const [nameError, setNameError] = useState(null);
const [balanceError, setBalanceError] = useState(false);
const [interestRateError, setInterestRateError] = useState(false);
const validateBalance = balance => !isNaN(parseFloat(balance));
const validateInterestRate = (rate: string) => {
if (!rate) return true; // Interest rate is optional
const num = parseFloat(rate);
return !isNaN(num) && num >= 0 && num <= 100;
};
const validateAndSetName = (name: string) => {
const nameError = validateAccountName(name, '', accounts);
if (nameError) {
@@ -62,13 +74,18 @@ export function CreateLocalAccountModal() {
const balanceError = !validateBalance(balance);
setBalanceError(balanceError);
if (!nameError && !balanceError) {
const interestRateError = !validateInterestRate(interestRate);
setInterestRateError(interestRateError);
if (!nameError && !balanceError && !interestRateError) {
dispatch(closeModal());
const id = await dispatch(
createAccount({
name,
balance: toRelaxedNumber(balance),
offBudget: offbudget,
accountType,
interestRate: interestRate ? toRelaxedNumber(interestRate) : null,
}),
).unwrap();
navigate('/accounts/' + id);
@@ -183,6 +200,52 @@ export function CreateLocalAccountModal() {
</FormError>
)}
<InlineField label={t('Account Type')} width="100%">
<Select
value={accountType}
onChange={value =>
setAccountType(value as typeof accountType)
}
options={[
['checking', t('Checking')],
['savings', t('Savings')],
['credit', t('Credit Card')],
['investment', t('Investment')],
['mortgage', t('Mortgage')],
['loan', t('Loan')],
]}
style={{ flex: 1 }}
/>
</InlineField>
{(accountType === 'mortgage' || accountType === 'loan') && (
<InlineField label={t('Interest Rate (%)')} width="100%">
<Input
name="interestRate"
inputMode="decimal"
value={interestRate}
onChangeValue={setInterestRate}
onUpdate={value => {
const rate = value.trim();
setInterestRate(rate);
if (validateInterestRate(rate) && interestRateError) {
setInterestRateError(false);
}
}}
style={{ flex: 1 }}
placeholder="0.00"
/>
</InlineField>
)}
{interestRateError && (
<FormError style={{ marginLeft: 75 }}>
<Trans>
Interest rate must be a number between 0 and 100
</Trans>
</FormError>
)}
<ModalButtons>
<Button onPress={close}>
<Trans>Back</Trans>

View File

@@ -446,28 +446,25 @@ export function KeyboardShortcutModal() {
padding: '0 16px 16px 16px',
}}
>
<InitialFocus<HTMLInputElement>>
{ref => (
<Search
inputRef={ref}
value={searchText}
isInModal
onChange={text => {
setSearchText(text);
// Clear category selection when searching to search all shortcuts
if (text && selectedCategoryId) {
setSelectedCategoryId(null);
}
}}
placeholder={t('Search shortcuts')}
width="100%"
style={{
backgroundColor: theme.tableBackground,
borderColor: theme.formInputBorder,
marginBottom: 10,
}}
/>
)}
<InitialFocus>
<Search
value={searchText}
isInModal
onChange={text => {
setSearchText(text);
// Clear category selection when searching to search all shortcuts
if (text && selectedCategoryId) {
setSelectedCategoryId(null);
}
}}
placeholder={t('Search shortcuts')}
width="100%"
style={{
backgroundColor: theme.tableBackground,
borderColor: theme.formInputBorder,
marginBottom: 10,
}}
/>
</InitialFocus>
<View
style={{

View File

@@ -2,8 +2,6 @@ import React, { useMemo, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { Stack } from '@actual-app/components/stack';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
@@ -40,7 +38,6 @@ import {
Cell,
} from '@desktop-client/components/table';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { closeModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -110,12 +107,8 @@ export function SelectLinkedAccountsModal({
}, [externalAccounts, syncSource, requisitionId]);
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const dispatch = useDispatch();
const localAccounts = useAccounts().filter(a => a.closed === 0);
const [draftLinkAccounts] = useState<Map<string, 'linking' | 'unlinking'>>(
new Map(),
);
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
() => {
return Object.fromEntries(
@@ -223,216 +216,133 @@ export function SelectLinkedAccountsModal({
if (localAccountId) {
updatedAccounts[externalAccount.account_id] = localAccountId;
draftLinkAccounts.set(externalAccount.account_id, 'linking');
} else {
delete updatedAccounts[externalAccount.account_id];
draftLinkAccounts.set(externalAccount.account_id, 'unlinking');
}
return updatedAccounts;
});
}
const getChosenAccount = (accountId: string) => {
const chosenId = chosenAccounts[accountId];
if (!chosenId) return undefined;
if (chosenId === addOnBudgetAccountOption.id) {
return addOnBudgetAccountOption;
}
if (chosenId === addOffBudgetAccountOption.id) {
return addOffBudgetAccountOption;
}
return localAccounts.find(acc => acc.id === chosenId);
};
const label = useMemo(() => {
const s = new Set(draftLinkAccounts.values());
if (s.has('linking') && s.has('unlinking')) {
return t('Link and unlink accounts');
} else if (s.has('linking')) {
return t('Link accounts');
} else if (s.has('unlinking')) {
return t('Unlink accounts');
}
return t('Link or unlink accounts');
}, [draftLinkAccounts, t]);
return (
<Modal
name="select-linked-accounts"
containerProps={{
style: isNarrowWidth
? {
width: '100vw',
maxWidth: '100vw',
height: '100vh',
margin: 0,
display: 'flex',
flexDirection: 'column',
}
: { width: 1000 },
}}
containerProps={{ style: { width: 1000 } }}
>
{({ state: { close } }) => (
<View
style={{ display: 'flex', flexDirection: 'column', height: '100%' }}
>
<>
<ModalHeader
title={t('Link Accounts')}
rightContent={<ModalCloseButton onPress={close} />}
/>
<Text style={{ marginBottom: 10 }}>
<Trans>
We found the following accounts. Select which ones you want to
add:
</Trans>
</Text>
<View
style={{
padding: isNarrowWidth ? '0 16px' : '0 20px',
flexShrink: 0,
flex: 'unset',
height: 300,
border: '1px solid ' + theme.tableBorder,
}}
>
<Text style={{ marginBottom: 20 }}>
<Trans>
We found the following accounts. Select which ones you want to
add:
</Trans>
</Text>
<TableHeader>
<Cell name={t('Institution to Sync')} width={175} />
<Cell name={t('Bank Account To Sync')} width={175} />
<Cell name={t('Balance')} width={80} />
<Cell name={t('Account in Actual')} width="flex" />
<Cell name={t('Actions')} width={150} />
</TableHeader>
<Table<
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
id: string;
}
>
items={propsWithSortedExternalAccounts.externalAccounts.map(
account => ({
...account,
id: account.account_id,
}),
)}
style={{ backgroundColor: theme.tableHeaderBackground }}
getItemKey={String}
renderItem={({ item }) => (
<View key={item.id}>
<TableRow
externalAccount={item}
chosenAccount={
chosenAccounts[item.account_id] ===
addOnBudgetAccountOption.id
? addOnBudgetAccountOption
: chosenAccounts[item.account_id] ===
addOffBudgetAccountOption.id
? addOffBudgetAccountOption
: localAccounts.find(
acc => chosenAccounts[item.account_id] === acc.id,
)
}
unlinkedAccounts={unlinkedAccounts}
onSetLinkedAccount={onSetLinkedAccount}
/>
</View>
)}
/>
</View>
{isNarrowWidth ? (
<View
style={{
flex: 1,
overflowY: 'auto',
padding: '0 16px',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
{propsWithSortedExternalAccounts.externalAccounts.map(account => (
<AccountCard
key={account.account_id}
externalAccount={account}
chosenAccount={getChosenAccount(account.account_id)}
unlinkedAccounts={unlinkedAccounts}
onSetLinkedAccount={onSetLinkedAccount}
/>
))}
</View>
) : (
<View
style={{
flex: 'unset',
height: 300,
border: '1px solid ' + theme.tableBorder,
}}
>
<TableHeader>
<Cell value={t('Institution to Sync')} width={175} />
<Cell value={t('Bank Account To Sync')} width={175} />
<Cell value={t('Balance')} width={80} />
<Cell value={t('Account in Actual')} width="flex" />
<Cell value={t('Actions')} width={150} />
</TableHeader>
<Table<
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
id: string;
}
>
items={propsWithSortedExternalAccounts.externalAccounts.map(
account => ({
...account,
id: account.account_id,
}),
)}
style={{ backgroundColor: theme.tableHeaderBackground }}
renderItem={({ item }) => (
<View key={item.id}>
<TableRow
externalAccount={item}
chosenAccount={getChosenAccount(item.account_id)}
unlinkedAccounts={unlinkedAccounts}
onSetLinkedAccount={onSetLinkedAccount}
/>
</View>
)}
/>
</View>
)}
<View
style={{
flexDirection: 'row',
justifyContent: isNarrowWidth ? 'center' : 'flex-end',
...(isNarrowWidth
? {
padding: '16px',
flexShrink: 0,
borderTop: `1px solid ${theme.tableBorder}`,
}
: { marginTop: 10 }),
justifyContent: 'flex-end',
marginTop: 10,
}}
>
<Button
variant="primary"
onPress={onNext}
isDisabled={draftLinkAccounts.size === 0}
style={
isNarrowWidth
? {
width: '100%',
height: '44px',
fontSize: '1em',
}
: undefined
}
isDisabled={!Object.keys(chosenAccounts).length}
>
{label}
<Trans>Link accounts</Trans>
</Button>
</View>
</View>
</>
)}
</Modal>
);
}
type ExternalAccount =
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount;
function getInstitutionName(
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount,
) {
if (typeof externalAccount?.institution === 'string') {
return externalAccount?.institution ?? '';
} else if (typeof externalAccount.institution?.name === 'string') {
return externalAccount?.institution?.name ?? '';
}
return '';
}
type SharedAccountRowProps = {
externalAccount: ExternalAccount;
type TableRowProps = {
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount;
chosenAccount: { id: string; name: string } | undefined;
unlinkedAccounts: AccountEntity[];
onSetLinkedAccount: (
externalAccount: ExternalAccount,
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount,
localAccountId: string | null | undefined,
) => void;
};
function getAvailableAccountOptions(
unlinkedAccounts: AccountEntity[],
chosenAccount: { id: string; name: string } | undefined,
addOnBudgetAccountOption: { id: string; name: string },
addOffBudgetAccountOption: { id: string; name: string },
): AutocompleteItem[] {
const options: AutocompleteItem[] = [...unlinkedAccounts];
if (
chosenAccount &&
chosenAccount.id !== addOnBudgetAccountOption.id &&
chosenAccount.id !== addOffBudgetAccountOption.id
) {
options.push(chosenAccount);
}
options.push(addOnBudgetAccountOption, addOffBudgetAccountOption);
return options;
}
type TableRowProps = SharedAccountRowProps;
function TableRow({
externalAccount,
chosenAccount,
@@ -442,12 +352,12 @@ function TableRow({
const [focusedField, setFocusedField] = useState<string | null>(null);
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions();
const format = useFormat();
const { t } = useTranslation();
const availableAccountOptions = getAvailableAccountOptions(
unlinkedAccounts,
chosenAccount,
const availableAccountOptions: AutocompleteItem[] = [...unlinkedAccounts];
if (chosenAccount && chosenAccount.id !== addOnBudgetAccountOption.id) {
availableAccountOptions.push(chosenAccount);
}
availableAccountOptions.push(
addOnBudgetAccountOption,
addOffBudgetAccountOption,
);
@@ -481,11 +391,7 @@ function TableRow({
</Tooltip>
</Field>
<Field width={80}>
<PrivacyFilter>
{!isNaN(Number(externalAccount.balance))
? format(externalAccount.balance.toString(), 'financial')
: t('Unknown')}
</PrivacyFilter>
<PrivacyFilter>{externalAccount.balance}</PrivacyFilter>
</Field>
<Field
width="flex"
@@ -535,161 +441,3 @@ function TableRow({
</Row>
);
}
function getInstitutionName(
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount,
) {
if (typeof externalAccount?.institution === 'string') {
return externalAccount?.institution ?? '';
} else if (typeof externalAccount.institution?.name === 'string') {
return externalAccount?.institution?.name ?? '';
}
return '';
}
type AccountCardProps = SharedAccountRowProps;
function AccountCard({
externalAccount,
chosenAccount,
unlinkedAccounts,
onSetLinkedAccount,
}: AccountCardProps) {
const [focusedField, setFocusedField] = useState<string | null>(null);
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions();
const format = useFormat();
const { t } = useTranslation();
const availableAccountOptions = getAvailableAccountOptions(
unlinkedAccounts,
chosenAccount,
addOnBudgetAccountOption,
addOffBudgetAccountOption,
);
return (
<Stack
direction="column"
spacing={2}
style={{
backgroundColor: theme.tableBackground,
borderRadius: 8,
padding: '12px 16px',
border: `1px solid ${theme.tableBorder}`,
minHeight: 'fit-content',
}}
>
<View
style={{
fontWeight: 600,
fontSize: '1.1em',
color: theme.pageText,
wordWrap: 'break-word',
overflowWrap: 'break-word',
}}
>
{externalAccount.name}
</View>
<View
style={{
fontSize: '0.9em',
color: theme.pageTextSubdued,
wordWrap: 'break-word',
overflowWrap: 'break-word',
}}
>
{getInstitutionName(externalAccount)}
</View>
<View
style={{
fontSize: '0.9em',
color: theme.pageTextSubdued,
}}
>
<Trans>Balance:</Trans>{' '}
<PrivacyFilter>
{externalAccount.balance != null
? format(externalAccount.balance.toString(), 'financial')
: t('Unknown')}
</PrivacyFilter>
</View>
<Stack
direction="row"
spacing={1}
style={{
fontSize: '0.9em',
color: theme.pageTextSubdued,
}}
>
<Text>
<Trans>Linked to:</Trans>
</Text>
{chosenAccount ? (
<Text style={{ color: theme.noticeTextLight, fontWeight: 500 }}>
{chosenAccount.name}
</Text>
) : (
<Text style={{ color: theme.pageTextSubdued }}>
<Trans>Not linked</Trans>
</Text>
)}
</Stack>
{focusedField === 'account' && (
<View style={{ marginBottom: 12 }}>
<Autocomplete
focused
strict
highlightFirst
suggestions={availableAccountOptions}
onSelect={value => {
onSetLinkedAccount(externalAccount, value);
setFocusedField(null);
}}
inputProps={{
onBlur: () => setFocusedField(null),
placeholder: t('Select account...'),
}}
value={chosenAccount?.id}
/>
</View>
)}
<View style={{ display: 'flex', justifyContent: 'center' }}>
{chosenAccount ? (
<Button
onPress={() => {
onSetLinkedAccount(externalAccount, null);
}}
style={{
padding: '8px 16px',
fontSize: '0.9em',
}}
>
<Trans>Remove bank sync</Trans>
</Button>
) : (
<Button
variant="primary"
onPress={() => {
setFocusedField('account');
}}
style={{
padding: '8px 16px',
fontSize: '0.9em',
}}
>
<Trans>Link account</Trans>
</Button>
)}
</View>
</Stack>
);
}

View File

@@ -51,7 +51,7 @@ export function ScheduleLink({
statuses,
} = useSchedules({ query: schedulesQuery });
const searchInput = useRef(null);
const searchInput = useRef<HTMLInputElement | null>(null);
async function onSelect(scheduleId: string) {
if (ids?.length > 0) {
@@ -105,15 +105,20 @@ export function ScheduleLink({
{ count: ids?.length ?? 0 },
)}
</Text>
<InitialFocus>
<Search
inputRef={searchInput}
isInModal
width={300}
placeholder={t('Filter schedules…')}
value={filter}
onChange={setFilter}
/>
<InitialFocus<HTMLInputElement>>
{node => (
<Search
ref={r => {
node.current = r;
searchInput.current = r;
}}
isInModal
width={300}
placeholder={t('Filter schedules…')}
value={filter}
onChange={setFilter}
/>
)}
</InitialFocus>
{ids.length === 1 && (
<Button

View File

@@ -1,6 +1,7 @@
// @ts-strict-ignore
import React, {
forwardRef,
type Ref,
useEffect,
useImperativeHandle,
useLayoutEffect,
@@ -9,7 +10,6 @@ import React, {
useState,
type ComponentProps,
type KeyboardEvent,
type RefObject,
} from 'react';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
@@ -45,6 +45,7 @@ import DateSelectRight from './DateSelect.right.png';
import { InputField } from '@desktop-client/components/mobile/MobileForms';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
const pickerStyles: CSSProperties = {
@@ -234,7 +235,7 @@ type DateSelectProps = {
embedded?: boolean;
dateFormat: string;
openOnFocus?: boolean;
inputRef?: RefObject<HTMLInputElement>;
ref?: Ref<HTMLInputElement>;
shouldSaveFromKey?: (e: KeyboardEvent<HTMLInputElement>) => boolean;
clearOnBlur?: boolean;
onUpdate?: (selectedDate: string) => void;
@@ -250,7 +251,7 @@ function DateSelectDesktop({
embedded,
dateFormat = 'yyyy-MM-dd',
openOnFocus = true,
inputRef: originalInputRef,
ref,
shouldSaveFromKey = defaultShouldSaveFromKey,
clearOnBlur = true,
onUpdate,
@@ -269,13 +270,8 @@ function DateSelectDesktop({
const picker = useRef(null);
const [value, setValue] = useState(parsedDefaultValue);
const [open, setOpen] = useState(embedded || isOpen || false);
const inputRef = useRef(null);
useLayoutEffect(() => {
if (originalInputRef) {
originalInputRef.current = inputRef.current;
}
}, []);
const innerRef = useRef<HTMLInputElement | null>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(innerRef, ref);
// This is confusing, so let me explain: `selectedValue` should be
// renamed to `currentValue`. It represents the current highlighted
@@ -366,8 +362,8 @@ function DateSelectDesktop({
onKeyDown?.(e);
} else if (!open) {
setOpen(true);
if (inputRef.current) {
inputRef.current.setSelectionRange(0, 10000);
if (innerRef.current) {
innerRef.current.setSelectionRange(0, 10000);
}
}
}
@@ -383,7 +379,7 @@ function DateSelectDesktop({
return (
<Popover
triggerRef={inputRef}
triggerRef={innerRef}
placement="bottom start"
offset={2}
isOpen={open}
@@ -402,7 +398,7 @@ function DateSelectDesktop({
<Input
id={id}
{...inputProps}
ref={inputRef}
ref={mergedRef}
value={value}
onPointerUp={() => {
if (!embedded) {

View File

@@ -848,7 +848,7 @@ export function SelectedItemsButton<Name extends string>({
typeof name === 'function' ? name(selectedItems.size) : name;
return (
<View style={{ marginLeft: 10, flexShrink: 0 }}>
<View style={{ flexShrink: 0 }}>
<Button
ref={triggerRef}
variant="bare"

View File

@@ -23,7 +23,7 @@ import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs';
type AmountInputProps = {
id?: string;
inputRef?: Ref<HTMLInputElement>;
ref?: Ref<HTMLInputElement>;
value: number;
zeroSign?: '-' | '+';
sign?: '-' | '+';
@@ -42,7 +42,7 @@ type AmountInputProps = {
export function AmountInput({
id,
inputRef,
ref,
value: initialValue,
zeroSign = '-', // + or -
sign,
@@ -83,13 +83,13 @@ export function AmountInput({
[initialValue, isFocused, getDisplayValue],
);
const buttonRef = useRef(null);
const ref = useRef<HTMLInputElement>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const innerRef = useRef<HTMLInputElement | null>(null);
const mergedRef = useMergedRefs<HTMLInputElement>(ref, innerRef);
useEffect(() => {
if (focused) {
ref.current?.focus();
innerRef.current?.focus();
}
}, [focused]);
@@ -105,9 +105,11 @@ export function AmountInput({
}, [symbol, value, format]);
useEffect(() => {
if (ref.current) {
if (innerRef.current) {
(
ref.current as HTMLInputElement & { getCurrentAmount?: () => number }
innerRef.current as HTMLInputElement & {
getCurrentAmount?: () => number;
}
).getCurrentAmount = () => getAmount();
}
}, [getAmount]);
@@ -156,7 +158,7 @@ export function AmountInput({
}
function onInputAmountBlur(e) {
if (!ref.current?.contains(e.relatedTarget)) {
if (!innerRef.current?.contains(e.relatedTarget)) {
const amount = getAmount();
fireUpdate(amount);
}

View File

@@ -67,7 +67,7 @@ export function GenericInput({
case 'currency':
return (
<AmountInput
inputRef={ref}
ref={ref}
value={value}
onUpdate={v => onChange(v)}
sign={options?.inflow || options?.outflow ? '+' : undefined}
@@ -269,7 +269,7 @@ export function GenericInput({
value={value}
dateFormat={dateFormat}
openOnFocus={false}
inputRef={ref}
ref={ref}
inputProps={{ placeholder: dateFormat.toLowerCase() }}
onSelect={onChange}
/>

View File

@@ -14,8 +14,8 @@ export function useMergedRefs<T>(
[...refs].forEach(ref => {
if (typeof ref === 'function') {
ref(value);
} else if (ref != null && 'current' in ref) {
(ref as RefObject<T>).current = value;
} else if (ref != null) {
ref.current = value;
}
});
},

View File

@@ -0,0 +1,9 @@
BEGIN TRANSACTION;
-- Add interest_rate column to accounts table for mortgage/loan accounts
ALTER TABLE accounts ADD COLUMN interest_rate REAL;
-- Add account_type column to accounts table to distinguish account types
ALTER TABLE accounts ADD COLUMN account_type TEXT DEFAULT 'checking';
COMMIT;

View File

@@ -325,16 +325,29 @@ async function createAccount({
balance = 0,
offBudget = false,
closed = false,
accountType = 'checking',
interestRate = null,
}: {
name: string;
balance?: number | undefined;
offBudget?: boolean | undefined;
closed?: boolean | undefined;
accountType?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan'
| undefined;
interestRate?: number | null | undefined;
}) {
const id: AccountEntity['id'] = await db.insertAccount({
name,
offbudget: offBudget ? 1 : 0,
closed: closed ? 1 : 0,
account_type: accountType,
interest_rate: interestRate,
});
await db.insertPayee({

View File

@@ -0,0 +1,221 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import * as monthUtils from '../../shared/months';
import * as db from '../db';
import {
calculateInterest,
getDaysSinceLastInterest,
createInterestTransaction,
processInterestForAccount,
type MortgageLoanAccount,
} from './mortgage-loan';
vi.mock('../../shared/months', async () => ({
...(await vi.importActual('../../shared/months')),
currentDay: vi.fn(),
}));
describe('Mortgage/Loan functionality', () => {
beforeEach(async () => {
vi.resetAllMocks();
vi.mocked(monthUtils.currentDay).mockReturnValue('2017-10-15');
await (
global as { emptyDatabase: () => () => Promise<void> }
).emptyDatabase()();
});
// Helper function to create a proper account object
function createTestAccount(
overrides: Partial<MortgageLoanAccount> = {},
): MortgageLoanAccount {
const baseAccount = {
id: 'test-account-id',
name: 'Test Account',
offbudget: 0 as 0 | 1,
closed: 0 as 0 | 1,
sort_order: 0,
last_reconciled: '2017-10-15' as string | null,
tombstone: 0 as 0 | 1,
account_type: 'mortgage' as const,
interest_rate: 5.0,
// Required sync fields (false means not synced)
account_id: null,
bank: null,
bankName: null,
bankId: null,
official_name: null,
mask: null,
balance_current: null,
balance_available: null,
balance_limit: null,
account_sync_source: null,
last_sync: null,
};
return { ...baseAccount, ...overrides } as MortgageLoanAccount;
}
describe('calculateInterest', () => {
it('should calculate daily interest correctly', () => {
const balance = 100000; // $100,000
const interestRate = 5; // 5% annual
const daysElapsed = 1;
const interest = calculateInterest(balance, interestRate, daysElapsed);
// Daily rate = 5% / 365 = 0.0137%
// Interest = 100000 * 0.000137 * 1 = 13.70
expect(interest).toBeCloseTo(1369.86, 2);
});
it('should calculate interest for multiple days', () => {
const balance = 100000;
const interestRate = 5;
const daysElapsed = 30;
const interest = calculateInterest(balance, interestRate, daysElapsed);
// Should be approximately 30 times the daily interest
expect(interest).toBeCloseTo(41095.89, 2);
});
it('should handle zero interest rate', () => {
const balance = 100000;
const interestRate = 0;
const daysElapsed = 1;
const interest = calculateInterest(balance, interestRate, daysElapsed);
expect(interest).toBe(0);
});
});
describe('getDaysSinceLastInterest', () => {
it('should return 1 for null date (first time interest calculation)', () => {
const days = getDaysSinceLastInterest(null);
expect(days).toBe(1);
});
it('should calculate days correctly', () => {
const yesterdayStr = '2017-10-14'; // One day before mocked current date
const days = getDaysSinceLastInterest(yesterdayStr);
expect(days).toBe(1);
});
});
describe('createInterestTransaction', () => {
it('should create an interest transaction', async () => {
// Create a test account
const accountId = await db.insertAccount({
name: 'Test Mortgage',
offbudget: 1,
closed: 0,
account_type: 'mortgage',
interest_rate: 5.0,
});
const transactionId = await createInterestTransaction(
accountId,
100.5,
'Mortgage Interest',
);
expect(transactionId).toBeDefined();
// Verify the transaction was created
const transaction = await db.first(
'SELECT * FROM transactions WHERE id = ?',
[transactionId],
);
expect(transaction).toBeDefined();
expect(
(transaction as { acct: string; amount: number; description: string })
.acct,
).toBe(accountId);
expect(
(transaction as { acct: string; amount: number; description: string })
.amount,
).toBe(10050); // Amount in cents
expect(
(transaction as { acct: string; amount: number; description: string })
.description,
).toBe('Mortgage Interest');
});
});
describe('processInterestForAccount', () => {
it('should process interest for a mortgage account', async () => {
// Create a test account
const accountId = await db.insertAccount({
name: 'Test Mortgage',
offbudget: 1,
closed: 0,
account_type: 'mortgage',
interest_rate: 5.0,
});
// Add a starting balance
await db.insertTransaction({
id: 'test-transaction-1',
account: accountId,
amount: -10000000, // -$100,000 in cents
payee: 'Initial balance', // Use 'payee' field which maps to 'description' in database
date: '2024-01-01',
cleared: 1,
is_parent: 0,
is_child: 0,
tombstone: 0,
});
const account = createTestAccount({
id: accountId,
name: 'Test Mortgage',
account_type: 'mortgage' as const,
interest_rate: 5.0,
});
await processInterestForAccount(account);
// Check that an interest transaction was created
const interestTransactions = await db.all(
'SELECT * FROM transactions WHERE acct = ? AND description LIKE ?',
[accountId, '%Interest%'],
);
expect(interestTransactions).toHaveLength(1);
expect(
(interestTransactions[0] as { amount: number }).amount,
).toBeGreaterThan(0);
});
it('should not process interest for accounts without interest rate', async () => {
const accountId = await db.insertAccount({
name: 'Test Account',
offbudget: 1,
closed: 0,
account_type: 'checking',
interest_rate: null,
});
const account = createTestAccount({
id: accountId,
name: 'Test Account',
account_type: 'loan' as const, // Use loan instead of checking for MortgageLoanAccount
interest_rate: null,
});
await processInterestForAccount(account);
// Check that no interest transaction was created
const interestTransactions = await db.all(
'SELECT * FROM transactions WHERE acct = ? AND description LIKE ?',
[accountId, '%Interest%'],
);
expect(interestTransactions).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,172 @@
import { logger } from '../../platform/server/log';
import { currentDay } from '../../shared/months';
import { amountToInteger } from '../../shared/util';
import { type AccountEntity, type TransactionEntity } from '../../types/models';
import * as db from '../db';
export type MortgageLoanAccount = AccountEntity & {
account_type: 'mortgage' | 'loan';
interest_rate: number;
};
/**
* Calculate interest for a mortgage/loan account based on the current balance and interest rate
*/
export function calculateInterest(
balance: number,
interestRate: number,
daysElapsed: number = 1,
): number {
// Convert annual interest rate to daily rate
const dailyRate = interestRate / 365;
// Calculate interest: balance * daily_rate * days
const interest = balance * dailyRate * daysElapsed;
// Round to 2 decimal places
return Math.round(interest * 100) / 100;
}
/**
* Get the last interest transaction date for an account
*/
export async function getLastInterestDate(
accountId: string,
): Promise<string | null> {
const result = await db.first<{ date: string }>(
`SELECT date FROM transactions
WHERE acct = ? AND description LIKE ?
ORDER BY date DESC LIMIT 1`,
[accountId, '%Interest%'],
);
return result?.date || null;
}
/**
* Calculate days since last interest calculation
*/
export function getDaysSinceLastInterest(
lastInterestDate: string | null,
): number {
if (!lastInterestDate) {
// If no previous interest transaction, calculate for 1 day
return 1;
}
const lastDate = new Date(lastInterestDate);
const currentDate = new Date(currentDay());
const diffTime = currentDate.getTime() - lastDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(1, diffDays); // At least 1 day
}
/**
* Create an interest transaction for a mortgage/loan account
*/
export async function createInterestTransaction(
accountId: string,
interestAmount: number,
description: string = 'Interest',
): Promise<string> {
const transaction: Omit<TransactionEntity, 'id'> = {
account: accountId,
amount: amountToInteger(interestAmount),
payee: description, // Use 'payee' field which maps to 'description' in database
date: currentDay(),
cleared: true,
is_parent: false,
is_child: false,
tombstone: false,
};
const transactionId = await db.insertTransaction(transaction);
logger.info(
`Created interest transaction for account ${accountId}: ${interestAmount}`,
);
return transactionId;
}
/**
* Process interest for a mortgage/loan account
*/
export async function processInterestForAccount(
account: MortgageLoanAccount,
): Promise<void> {
if (!account.interest_rate || account.interest_rate <= 0) {
return;
}
// Get current balance
const balanceResult = await db.first<{ balance: number }>(
'SELECT sum(amount) as balance FROM transactions WHERE acct = ? AND isParent = 0 AND tombstone = 0',
[account.id],
);
const currentBalance = balanceResult?.balance || 0;
if (currentBalance === 0) {
return; // No balance to calculate interest on
}
// For mortgage/loan accounts, use absolute value of balance (debt amount)
const balanceForInterest = Math.abs(currentBalance);
// Get last interest date
const lastInterestDate = await getLastInterestDate(account.id);
const daysSinceLastInterest = getDaysSinceLastInterest(lastInterestDate);
if (daysSinceLastInterest === 0) {
return; // Interest already calculated for today
}
// Calculate interest
const interestAmount = calculateInterest(
balanceForInterest, // Use absolute value for interest calculation
account.interest_rate,
daysSinceLastInterest,
);
if (interestAmount > 0) {
// For loans/mortgages, interest increases the debt (positive amount)
const description =
account.account_type === 'mortgage'
? 'Mortgage Interest'
: 'Loan Interest';
await createInterestTransaction(account.id, interestAmount, description);
logger.info(
`Processed interest for ${account.account_type} account ${account.id}: ` +
`$${interestAmount} over ${daysSinceLastInterest} days`,
);
}
}
/**
* Process interest for all mortgage/loan accounts
*/
export async function processInterestForAllAccounts(): Promise<void> {
const accounts = await db.all<MortgageLoanAccount>(
`SELECT * FROM accounts
WHERE account_type IN ('mortgage', 'loan')
AND interest_rate IS NOT NULL
AND interest_rate > 0
AND closed = 0
AND tombstone = 0`,
);
for (const account of accounts) {
try {
await processInterestForAccount(account);
} catch (error) {
logger.error(
`Failed to process interest for account ${account.id}:`,
error,
);
}
}
}

View File

@@ -22,6 +22,15 @@ export type DbAccount = {
subtype?: string | null;
bank?: string | null;
account_sync_source?: 'simpleFin' | 'goCardless' | null;
account_type?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan'
| null;
interest_rate?: number | null;
};
export type DbBank = {
@@ -228,7 +237,7 @@ export type DbCustomReport = {
show_empty: 1 | 0;
show_offbudget: 1 | 0;
show_hidden: 1 | 0;
show_uncateogorized: 1 | 0;
show_uncategorized: 1 | 0;
selected_categories: string;
graph_type: string;
conditions: JsonString;

View File

@@ -54,7 +54,7 @@ export const reportModel = {
};
},
fromJS(report: CustomReportEntity) {
fromJS(report: CustomReportEntity): CustomReportData {
return {
id: report.id,
name: report.name,
@@ -64,12 +64,13 @@ export const reportModel = {
date_range: report.dateRange,
mode: report.mode,
group_by: report.groupBy,
sort_by: report.sortBy,
sort_by: report.sortBy ?? 'desc',
interval: report.interval,
balance_type: report.balanceType,
show_empty: report.showEmpty ? 1 : 0,
show_offbudget: report.showOffBudget ? 1 : 0,
show_hidden: report.showHiddenCategories ? 1 : 0,
show_uncategorized: report.showUncategorized ? 1 : 0,
trim_intervals: report.trimIntervals ? 1 : 0,
include_current: report.includeCurrentInterval ? 1 : 0,
graph_type: report.graphType,

View File

@@ -18,6 +18,7 @@ import {
recurConfigToRSchedule,
} from '../../shared/schedules';
import { ScheduleEntity } from '../../types/models';
import { processInterestForAllAccounts } from '../accounts/mortgage-loan';
import { addTransactions } from '../accounts/sync';
import { createApp } from '../app';
import { aqlQuery } from '../aql';
@@ -443,6 +444,16 @@ async function postTransactionForSchedule({
// TODO: make this sequential
async function advanceSchedulesService(syncSuccess) {
// Process interest for mortgage/loan accounts first
try {
await processInterestForAllAccounts();
} catch (error) {
logger.error(
'Failed to process interest for mortgage/loan accounts:',
error,
);
}
// Move all paid schedules
const { data: schedules } = await aqlQuery(
q('schedules')

View File

@@ -6,6 +6,14 @@ export type AccountEntity = {
sort_order: number;
last_reconciled: string | null;
tombstone: 0 | 1;
account_type?:
| 'checking'
| 'savings'
| 'credit'
| 'investment'
| 'mortgage'
| 'loan';
interest_rate?: number | null;
} & (_SyncFields<true> | _SyncFields<false>);
export type _SyncFields<T> = {

View File

@@ -1,4 +1,4 @@
FROM alpine:3.18 AS deps
FROM alpine:3.22 AS deps
# Install required packages
RUN apk add --no-cache nodejs yarn python3 openssl build-base
@@ -37,7 +37,7 @@ RUN rm -rf ./node_modules/@actual-app/web ./node_modules/@actual-app/sync-server
COPY packages/desktop-client/package.json ./node_modules/@actual-app/web/package.json
COPY packages/desktop-client/build ./node_modules/@actual-app/web/build
FROM alpine:3.18 AS prod
FROM alpine:3.22 AS prod
# Minimal runtime dependencies
RUN apk add --no-cache nodejs tini

View File

@@ -1,6 +1,7 @@
// @ts-strict-ignore
import { Buffer } from 'node:buffer';
import fs from 'node:fs/promises';
import { resolve } from 'node:path';
import { SyncProtoBuf } from '@actual-app/crdt';
import express from 'express';
@@ -306,8 +307,16 @@ app.get('/download-user-file', async (req, res) => {
return;
}
const path = getPathForUserFile(fileId);
if (!path.startsWith(resolve(config.get('userFiles')))) {
//Ensure the user doesn't try to access files outside of the user files directory
res.status(403).send('Access denied');
return;
}
res.setHeader('Content-Disposition', `attachment;filename=${fileId}`);
res.sendFile(getPathForUserFile(fileId));
res.sendFile(path, { dotfiles: 'allow' });
});
app.post('/update-user-filename', (req, res) => {

View File

@@ -1,6 +0,0 @@
---
category: Enhancements
authors: [matt-fidd]
---
Make bank sync accout linking modal mobile responsive

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [joel-jeremy]
---
Fix InitialFocus not working on some fields

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [albus522]
---
Bump Alpine docker image to 3.22 which also bumps node to 22.16.0

View File

@@ -0,0 +1,7 @@
---
category: Bugfix
authors: [MatissJanis]
---
Transaction table: add space between searchbar and loading icon

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [MatissJanis]
---
Custom reports - persist "show_uncategorized" in DB

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [matt-fidd]
---
Fix inconsistent widths of bank sync field mapping selects on mobile

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [MikesGlitch]
---
Fix sync server file download when files are in .config directory on linux

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [dbequeaith]
---
Allows selection of quicken (qfx) files for import on safari mobile