Compare commits

...

15 Commits

Author SHA1 Message Date
lelemm
d0c00252e3 code rabbit suggestion 2026-02-09 10:46:37 -03:00
lelemm
f53d3c662d Update .github/workflows/ai-generated-release-notes.yml
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-09 10:44:09 -03:00
lelemm
a8a3355354 lint 2026-02-09 10:41:56 -03:00
github-actions[bot]
9d51ef7ce8 Add release notes for PR #6917 2026-02-09 13:40:37 +00:00
lelemm
e6d63f620e Auto note 2026-02-09 10:34:35 -03:00
xaviuzz
266e7f9cac Fix Ctrl+Enter losing amount value when adding transaction (#6911)
* Fix Ctrl+Enter losing amount value when adding transaction

Fixes #6901

When using Ctrl+Enter to add a transaction immediately after typing
in the amount field, the value wasn't being committed before the
transaction was saved, resulting in a zero amount.

The fix wraps the add-and-close logic in an afterSave() callback
to ensure field values are committed before adding the transaction.

Added regression tests for both debit and credit fields to verify
the fix works correctly.

* Add release notes for PR #6911

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-08 21:39:25 +00:00
Michael Clark
e951e21fe1 🎨 Storybook docs for block, card, colorpicker, formerror (#6874)
* storybook docs for block, card, colorpicker, formerror

* release notes
2026-02-07 09:40:30 +00:00
Stephen Brown II
1a26253457 Update the Create Linked Account workflow to prompt for Starting Date and Balance (#6629)
* feat: Add optional starting date and balance for bank sync accounts

Adds the ability to specify a custom starting date and balance when
linking new bank sync accounts in the Select Linked Accounts modal.

Addresses: https://discord.com/channels/937901803608096828/1402270361625563186

Changes:
- Frontend: Added inline date and amount input fields in the account
  linking table for new accounts
- Redux: Extended link account actions to accept startingDate and
  startingBalance parameters
- Backend: Updated account linking handlers to pass custom values to
  sync logic
- Sync: Modified syncAccount and processBankSyncDownload to use custom
  starting date/balance for initial sync transactions

Features:
- Only displays starting options when creating new accounts (not upgrades)
- AmountInput with smart sign detection based on account balance
  (negative for credit cards/loans)
- Defaults to 90 days ago for date and 0 for balance
- Mobile-responsive with separate AccountCard layout
- Works across all sync providers: GoCardless, SimpleFIN, Pluggy.ai

The custom starting balance is used directly for the starting balance
transaction, and the custom starting date determines both the sync
start date and the transaction date for the starting balance entry.

* refactor: Extract shared types and components for starting balance inputs

- Create CustomStartingSettings type to replace repeated inline type definitions
- Extract StartingOptionsInput component to consolidate duplicate UI between mobile/desktop views
- Create LinkAccountBasePayload type shared across GoCardless, SimpleFIN, and PluggyAI link functions
- Apply same base type pattern to server-side link account handlers

This simplifies the code introduced for custom starting date/balance when linking bank accounts.

[autofix.ci] apply automated fixes

* allow explicit zero values

* refactor: add type guard for BankSyncError to remove oxlint-disable

- Create isBankSyncError() type guard function with proper type narrowing
- Remove oxlint-disable-next-line comment that suppressed the no-explicit-any rule
- Add JSDoc comments for both isBankSyncError and handleSyncError functions
- Remove redundant type assertion now that type guard narrows correctly

* refactor: address code review nitpicks for SelectLinkedAccountsModal

- Use locale-aware date formatting instead of toISOString()
- Extract isNewAccountOption helper to reduce duplication
- Align AccountCardProps type definition pattern with TableRowProps

* Add placeholder date/balance for already linked accounts

* [autofix.ci] apply automated fixes

* Use StartingBalanceInfo only, and add mobile view

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-06 21:44:30 +00:00
distantvapor
e72f18c5db Add new theme 'You Need A Dark Mode' to catalog (#6891)
* Add new theme 'You Need A Dark Mode' to catalog

* Add 'You Need A Dark Mode' theme to catalog
2026-02-06 20:18:12 +00:00
Christian Speich
5deb2cf790 Add bank sync option to update dates. (#6850)
Signed-off-by: Christian Speich <christian@spei.ch>
2026-02-06 20:16:32 +00:00
Joel Jeremy Marquez
111e01449d Add build-electron to tsconfig excludes (#6883)
* Add build-electron to tsconfig excludes

* Add release notes for PR #6883

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-06 19:20:03 +00:00
Joel Jeremy Marquez
c0bd920c26 Fix react-hooks/exhaustive-deps in DateSelect (#6864)
* Fix react-hooks/exhaustive-deps in DateSelect

* Add release notes for PR #6864

* Fix remaining suppressions

* Change category to Maintenance and fix linting issues

Updated category from Enhancements to Maintenance and fixed linting issues related to react-hooks/exhaustive-deps in DateSelect.

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-06 18:03:31 +00:00
Stephen Brown II
b695af66c0 Avoid duplicate category import errors in YNAB5 importer (#6878)
* avoid duplicate category import errors

Add normalizeError helper function

* Add release notes file
2026-02-06 16:15:56 +00:00
Noah
650521f05b Add Catppuccin Themes to custom theme catalog (#6857)
* Add Catppuccin Themes to theme catalog

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-06 16:07:45 +00:00
tabedzki
738a8cda7c Fix date range calculation in BudgetAnalysisCard (#6875)
* fix: corrected date range calculation in BudgetAnalysisCard using calculateTimeRange

* add release note

* fix: ensure correct date formatting in BudgetAnalysisCard for start and end dates

* fix: rename release note file
2026-02-06 15:58:31 +00:00
38 changed files with 1398 additions and 175 deletions

View File

@@ -28,7 +28,7 @@ try {
}
const data = JSON.stringify({
model: 'gpt-4o-mini',
model: 'gpt-4.1-mini',
messages: [
{
role: 'system',
@@ -71,30 +71,26 @@ try {
const rawContent = response.choices[0].message.content.trim();
console.log('Raw content from OpenAI:', rawContent);
let category;
try {
category = JSON.parse(rawContent);
console.log('Parsed category:', category);
} catch (parseError) {
console.log(
'JSON parse error, using raw content:',
parseError.message,
);
category = rawContent;
}
//CHANGED HOW IT READS THE CATEGORY TO AVOID ERRORS WHEN LLM DOESNT ANSWER A JSON
// Validate the category response
const validCategories = [
'Features',
'Bugfixes',
'Enhancements',
'Maintenance',
];
if (validCategories.includes(category)) {
const lowerContent = rawContent.toLowerCase();
const category = validCategories.find(cat =>
lowerContent.includes(cat.toLowerCase()),
);
if (category) {
console.log('OpenAI categorized as:', category);
setOutput('result', category);
} else {
console.log('Invalid category from OpenAI:', category);
console.log(
'No valid category found in OpenAI response:',
rawContent,
);
console.log('Valid categories are:', validCategories);
setOutput('result', 'null');
}

View File

@@ -28,7 +28,7 @@ try {
console.log('CodeRabbit comment body:', commentBody);
const data = JSON.stringify({
model: 'gpt-4o-mini',
model: 'gpt-4.1-mini',
messages: [
{
role: 'system',

View File

@@ -83,11 +83,22 @@ jobs:
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
# Internal bot doesn't trigger workflows on commit; switching to Actual Bot App
- name: Generate GitHub App token
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.ACTUAL_BOT_APP_ID }}
private-key: ${{ secrets.ACTUAL_BOT_PRIVATE_KEY }}
owner: actualbudget
repositories: actual
- name: Create and commit release notes file via GitHub API
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
env:
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
@@ -97,7 +108,7 @@ jobs:
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}

View File

@@ -26,6 +26,7 @@ const config: StorybookConfig = {
core: {
disableTelemetry: true,
},
staticDirs: ['./public'],
async viteFinal(config) {
const { mergeConfig } = await import('vite');

View File

@@ -0,0 +1,9 @@
# /assets folder contain processed assets with a file hash
# They are safe for immutable caching, as filename change when content change
/assets/*
Cache-Control: public
Cache-Control: max-age=365000000
Cache-Control: immutable

View File

@@ -0,0 +1,139 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { AlignedText } from './AlignedText';
const meta = {
title: 'AlignedText',
component: AlignedText,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof AlignedText>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
left: 'Label',
right: 'Value',
style: { width: 300, display: 'flex' },
},
parameters: {
docs: {
description: {
story:
'AlignedText displays two pieces of content aligned on opposite sides.',
},
},
},
};
export const TruncateLeft: Story = {
args: {
left: 'This is a very long label that should be truncated on the left side',
right: '$100.00',
truncate: 'left',
style: { width: 250, display: 'flex' },
},
parameters: {
docs: {
description: {
story:
'When `truncate="left"`, the left content is truncated with ellipsis.',
},
},
},
};
export const TruncateRight: Story = {
args: {
left: 'Short Label',
right:
'This is a very long value that should be truncated on the right side',
truncate: 'right',
style: { width: 250, display: 'flex' },
},
parameters: {
docs: {
description: {
story:
'When `truncate="right"`, the right content is truncated with ellipsis.',
},
},
},
};
export const FinancialAmount: Story = {
args: {
left: 'Groceries',
right: '$1,234.56',
style: { width: 300, display: 'flex' },
rightStyle: { fontWeight: 'bold' },
},
parameters: {
docs: {
description: {
story:
'Example showing AlignedText used for displaying financial data.',
},
},
},
};
export const WithCustomStyles: Story = {
args: {
left: 'Category',
right: 'Amount',
style: {
width: 300,
padding: 10,
backgroundColor: '#f5f5f5',
borderRadius: 4,
display: 'flex',
},
leftStyle: { color: '#666', fontStyle: 'italic' },
rightStyle: { color: '#333', fontWeight: 'bold' },
},
};
export const MultipleRows: Story = {
args: {
left: 'Income',
right: '$5,000.00',
},
render: () => (
<div
style={{ width: 300, display: 'flex', flexDirection: 'column', gap: 8 }}
>
<AlignedText
left="Income"
right="$5,000.00"
rightStyle={{ color: 'green' }}
style={{ display: 'flex' }}
/>
<AlignedText
left="Expenses"
right="-$3,200.00"
rightStyle={{ color: 'red' }}
style={{ display: 'flex' }}
/>
<AlignedText
left="Balance"
right="$1,800.00"
style={{ borderTop: '1px solid #ccc', paddingTop: 8, display: 'flex' }}
rightStyle={{ fontWeight: 'bold' }}
/>
</div>
),
parameters: {
docs: {
description: {
story:
'Multiple AlignedText components stacked to create a summary view.',
},
},
},
};

View File

@@ -0,0 +1,111 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Block } from './Block';
import { theme } from './theme';
const meta = {
title: 'Block',
component: Block,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof Block>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'This is a Block component',
},
parameters: {
docs: {
description: {
story:
'Block is a basic div wrapper that accepts Emotion CSS styles via the `style` prop.',
},
},
},
tags: ['autodocs'],
};
export const WithStyles: Story = {
args: {
children: 'Styled Block',
style: {
padding: 20,
backgroundColor: theme.cardBackground,
borderRadius: 8,
border: `1px solid ${theme.cardBorder}`,
color: theme.pageText,
},
},
};
export const WithFlexLayout: Story = {
render: () => (
<Block
style={{
display: 'flex',
gap: 10,
padding: 15,
borderRadius: 4,
color: theme.pageText,
}}
>
<Block
style={{
padding: 10,
backgroundColor: theme.cardBackground,
borderRadius: 4,
border: `1px solid ${theme.cardBorder}`,
}}
>
Item 1
</Block>
<Block
style={{
padding: 10,
backgroundColor: theme.cardBackground,
borderRadius: 4,
border: `1px solid ${theme.cardBorder}`,
}}
>
Item 2
</Block>
<Block
style={{
padding: 10,
backgroundColor: theme.cardBackground,
borderRadius: 4,
border: `1px solid ${theme.cardBorder}`,
}}
>
Item 3
</Block>
</Block>
),
parameters: {
docs: {
description: {
story: 'Block components can be nested and styled with flexbox.',
},
},
},
};
export const AsContainer: Story = {
args: {
children: 'Container Block',
style: {
width: 300,
padding: 25,
textAlign: 'center',
backgroundColor: theme.cardBackground,
border: `2px dashed ${theme.cardBorder}`,
borderRadius: 8,
color: theme.pageText,
},
},
};

View File

@@ -20,7 +20,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Primary: Story = {
args: {
variant: 'primary',

View File

@@ -0,0 +1,82 @@
import { styles } from '@actual-app/components/styles';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Card } from './Card';
import { Paragraph } from './Paragraph';
import { theme } from './theme';
const meta = {
title: 'Card',
component: Card,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'Card content goes here',
style: {
padding: 20,
width: 300,
color: theme.pageText,
},
},
parameters: {
docs: {
description: {
story: `
Default Card component uses the following theme CSS variables:
- \`--color-cardBackground\`
- \`--color-cardBorder\`
`,
},
},
},
};
export const WithCustomContent: Story = {
args: {
style: {
padding: 20,
width: 300,
color: theme.pageText,
},
},
render: args => (
<Card {...args}>
<h3 style={{ ...styles.largeText }}>Card Title</h3>
<Paragraph style={{ margin: 0 }}>
This is a card with more complex content including a title and
paragraph.
</Paragraph>
</Card>
),
};
export const Narrow: Story = {
args: {
children: 'Narrow card',
style: {
padding: 15,
width: 150,
color: theme.pageText,
},
},
};
export const Wide: Story = {
args: {
children: 'Wide card with more content space',
style: {
padding: 25,
width: 500,
color: theme.pageText,
},
},
};

View File

@@ -0,0 +1,108 @@
import { useState } from 'react';
import { ColorSwatch } from 'react-aria-components';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import { Button } from './Button';
import { ColorPicker } from './ColorPicker';
const meta = {
title: 'ColorPicker',
component: ColorPicker,
parameters: {
layout: 'centered',
},
args: {
onChange: fn(),
children: <Button>Pick a color</Button>,
},
tags: ['autodocs'],
} satisfies Meta<typeof ColorPicker>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
defaultValue: '#690CB0',
children: <Button>Pick a color</Button>,
},
};
export const WithColorSwatch: Story = {
args: {
defaultValue: '#1976D2',
children: (
<Button style={{ padding: 4 }}>
<ColorSwatch
style={{
width: 24,
height: 24,
borderRadius: 4,
boxShadow: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)',
}}
/>
</Button>
),
},
};
export const CustomColorSet: Story = {
args: {
defaultValue: '#FF0000',
columns: 4,
colorset: [
'#FF0000',
'#00FF00',
'#0000FF',
'#FFFF00',
'#FF00FF',
'#00FFFF',
'#FFA500',
'#800080',
],
children: <Button>Custom Colors</Button>,
},
parameters: {
docs: {
description: {
story:
'ColorPicker with a custom color set and different column layout.',
},
},
},
};
export const Controlled: Story = {
args: {
children: <Button>Pick a color</Button>,
},
render: () => {
const [color, setColor] = useState('#388E3C');
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<ColorPicker value={color} onChange={c => setColor(c.toString('hex'))}>
<Button style={{ padding: 4 }}>
<ColorSwatch
style={{
width: 24,
height: 24,
borderRadius: 4,
}}
/>
</Button>
</ColorPicker>
<span>Selected: {color}</span>
</div>
);
},
parameters: {
docs: {
description: {
story: 'Controlled ColorPicker with external state management.',
},
},
},
};

View File

@@ -0,0 +1,90 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { FormError } from './FormError';
import { Input } from './Input';
import { View } from './View';
const meta = {
title: 'FormError',
component: FormError,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof FormError>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'This field is required',
},
parameters: {
docs: {
description: {
story: 'FormError displays validation error messages in red text.',
},
},
},
};
export const InFormContext: Story = {
render: () => (
<View
style={{ display: 'flex', flexDirection: 'column', gap: 5, width: 250 }}
>
<Input placeholder="Email address" style={{ borderColor: 'red' }} />
<FormError>Please enter a valid email address</FormError>
</View>
),
parameters: {
docs: {
description: {
story:
'FormError displayed below an input field with validation error.',
},
},
},
};
export const MultipleErrors: Story = {
render: () => (
<View style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<FormError>Password must be at least 8 characters</FormError>
<FormError>Password must contain a number</FormError>
<FormError>Password must contain a special character</FormError>
</View>
),
parameters: {
docs: {
description: {
story:
'Multiple FormError components for displaying several validation errors.',
},
},
},
};
export const CustomStyle: Story = {
args: {
children: 'Custom styled error message',
style: {
fontSize: 14,
fontWeight: 'bold',
padding: 10,
backgroundColor: '#ffebee',
borderRadius: 4,
border: '1px solid red',
},
},
};
export const LongErrorMessage: Story = {
args: {
children:
'This is a longer error message that explains the validation issue in more detail. Please correct the input and try again.',
style: { maxWidth: 300 },
},
};

View File

@@ -245,17 +245,30 @@ export const unlinkAccount = createAppAsyncThunk(
},
);
type LinkAccountPayload = {
// Shared base type for link account payloads
type LinkAccountBasePayload = {
upgradingId?: AccountEntity['id'];
offBudget?: boolean;
startingDate?: string;
startingBalance?: number;
};
type LinkAccountPayload = LinkAccountBasePayload & {
requisitionId: string;
account: SyncServerGoCardlessAccount;
upgradingId?: AccountEntity['id'] | undefined;
offBudget?: boolean | undefined;
};
export const linkAccount = createAppAsyncThunk(
`${sliceName}/linkAccount`,
async (
{ requisitionId, account, upgradingId, offBudget }: LinkAccountPayload,
{
requisitionId,
account,
upgradingId,
offBudget,
startingDate,
startingBalance,
}: LinkAccountPayload,
{ dispatch },
) => {
await send('gocardless-accounts-link', {
@@ -263,50 +276,64 @@ export const linkAccount = createAppAsyncThunk(
account,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
dispatch(markPayeesDirty());
dispatch(markAccountsDirty());
},
);
type LinkAccountSimpleFinPayload = {
type LinkAccountSimpleFinPayload = LinkAccountBasePayload & {
externalAccount: SyncServerSimpleFinAccount;
upgradingId?: AccountEntity['id'] | undefined;
offBudget?: boolean | undefined;
};
export const linkAccountSimpleFin = createAppAsyncThunk(
`${sliceName}/linkAccountSimpleFin`,
async (
{ externalAccount, upgradingId, offBudget }: LinkAccountSimpleFinPayload,
{
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
}: LinkAccountSimpleFinPayload,
{ dispatch },
) => {
await send('simplefin-accounts-link', {
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
dispatch(markPayeesDirty());
dispatch(markAccountsDirty());
},
);
type LinkAccountPluggyAiPayload = {
type LinkAccountPluggyAiPayload = LinkAccountBasePayload & {
externalAccount: SyncServerPluggyAiAccount;
upgradingId?: AccountEntity['id'];
offBudget?: boolean;
};
export const linkAccountPluggyAi = createAppAsyncThunk(
`${sliceName}/linkAccountPluggyAi`,
async (
{ externalAccount, upgradingId, offBudget }: LinkAccountPluggyAiPayload,
{
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
}: LinkAccountPluggyAiPayload,
{ dispatch },
) => {
await send('pluggyai-accounts-link', {
externalAccount,
upgradingId,
offBudget,
startingDate,
startingBalance,
});
dispatch(markPayeesDirty());
dispatch(markAccountsDirty());

View File

@@ -152,6 +152,8 @@ type BankSyncCheckboxOptionsProps = {
setReimportDeleted: (value: boolean) => void;
importTransactions: boolean;
setImportTransactions: (value: boolean) => void;
updateDates: boolean;
setUpdateDates: (value: boolean) => void;
helpMode?: 'desktop' | 'mobile';
};
@@ -164,6 +166,8 @@ export function BankSyncCheckboxOptions({
setReimportDeleted,
importTransactions,
setImportTransactions,
updateDates,
setUpdateDates,
helpMode = 'desktop',
}: BankSyncCheckboxOptionsProps) {
const { t } = useTranslation();
@@ -214,6 +218,18 @@ export function BankSyncCheckboxOptions({
>
<Trans>Investment Account</Trans>
</CheckboxOptionWithHelp>
<CheckboxOptionWithHelp
id="form_update_dates"
checked={updateDates}
onChange={() => setUpdateDates(!updateDates)}
helpText={t(
'By enabling this, the transaction date will be overwritten by the one provided by the bank.',
)}
helpMode={helpMode}
>
<Trans>Update Dates</Trans>
</CheckboxOptionWithHelp>
</>
);
}

View File

@@ -148,6 +148,8 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
setReimportDeleted,
importTransactions,
setImportTransactions,
updateDates,
setUpdateDates,
mappings,
setMapping,
fields,
@@ -222,6 +224,8 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
setReimportDeleted={setReimportDeleted}
importTransactions={importTransactions}
setImportTransactions={setImportTransactions}
updateDates={updateDates}
setUpdateDates={setUpdateDates}
helpMode="desktop"
/>

View File

@@ -32,6 +32,10 @@ export function useBankSyncAccountSettings(accountId: string) {
const [savedImportTransactions = true, setSavedImportTransactions] =
useSyncedPref(`sync-import-transactions-${accountId}`);
const [savedUpdateDates = false, setSavedUpdateDates] = useSyncedPref(
`sync-update-dates-${accountId}`,
);
const [transactionDirection, setTransactionDirection] =
useState<TransactionDirection>('payment');
const [importPending, setImportPending] = useState(
@@ -49,6 +53,9 @@ export function useBankSyncAccountSettings(accountId: string) {
const [importTransactions, setImportTransactions] = useState(
String(savedImportTransactions) === 'true',
);
const [updateDates, setUpdateDates] = useState(
String(savedUpdateDates) === 'true',
);
const transactionQuery = q('transactions')
.filter({
@@ -84,6 +91,7 @@ export function useBankSyncAccountSettings(accountId: string) {
setSavedImportNotes(String(importNotes));
setSavedReimportDeleted(String(reimportDeleted));
setSavedImportTransactions(String(importTransactions));
setSavedUpdateDates(String(updateDates));
};
const setMapping = (field: string, value: string) => {
@@ -110,6 +118,8 @@ export function useBankSyncAccountSettings(accountId: string) {
setReimportDeleted,
importTransactions,
setImportTransactions,
updateDates,
setUpdateDates,
mappings,
setMapping,
exampleTransaction,

View File

@@ -37,6 +37,8 @@ export function MobileBankSyncAccountEditPage() {
setReimportDeleted,
importTransactions,
setImportTransactions,
updateDates,
setUpdateDates,
mappings,
setMapping,
fields,
@@ -146,6 +148,8 @@ export function MobileBankSyncAccountEditPage() {
setReimportDeleted={setReimportDeleted}
importTransactions={importTransactions}
setImportTransactions={setImportTransactions}
updateDates={updateDates}
setUpdateDates={setUpdateDates}
helpMode="mobile"
/>
</View>

View File

@@ -1,15 +1,18 @@
import React, { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { Input } from '@actual-app/components/input';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { format as formatDate, parseISO } from 'date-fns';
import { currentDay, subDays } from 'loot-core/shared/months';
import {
type AccountEntity,
type SyncServerGoCardlessAccount,
@@ -41,9 +44,13 @@ import {
Table,
TableHeader,
} from '@desktop-client/components/table';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { closeModal } from '@desktop-client/modals/modalsSlice';
import { transactions } from '@desktop-client/queries';
import { liveQuery } from '@desktop-client/queries/liveQuery';
import { useDispatch } from '@desktop-client/redux';
function useAddBudgetAccountOptions() {
@@ -61,6 +68,20 @@ function useAddBudgetAccountOptions() {
return { addOnBudgetAccountOption, addOffBudgetAccountOption };
}
/**
* Helper to determine if the chosen account option represents creating a new account.
*/
function isNewAccountOption(
chosenAccountId: string | undefined,
addOnBudgetOptionId: string,
addOffBudgetOptionId: string,
): boolean {
return (
chosenAccountId === addOnBudgetOptionId ||
chosenAccountId === addOffBudgetOptionId
);
}
export type SelectLinkedAccountsModalProps =
| {
requisitionId: string;
@@ -129,6 +150,9 @@ export function SelectLinkedAccountsModal({
);
},
);
const [customStartingDates, setCustomStartingDates] = useState<
Record<string, StartingBalanceInfo>
>({});
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions();
@@ -158,6 +182,14 @@ export function SelectLinkedAccountsModal({
}
// Finally link the matched account
const customSettings = customStartingDates[chosenExternalAccountId];
const startingDate =
customSettings?.date && customSettings.date.trim() !== ''
? customSettings.date
: undefined;
const startingBalance =
customSettings?.amount != null ? customSettings.amount : undefined;
if (propsWithSortedExternalAccounts.syncSource === 'simpleFin') {
dispatch(
linkAccountSimpleFin({
@@ -171,6 +203,8 @@ export function SelectLinkedAccountsModal({
? chosenLocalAccountId
: undefined,
offBudget,
startingDate,
startingBalance,
}),
);
} else if (propsWithSortedExternalAccounts.syncSource === 'pluggyai') {
@@ -186,6 +220,8 @@ export function SelectLinkedAccountsModal({
? chosenLocalAccountId
: undefined,
offBudget,
startingDate,
startingBalance,
}),
);
} else {
@@ -202,6 +238,8 @@ export function SelectLinkedAccountsModal({
? chosenLocalAccountId
: undefined,
offBudget,
startingDate,
startingBalance,
}),
);
}
@@ -255,6 +293,33 @@ export function SelectLinkedAccountsModal({
return localAccounts.find(acc => acc.id === chosenId);
};
// Memoize default starting settings to avoid repeated calculations
const defaultStartingSettings = useMemo<StartingBalanceInfo>(
() => ({
date: subDays(currentDay(), 90),
amount: 0,
}),
[],
);
const getCustomStartingDate = (accountId: string) => {
if (customStartingDates[accountId]) {
return customStartingDates[accountId];
}
// Default to 90 days ago (matches server default)
return defaultStartingSettings;
};
const setCustomStartingDate = (
accountId: string,
settings: StartingBalanceInfo,
) => {
setCustomStartingDates(prev => ({
...prev,
[accountId]: settings,
}));
};
const label = useMemo(() => {
const s = new Set(draftLinkAccounts.values());
if (s.has('linking') && s.has('unlinking')) {
@@ -325,6 +390,8 @@ export function SelectLinkedAccountsModal({
chosenAccount={getChosenAccount(account.account_id)}
unlinkedAccounts={unlinkedAccounts}
onSetLinkedAccount={onSetLinkedAccount}
customStartingDate={getCustomStartingDate(account.account_id)}
onSetCustomStartingDate={setCustomStartingDate}
/>
))}
</View>
@@ -333,35 +400,44 @@ export function SelectLinkedAccountsModal({
style={{ ...styles.tableContainer, height: 300, flex: 'unset' }}
>
<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('Institution to Sync')} width={150} />
<Cell value={t('Bank Account To Sync')} width={150} />
<Cell value={t('Balance')} width={120} />
<Cell value={t('Account in Actual')} width="flex" />
<Cell value={t('Actions')} width={150} />
<Cell value={t('Starting Date')} width={120} />
<Cell value={t('Starting Balance')} width={120} />
<Cell value={t('Actions')} width={150} textAlign="center" />
</TableHeader>
<Table<
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
id: string;
}
>
<Table<ExternalAccount & { id: string }>
items={propsWithSortedExternalAccounts.externalAccounts.map(
account => ({
...account,
id: account.account_id,
}),
acc => ({ ...acc, id: acc.account_id }),
)}
style={{ backgroundColor: theme.tableHeaderBackground }}
renderItem={({ item }) => (
<View key={item.id}>
renderItem={({ item }) => {
const chosenAccount = getChosenAccount(item.account_id);
// Only show starting options for new accounts being created
const shouldShowStartingOptions = isNewAccountOption(
chosenAccount?.id,
addOnBudgetAccountOption.id,
addOffBudgetAccountOption.id,
);
return (
<TableRow
key={item.id}
externalAccount={item}
chosenAccount={getChosenAccount(item.account_id)}
chosenAccount={chosenAccount}
unlinkedAccounts={unlinkedAccounts}
onSetLinkedAccount={onSetLinkedAccount}
customStartingDate={getCustomStartingDate(
item.account_id,
)}
onSetCustomStartingDate={setCustomStartingDate}
showStartingOptions={shouldShowStartingOptions}
/>
</View>
)}
);
}}
/>
</View>
)}
@@ -407,6 +483,11 @@ type ExternalAccount =
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount;
type StartingBalanceInfo = {
date: string;
amount: number;
};
type SharedAccountRowProps = {
externalAccount: ExternalAccount;
chosenAccount: { id: string; name: string } | undefined;
@@ -435,19 +516,64 @@ function getAvailableAccountOptions(
return options;
}
type TableRowProps = SharedAccountRowProps;
type TableRowProps = SharedAccountRowProps & {
customStartingDate: StartingBalanceInfo;
onSetCustomStartingDate: (
accountId: string,
settings: StartingBalanceInfo,
) => void;
showStartingOptions: boolean;
};
function useStartingBalanceInfo(accountId: string | undefined) {
const [info, setInfo] = useState<StartingBalanceInfo | null>(null);
useEffect(() => {
if (!accountId) {
setInfo(null);
return;
}
const query = transactions(accountId)
.filter({ starting_balance_flag: true })
.select(['date', 'amount'])
.limit(1);
const live = liveQuery<StartingBalanceInfo>(query, {
onData: data => {
setInfo(data?.[0] ?? null);
},
onError: () => {
setInfo(null);
},
});
return () => {
live?.unsubscribe();
};
}, [accountId]);
return info;
}
function TableRow({
externalAccount,
chosenAccount,
unlinkedAccounts,
onSetLinkedAccount,
customStartingDate,
onSetCustomStartingDate,
showStartingOptions,
}: TableRowProps) {
const [focusedField, setFocusedField] = useState<string | null>(null);
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions();
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const { t } = useTranslation();
const startingBalanceInfo = useStartingBalanceInfo(
showStartingOptions ? undefined : chosenAccount?.id,
);
const availableAccountOptions = getAvailableAccountOptions(
unlinkedAccounts,
@@ -458,7 +584,8 @@ function TableRow({
return (
<Row style={{ backgroundColor: theme.tableBackground }}>
<Field width={175}>
{/* Institution to Sync */}
<Field width={150}>
<Tooltip content={getInstitutionName(externalAccount)}>
<View
style={{
@@ -471,7 +598,8 @@ function TableRow({
</View>
</Tooltip>
</Field>
<Field width={175}>
{/* Bank Account To Sync */}
<Field width={150}>
<Tooltip content={externalAccount.name}>
<View
style={{
@@ -484,7 +612,8 @@ function TableRow({
</View>
</Tooltip>
</Field>
<Field width={80}>
{/* Balance */}
<Field width={120} style={{ textAlign: 'right' }}>
<PrivacyFilter>
{externalAccount.balance != null ? (
<FinancialText>
@@ -495,6 +624,7 @@ function TableRow({
)}
</PrivacyFilter>
</Field>
{/* Account in Actual */}
<Field
width="flex"
truncate={focusedField !== 'account'}
@@ -518,6 +648,47 @@ function TableRow({
chosenAccount?.name
)}
</Field>
{showStartingOptions ? (
<StartingOptionsFields
accountId={externalAccount.account_id}
externalBalance={externalAccount.balance}
customStartingDate={customStartingDate}
onSetCustomStartingDate={onSetCustomStartingDate}
layout="inline"
/>
) : (
<>
{/* Starting Date */}
<Field width={120} truncate={false} style={{ textAlign: 'right' }}>
{startingBalanceInfo ? (
<Text
style={{
color: theme.pageTextSubdued,
fontStyle: 'italic',
}}
>
{formatDate(parseISO(startingBalanceInfo.date), dateFormat)}
</Text>
) : null}
</Field>
{/* Starting Balance */}
<Field width={120} truncate={false} style={{ textAlign: 'right' }}>
{startingBalanceInfo ? (
<PrivacyFilter>
<FinancialText
style={{
color: theme.pageTextSubdued,
fontStyle: 'italic',
}}
>
{format(startingBalanceInfo.amount, 'financial')}
</FinancialText>
</PrivacyFilter>
) : null}
</Field>
</>
)}
{/* Actions */}
<Field width={150}>
{chosenAccount ? (
<Button
@@ -558,18 +729,141 @@ function getInstitutionName(
return '';
}
type AccountCardProps = SharedAccountRowProps;
type StartingOptionsFieldsProps = {
accountId: string;
externalBalance: number | null | undefined;
customStartingDate: StartingBalanceInfo;
onSetCustomStartingDate: (
accountId: string,
settings: StartingBalanceInfo,
) => void;
layout: 'inline' | 'stacked';
};
function StartingOptionsFields({
accountId,
externalBalance,
customStartingDate,
onSetCustomStartingDate,
layout,
}: StartingOptionsFieldsProps) {
const zeroSign = externalBalance != null && externalBalance < 0 ? '-' : '+';
if (layout === 'inline') {
return (
<>
{/* Starting Date */}
<Field width={120} truncate={false}>
<Input
type="date"
value={customStartingDate.date}
onChange={e =>
onSetCustomStartingDate(accountId, {
...customStartingDate,
date: e.target.value,
})
}
style={{ width: '100%' }}
/>
</Field>
{/* Starting Balance */}
<Field width={120} truncate={false} style={{ textAlign: 'right' }}>
<AmountInput
value={customStartingDate.amount}
zeroSign={zeroSign}
onUpdate={amount =>
onSetCustomStartingDate(accountId, {
...customStartingDate,
amount,
})
}
style={{ width: '100%' }}
/>
</Field>
</>
);
}
return (
<View
style={{
marginTop: 8,
padding: '12px',
backgroundColor: theme.tableHeaderBackground,
borderRadius: 4,
}}
>
<View style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<View>
<Text
style={{
marginBottom: 4,
fontSize: 13,
color: theme.pageTextSubdued,
}}
>
<Trans>Starting date:</Trans>
</Text>
<Input
type="date"
value={customStartingDate.date}
onChange={e =>
onSetCustomStartingDate(accountId, {
...customStartingDate,
date: e.target.value,
})
}
style={{ width: '100%' }}
/>
</View>
<View>
<Text
style={{
marginBottom: 4,
fontSize: 13,
color: theme.pageTextSubdued,
}}
>
<Trans>Balance on that date:</Trans>
</Text>
<AmountInput
value={customStartingDate.amount}
zeroSign={zeroSign}
onUpdate={amount =>
onSetCustomStartingDate(accountId, {
...customStartingDate,
amount,
})
}
style={{ width: '100%' }}
/>
</View>
</View>
</View>
);
}
type AccountCardProps = SharedAccountRowProps & {
customStartingDate: StartingBalanceInfo;
onSetCustomStartingDate: (
accountId: string,
settings: StartingBalanceInfo,
) => void;
};
function AccountCard({
externalAccount,
chosenAccount,
unlinkedAccounts,
onSetLinkedAccount,
customStartingDate,
onSetCustomStartingDate,
}: AccountCardProps) {
const [focusedField, setFocusedField] = useState<string | null>(null);
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions();
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const { t } = useTranslation();
const availableAccountOptions = getAvailableAccountOptions(
@@ -579,6 +873,16 @@ function AccountCard({
addOffBudgetAccountOption,
);
// Only show starting date options for new accounts being created
const shouldShowStartingOptions = isNewAccountOption(
chosenAccount?.id,
addOnBudgetAccountOption.id,
addOffBudgetAccountOption.id,
);
const startingBalanceInfo = useStartingBalanceInfo(
shouldShowStartingOptions ? undefined : chosenAccount?.id,
);
return (
<SpaceBetween
direction="vertical"
@@ -655,6 +959,47 @@ function AccountCard({
)}
</SpaceBetween>
{!shouldShowStartingOptions && startingBalanceInfo && (
<View
style={{
fontSize: '0.9em',
color: theme.pageTextSubdued,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<View style={{ display: 'flex', flexDirection: 'row', gap: 4 }}>
<Text style={{ color: theme.pageTextSubdued }}>
<Trans>Starting date:</Trans>
</Text>
<Text
style={{
color: theme.pageTextSubdued,
fontStyle: 'italic',
}}
>
{formatDate(parseISO(startingBalanceInfo.date), dateFormat)}
</Text>
</View>
<View style={{ display: 'flex', flexDirection: 'row', gap: 4 }}>
<Text style={{ color: theme.pageTextSubdued }}>
<Trans>Starting balance:</Trans>
</Text>
<PrivacyFilter>
<FinancialText
style={{
color: theme.pageTextSubdued,
fontStyle: 'italic',
}}
>
{format(startingBalanceInfo.amount, 'financial')}
</FinancialText>
</PrivacyFilter>
</View>
</View>
)}
{focusedField === 'account' && (
<View style={{ marginBottom: 12 }}>
<Autocomplete
@@ -675,6 +1020,16 @@ function AccountCard({
</View>
)}
{shouldShowStartingOptions && (
<StartingOptionsFields
accountId={externalAccount.account_id}
externalBalance={externalAccount.balance}
customStartingDate={customStartingDate}
onSetCustomStartingDate={onSetCustomStartingDate}
layout="stacked"
/>
)}
{chosenAccount ? (
<Button
onPress={() => {

View File

@@ -16,6 +16,7 @@ import { BudgetAnalysisGraph } from '@desktop-client/components/reports/graphs/B
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { ReportCard } from '@desktop-client/components/reports/ReportCard';
import { ReportCardName } from '@desktop-client/components/reports/ReportCardName';
import { calculateTimeRange } from '@desktop-client/components/reports/reportRanges';
import { createBudgetAnalysisSpreadsheet } from '@desktop-client/components/reports/spreadsheets/budget-analysis-spreadsheet';
import { useReport } from '@desktop-client/components/reports/useReport';
import { useWidgetCopyMenu } from '@desktop-client/components/reports/useWidgetCopyMenu';
@@ -53,15 +54,11 @@ export function BudgetAnalysisCard({
mode: 'sliding-window' as const,
};
// Calculate date range
let startDate = timeFrame.start + '-01';
let endDate = monthUtils.getMonthEnd(timeFrame.end + '-01');
if (timeFrame.mode === 'sliding-window') {
const currentMonth = monthUtils.currentMonth();
startDate = monthUtils.subMonths(currentMonth, 5) + '-01';
endDate = monthUtils.getMonthEnd(currentMonth + '-01');
}
const [startMonth, endMonth] = calculateTimeRange(timeFrame);
const startDate = monthUtils.monthFromDate(startMonth) + '-01';
const endDate = monthUtils.getMonthEnd(
monthUtils.monthFromDate(endMonth) + '-01',
);
const getGraphData = useMemo(() => {
return createBudgetAnalysisSpreadsheet({

View File

@@ -2,6 +2,7 @@
import React, {
forwardRef,
useEffect,
useEffectEvent,
useImperativeHandle,
useLayoutEffect,
useMemo,
@@ -142,6 +143,8 @@ const DatePicker = forwardRef<DatePickerForwardedRef, DatePickerProps>(
const picker = useRef(null);
const mountPoint = useRef(null);
const onUpdateEffect = useEffectEvent(onUpdate);
useImperativeHandle(
ref,
() => ({
@@ -169,18 +172,16 @@ const DatePicker = forwardRef<DatePickerForwardedRef, DatePickerProps>(
if (newDate) {
picker.current.setDate(newDate, true);
onUpdate?.(newDate);
onUpdateEffect?.(newDate);
}
},
}),
// oxlint-disable-next-line react-hooks/exhaustive-deps
[],
);
useLayoutEffect(() => {
const initPikaday = useEffectEvent(() => {
const pikadayLocale = createPikadayLocale(locale);
picker.current = new Pikaday({
return new Pikaday({
theme: 'actual-date-picker',
keyboardInput: false,
firstDay: parseInt(firstDayOfWeekIdx),
@@ -197,13 +198,15 @@ const DatePicker = forwardRef<DatePickerForwardedRef, DatePickerProps>(
onSelect,
i18n: pikadayLocale,
});
});
useLayoutEffect(() => {
picker.current = initPikaday();
mountPoint.current.appendChild(picker.current.el);
return () => {
picker.current.destroy();
};
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -274,51 +277,43 @@ function DateSelectDesktop({
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
// value in the date select and always changes as the user moves
// around. `userSelectedValue` represents the last value that the
// user actually selected (with enter or click). Having both allows
// us to make various UX decisions
const [selectedValue, setSelectedValue] = useState(value);
const userSelectedValue = useRef(selectedValue);
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
useEffect(() => {
userSelectedValue.current = value;
}, [value]);
useEffect(() => setValue(parsedDefaultValue), [parsedDefaultValue]);
useEffect(() => {
if (getDayMonthRegex(dateFormat).test(value)) {
const onUpdateEffect = useEffectEvent((newValue: string) => {
if (getDayMonthRegex(dateFormat).test(newValue)) {
// Support only entering the month and day (4/5). This is complex
// because of the various date formats - we need to derive
// the right day/month format from it
const test = parse(value, getDayMonthFormat(dateFormat), new Date());
const test = parse(newValue, getDayMonthFormat(dateFormat), new Date());
if (isValid(test)) {
onUpdate?.(format(test, 'yyyy-MM-dd'));
setSelectedValue(format(test, dateFormat));
}
} else if (getShortYearRegex(dateFormat).test(value)) {
} else if (getShortYearRegex(dateFormat).test(newValue)) {
// Support entering the year as only two digits (4/5/19)
const test = parse(value, getShortYearFormat(dateFormat), new Date());
const test = parse(newValue, getShortYearFormat(dateFormat), new Date());
if (isValid(test)) {
onUpdate?.(format(test, 'yyyy-MM-dd'));
setSelectedValue(format(test, dateFormat));
}
} else {
const test = parse(value, dateFormat, new Date());
const test = parse(newValue, dateFormat, new Date());
if (isValid(test)) {
const date = format(test, 'yyyy-MM-dd');
onUpdate?.(date);
setSelectedValue(value);
setSelectedValue(newValue);
}
}
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [value, dateFormat]);
});
useEffect(() => {
onUpdateEffect(value);
}, [value]);
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (

View File

@@ -978,6 +978,69 @@ describe('Transactions', () => {
);
});
test('ctrl/cmd+enter saves amount value when pressed immediately after typing', async () => {
// Regression test for issue #6901: Ctrl+Enter should wait for the amount
// field value to be committed before adding the transaction
const { container, getTransactions, updateProps } = renderTransactions({
onCloseAddTransaction: () => {
updateProps({ isAdding: false });
},
});
expect(getTransactions().length).toBe(5);
updateProps({ isAdding: true });
// Type in notes field
let input = await editNewField(container, 'notes');
await userEvent.clear(input);
await userEvent.type(input, 'quick entry test');
// Type amount and immediately press Ctrl+Enter without tabbing away
input = await editNewField(container, 'debit');
await userEvent.clear(input);
await userEvent.type(input, '150.75');
// Press Ctrl+Enter immediately while still in the debit field
await userEvent.keyboard('{Control>}{Enter}{/Control}');
// The transaction should be added with the correct amount, not zero
expect(getTransactions().length).toBe(6);
expect(getTransactions()[0].amount).toBe(-15075); // 150.75 in cents
expect(getTransactions()[0].notes).toBe('quick entry test');
// Form should be closed
expect(container.querySelector('[data-testid="new-transaction"]')).toBe(
null,
);
});
test('ctrl/cmd+enter saves credit amount when pressed immediately after typing', async () => {
// Test the same fix for credit field (issue #6901)
const { container, getTransactions, updateProps } = renderTransactions({
onCloseAddTransaction: () => {
updateProps({ isAdding: false });
},
});
expect(getTransactions().length).toBe(5);
updateProps({ isAdding: true });
// Type amount in credit field and immediately press Ctrl+Enter
const input = await editNewField(container, 'credit');
await userEvent.clear(input);
await userEvent.type(input, '99.99');
await userEvent.keyboard('{Control>}{Enter}{/Control}');
// The transaction should be added with the correct positive amount
expect(getTransactions().length).toBe(6);
expect(getTransactions()[0].amount).toBe(9999); // 99.99 in cents
expect(container.querySelector('[data-testid="new-transaction"]')).toBe(
null,
);
});
test('ctrl/cmd+click on add button adds transaction and closes form', async () => {
const { container, getTransactions, updateProps } = renderTransactions({
onCloseAddTransaction: () => {

View File

@@ -2638,8 +2638,10 @@ export const TransactionTable = forwardRef(
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
shouldAddAndClose.current = true;
forceRerender({});
afterSave(() => {
shouldAddAndClose.current = true;
forceRerender({});
});
} else if (!e.shiftKey) {
function getLastTransaction(state: RefObject<TableState>) {
const { newTransactions } = state.current;
@@ -2710,8 +2712,10 @@ export const TransactionTable = forwardRef(
}, []);
const onAddAndCloseTemporary = useCallback(() => {
shouldAddAndClose.current = true;
forceRerender({});
afterSave(() => {
shouldAddAndClose.current = true;
forceRerender({});
});
}, []);
const {

View File

@@ -50,5 +50,30 @@
"#99ffff",
"hsl(0, 0%, 35%)"
]
},
{
"name": "Catppuccin Latte",
"repo": "noahjalex/catppuccin-latte-actual",
"colors": ["#eff1f5", "#dce0e8", "#ccd0da", "#8839ef", "#1e66f5", "#4c4f69"]
},
{
"name": "Catppuccin Frappé",
"repo": "noahjalex/catppuccin-frappe-actual",
"colors": ["#303446", "#232634", "#414559", "#ca9ee6", "#8caaee", "#c6d0f5"]
},
{
"name": "Catppuccin Macchiato",
"repo": "noahjalex/catppuccin-macchiato-actual",
"colors": ["#24273a", "#181926", "#363a4f", "#c6a0f6", "#8aadf4", "#cad3f5"]
},
{
"name": "Catppuccin Mocha",
"repo": "noahjalex/catppuccin-mocha-actual",
"colors": ["#1e1e2e", "#11111b", "#313244", "#cba6f7", "#89b4fa", "#cdd6f4"]
},
{
"name": "You Need A Dark Mode",
"repo": "distantvapor/you-need-a-dark-mode",
"colors": ["#121212", "#1E1E1E", "#00B3C4", "#006A84", "#D4D4D4", "#333333"]
}
]

View File

@@ -38,6 +38,14 @@ import * as link from './link';
import { getStartingBalancePayee } from './payees';
import * as bankSync from './sync';
// Shared base type for link account parameters
type LinkAccountBaseParams = {
upgradingId?: AccountEntity['id'];
offBudget?: boolean;
startingDate?: string;
startingBalance?: number;
};
export type AccountHandlers = {
'account-update': typeof updateAccount;
'accounts-get': typeof getAccounts;
@@ -120,11 +128,11 @@ async function linkGoCardlessAccount({
account,
upgradingId,
offBudget = false,
}: {
startingDate,
startingBalance,
}: LinkAccountBaseParams & {
requisitionId: string;
account: SyncServerGoCardlessAccount;
upgradingId?: AccountEntity['id'] | undefined;
offBudget?: boolean | undefined;
}) {
let id;
const bank = await link.findOrCreateBank(account.institution, requisitionId);
@@ -170,6 +178,8 @@ async function linkGoCardlessAccount({
id,
account.account_id,
bank.bank_id,
startingDate,
startingBalance,
);
connection.send('sync-event', {
@@ -184,10 +194,10 @@ async function linkSimpleFinAccount({
externalAccount,
upgradingId,
offBudget = false,
}: {
startingDate,
startingBalance,
}: LinkAccountBaseParams & {
externalAccount: SyncServerSimpleFinAccount;
upgradingId?: AccountEntity['id'] | undefined;
offBudget?: boolean | undefined;
}) {
let id;
@@ -240,6 +250,8 @@ async function linkSimpleFinAccount({
id,
externalAccount.account_id,
bank.bank_id,
startingDate,
startingBalance,
);
await connection.send('sync-event', {
@@ -254,10 +266,10 @@ async function linkPluggyAiAccount({
externalAccount,
upgradingId,
offBudget = false,
}: {
startingDate,
startingBalance,
}: LinkAccountBaseParams & {
externalAccount: SyncServerPluggyAiAccount;
upgradingId?: AccountEntity['id'] | undefined;
offBudget?: boolean | undefined;
}) {
let id;
@@ -310,6 +322,8 @@ async function linkPluggyAiAccount({
id,
externalAccount.account_id,
bank.bank_id,
startingDate,
startingBalance,
);
await connection.send('sync-event', {
@@ -845,24 +859,37 @@ type SyncError =
internal?: string;
};
/**
* Type guard to check if an error is a BankSyncError.
* Handles both class instances and plain objects with the BankSyncError shape.
*/
function isBankSyncError(err: unknown): err is BankSyncError {
return (
err instanceof BankSyncError ||
(typeof err === 'object' &&
err !== null &&
'type' in err &&
err.type === 'BankSyncError')
);
}
/**
* Converts a sync error into a standardized SyncError response object.
*/
function handleSyncError(
err: Error | PostError | BankSyncError,
acct: db.DbAccount,
): SyncError {
// TODO: refactor bank sync logic to use BankSyncError properly
// oxlint-disable-next-line typescript/no-explicit-any
if (err instanceof BankSyncError || (err as any)?.type === 'BankSyncError') {
const error = err as BankSyncError;
if (isBankSyncError(err)) {
const syncError = {
type: 'SyncError',
accountId: acct.id,
message: 'Failed syncing account "' + acct.name + '."',
category: error.category,
code: error.code,
category: err.category,
code: err.code,
};
if (error.category === 'RATE_LIMIT_EXCEEDED') {
if (err.category === 'RATE_LIMIT_EXCEEDED') {
return {
...syncError,
message: `Failed syncing account ${acct.name}. Rate limit exceeded. Please try again later.`,

View File

@@ -488,6 +488,7 @@ export async function reconcileTransactions(
strictIdChecking = true,
isPreview = false,
defaultCleared = true,
updateDates = false,
): Promise<ReconcileTransactionsResult> {
logger.log('Performing transaction reconciliation');
@@ -536,6 +537,10 @@ export async function reconcileTransactions(
existing.raw_synced_data ?? trans.raw_synced_data ?? null,
};
if (updateDates && trans.date) {
updates['date'] = trans.date;
}
const fieldsToMarkUpdated = Object.keys(updates).filter(k => {
// do not mark raw_synced_data if it's gone from falsy to falsy
if (!existing.raw_synced_data && !trans.raw_synced_data) {
@@ -558,13 +563,27 @@ export async function reconcileTransactions(
updatedPreview.push({ transaction: trans, ignored: true });
}
if (existing.is_parent && existing.cleared !== updates.cleared) {
const clearedUpdated = existing.cleared !== updates.cleared;
const dateUpdated =
updateDates && trans.date && existing.date !== trans.date;
if (existing.is_parent && (clearedUpdated || dateUpdated)) {
const children = await db.all<Pick<db.DbViewTransaction, 'id'>>(
'SELECT id FROM v_transactions WHERE parent_id = ?',
[existing.id],
);
const childUpdates = {};
if (clearedUpdated) {
childUpdates['cleared'] = updates.cleared;
}
if (dateUpdated) {
childUpdates['date'] = trans.date;
}
for (const child of children) {
updated.push({ id: child.id, cleared: updates.cleared });
updated.push({ id: child.id, ...childUpdates });
}
}
} else if (trans.tombstone) {
@@ -883,6 +902,8 @@ async function processBankSyncDownload(
id,
acctRow,
initialSync = false,
customStartingBalance?: number,
customStartingDate?: string,
) {
// If syncing an account from sync source it must not use strictIdChecking. This allows
// the fuzzy search to match transactions where the import IDs are different. It is a known quirk
@@ -895,6 +916,12 @@ async function processBankSyncDownload(
.select('value'),
).then(data => String(data?.data?.[0]?.value ?? 'true') === 'true');
const updateDates = await aqlQuery(
q('preferences')
.filter({ id: `sync-update-dates-${id}` })
.select('value'),
).then(data => String(data?.data?.[0]?.value ?? 'false') === 'true');
/** Starting balance is actually the current balance of the account. */
const {
transactions: originalTransactions,
@@ -905,16 +932,17 @@ async function processBankSyncDownload(
const { transactions } = download;
let balanceToUse = currentBalance;
if (acctRow.account_sync_source === 'simpleFin') {
// Use custom starting balance if provided, otherwise calculate it
if (customStartingBalance !== undefined) {
balanceToUse = customStartingBalance;
} else if (acctRow.account_sync_source === 'simpleFin') {
const previousBalance = transactions.reduce((total, trans) => {
return (
total - parseInt(trans.transactionAmount.amount.replace('.', ''))
);
}, currentBalance);
balanceToUse = previousBalance;
}
if (acctRow.account_sync_source === 'pluggyai') {
} else if (acctRow.account_sync_source === 'pluggyai') {
const currentBalance = download.startingBalance;
const previousBalance = transactions.reduce(
(total, trans) => total - trans.transactionAmount.amount * 100,
@@ -925,10 +953,15 @@ async function processBankSyncDownload(
const oldestTransaction = transactions[transactions.length - 1];
const oldestDate =
transactions.length > 0
? oldestTransaction.date
: monthUtils.currentDay();
// Use custom starting date if provided, otherwise use oldest transaction date or current day
let startingBalanceDate: string;
if (customStartingDate) {
startingBalanceDate = customStartingDate;
} else if (transactions.length > 0) {
startingBalanceDate = oldestTransaction.date;
} else {
startingBalanceDate = monthUtils.currentDay();
}
const payee = await getStartingBalancePayee();
@@ -938,7 +971,7 @@ async function processBankSyncDownload(
amount: balanceToUse,
category: acctRow.offbudget === 0 ? payee.category : null,
payee: payee.id,
date: oldestDate,
date: startingBalanceDate,
cleared: true,
starting_balance_flag: true,
});
@@ -948,6 +981,9 @@ async function processBankSyncDownload(
transactions,
true,
useStrictIdChecking,
false,
true,
updateDates,
);
return {
...result,
@@ -967,6 +1003,9 @@ async function processBankSyncDownload(
importTransactions ? transactions : [],
true,
useStrictIdChecking,
false,
true,
updateDates,
);
if (currentBalance != null) {
@@ -983,10 +1022,13 @@ export async function syncAccount(
id: string,
acctId: string,
bankId: string,
customStartingDate?: string,
customStartingBalance?: number,
) {
const acctRow = await db.select('accounts', id);
const syncStartDate = await getAccountSyncStartDate(id);
const syncStartDate =
customStartingDate ?? (await getAccountSyncStartDate(id));
const oldestTransaction = await getAccountOldestTransaction(id);
const newAccount = oldestTransaction == null;
@@ -1010,7 +1052,14 @@ export async function syncAccount(
);
}
return processBankSyncDownload(download, id, acctRow, newAccount);
return processBankSyncDownload(
download,
id,
acctRow,
newAccount,
customStartingBalance,
customStartingDate,
);
}
export async function simpleFinBatchSync(

View File

@@ -27,6 +27,18 @@ import {
type Transaction,
} from './ynab5-types';
const MAX_RETRY = 20;
function normalizeError(e: unknown): string {
if (e instanceof Error) {
return e.message;
}
if (typeof e === 'string') {
return e;
}
return String(e);
}
type FlaggedTransaction = Pick<
Transaction | ScheduledTransaction,
'flag_name' | 'flag_color' | 'deleted'
@@ -318,9 +330,55 @@ async function importCategories(
// Can't be done in parallel to have
// correct sort order.
async function createCategoryGroupWithUniqueName(params: {
name: string;
is_income: boolean;
hidden: boolean;
}) {
const baseName = params.hidden ? `${params.name} (hidden)` : params.name;
let count = 0;
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await actual.createCategoryGroup({ ...params, name });
return { id, name };
} catch (e) {
if (count >= MAX_RETRY) {
const errorMsg = normalizeError(e);
throw Error('Unable to create category group: ' + errorMsg);
}
count += 1;
}
}
}
async function createCategoryWithUniqueName(params: {
name: string;
group_id: string;
hidden: boolean;
}) {
const baseName = params.hidden ? `${params.name} (hidden)` : params.name;
let count = 0;
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await actual.createCategory({ ...params, name });
return { id, name };
} catch (e) {
if (count >= MAX_RETRY) {
const errorMsg = normalizeError(e);
throw Error('Unable to create category: ' + errorMsg);
}
count += 1;
}
}
}
for (const group of data.category_groups) {
if (!group.deleted) {
let groupId;
let groupId: string;
// Ignores internal category and credit cards
if (
!equalsIgnoreCase(group.name, 'Internal Master Category') &&
@@ -328,30 +386,15 @@ async function importCategories(
!equalsIgnoreCase(group.name, 'Hidden Categories') &&
!equalsIgnoreCase(group.name, 'Income')
) {
let run = true;
const MAX_RETRY = 10;
let count = 1;
const origName = group.name;
while (run) {
try {
groupId = await actual.createCategoryGroup({
name: group.name,
is_income: false,
hidden: group.hidden,
});
entityIdMap.set(group.id, groupId);
if (group.note) {
send('notes-save', { id: groupId, note: group.note });
}
run = false;
} catch (e) {
group.name = origName + '-' + count.toString();
count += 1;
if (count >= MAX_RETRY) {
run = false;
throw Error(e.message);
}
}
const createdGroup = await createCategoryGroupWithUniqueName({
name: group.name,
is_income: false,
hidden: group.hidden,
});
groupId = createdGroup.id;
entityIdMap.set(group.id, groupId);
if (group.note) {
send('notes-save', { id: groupId, note: group.note });
}
}
@@ -379,30 +422,20 @@ async function importCategories(
case 'internal': // uncategorized is ignored too, handled by actual
break;
default: {
let run = true;
const MAX_RETRY = 10;
let count = 1;
const origName = cat.name;
while (run) {
try {
const id = await actual.createCategory({
name: cat.name,
group_id: groupId,
hidden: cat.hidden,
});
entityIdMap.set(cat.id, id);
if (cat.note) {
send('notes-save', { id, note: cat.note });
}
run = false;
} catch (e) {
cat.name = origName + '-' + count.toString();
count += 1;
if (count >= MAX_RETRY) {
run = false;
throw Error(e.message);
}
}
if (!groupId) {
break;
}
const createdCategory = await createCategoryWithUniqueName({
name: cat.name,
group_id: groupId,
hidden: cat.hidden,
});
entityIdMap.set(cat.id, createdCategory.id);
if (cat.note) {
send('notes-save', {
id: createdCategory.id,
note: cat.note,
});
}
}
}
@@ -785,7 +818,6 @@ async function importScheduledTransactions(
date: RecurConfig | string;
}) {
const baseName = params.name;
const MAX_RETRY = 50;
let count = 1;
while (true) {
@@ -793,7 +825,8 @@ async function importScheduledTransactions(
return await actual.createSchedule({ ...params, name: params.name });
} catch (e) {
if (count >= MAX_RETRY) {
throw Error(e.message);
const errorMsg = normalizeError(e);
throw Error(errorMsg);
}
params.name = `${baseName} (${count})`;
count += 1;

View File

@@ -43,6 +43,7 @@ export type SyncedPrefs = Partial<
| `sync-reimport-deleted-${string}`
| `sync-import-notes-${string}`
| `sync-import-transactions-${string}`
| `sync-update-dates-${string}`
| `ofx-fallback-missing-payee-${string}`
| `flip-amount-${string}-${'csv' | 'qif'}`
| `flags.${FeatureFlag}`

View File

@@ -50,6 +50,7 @@
"node_modules",
"**/node_modules/*",
"**/build/*",
"**/build-electron/*",
"**/client-build/*",
"**/dist/*",
"**/lib-dist/*",

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [StephenBrown2]
---
Add ability to specify custom starting date and balance when linking new bank sync accounts

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [kleinweby]
---
Add bank sync option to allow overwriting dates with the bank's value.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [noahjalex]
---
Add Catppuccin Themes to custom theme catalog

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Fix react-hooks/exhaustive-deps in DateSelect

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MikesGlitch]
---
Adding more components to the Storybook docs & improving storybook caching

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [tabedzki]
---
Budget Analysis Fix: correct date range calculation for card view

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [StephenBrown2]
---
Avoid duplicate category import errors in YNAB5 importer

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Add Electron build artifacts to TypeScript configuration excludes to improve compilation efficiency.

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [distantvapor]
---
Add "You Need A Dark Mode" custom theme to the theme catalog

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [xaviuzz]
---
Fix Ctrl+Enter losing amount value when adding transaction

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [lelemm]
---
Enhance auto-generated release notes with improved model, category extraction, and GitHub App token integration.