mirror of
https://github.com/actualbudget/actual.git
synced 2026-04-29 02:54:09 -05:00
Compare commits
8 Commits
mobile/lin
...
cursor/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44cbfc8919 | ||
|
|
ae6bea2b15 | ||
|
|
37481535e7 | ||
|
|
45a4f0a40d | ||
|
|
9a3e33c0d7 | ||
|
|
25d072944e | ||
|
|
cf8a4b6e6a | ||
|
|
55b1ed170b |
@@ -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,
|
||||
|
||||
3
packages/desktop-client/.gitignore
vendored
3
packages/desktop-client/.gitignore
vendored
@@ -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 |
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('')}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
221
packages/loot-core/src/server/accounts/mortgage-loan.test.ts
Normal file
221
packages/loot-core/src/server/accounts/mortgage-loan.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
172
packages/loot-core/src/server/accounts/mortgage-loan.ts
Normal file
172
packages/loot-core/src/server/accounts/mortgage-loan.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Make bank sync accout linking modal mobile responsive
|
||||
6
upcoming-release-notes/5987.md
Normal file
6
upcoming-release-notes/5987.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Fix InitialFocus not working on some fields
|
||||
6
upcoming-release-notes/5989.md
Normal file
6
upcoming-release-notes/5989.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [albus522]
|
||||
---
|
||||
|
||||
Bump Alpine docker image to 3.22 which also bumps node to 22.16.0
|
||||
7
upcoming-release-notes/5994.md
Normal file
7
upcoming-release-notes/5994.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Transaction table: add space between searchbar and loading icon
|
||||
|
||||
6
upcoming-release-notes/6005.md
Normal file
6
upcoming-release-notes/6005.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Custom reports - persist "show_uncategorized" in DB
|
||||
6
upcoming-release-notes/6007.md
Normal file
6
upcoming-release-notes/6007.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Fix inconsistent widths of bank sync field mapping selects on mobile
|
||||
6
upcoming-release-notes/6010.md
Normal file
6
upcoming-release-notes/6010.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Fix sync server file download when files are in .config directory on linux
|
||||
6
upcoming-release-notes/6020.md
Normal file
6
upcoming-release-notes/6020.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [dbequeaith]
|
||||
---
|
||||
|
||||
Allows selection of quicken (qfx) files for import on safari mobile
|
||||
Reference in New Issue
Block a user