mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-10 16:26:43 -05:00
Compare commits
15 Commits
feat/scope
...
feat/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c00252e3 | ||
|
|
f53d3c662d | ||
|
|
a8a3355354 | ||
|
|
9d51ef7ce8 | ||
|
|
e6d63f620e | ||
|
|
266e7f9cac | ||
|
|
e951e21fe1 | ||
|
|
1a26253457 | ||
|
|
e72f18c5db | ||
|
|
5deb2cf790 | ||
|
|
111e01449d | ||
|
|
c0bd920c26 | ||
|
|
b695af66c0 | ||
|
|
650521f05b | ||
|
|
738a8cda7c |
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
15
.github/workflows/ai-generated-release-notes.yml
vendored
15
.github/workflows/ai-generated-release-notes.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -26,6 +26,7 @@ const config: StorybookConfig = {
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
staticDirs: ['./public'],
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import('vite');
|
||||
|
||||
|
||||
9
packages/component-library/.storybook/public/_headers
Normal file
9
packages/component-library/.storybook/public/_headers
Normal 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
|
||||
|
||||
139
packages/component-library/src/AlignedText.stories.tsx
Normal file
139
packages/component-library/src/AlignedText.stories.tsx
Normal 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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
111
packages/component-library/src/Block.stories.tsx
Normal file
111
packages/component-library/src/Block.stories.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
82
packages/component-library/src/Card.stories.tsx
Normal file
82
packages/component-library/src/Card.stories.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
108
packages/component-library/src/ColorPicker.stories.tsx
Normal file
108
packages/component-library/src/ColorPicker.stories.tsx
Normal 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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
90
packages/component-library/src/FormError.stories.tsx
Normal file
90
packages/component-library/src/FormError.stories.tsx
Normal 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 },
|
||||
},
|
||||
};
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"node_modules",
|
||||
"**/node_modules/*",
|
||||
"**/build/*",
|
||||
"**/build-electron/*",
|
||||
"**/client-build/*",
|
||||
"**/dist/*",
|
||||
"**/lib-dist/*",
|
||||
|
||||
6
upcoming-release-notes/6629.md
Normal file
6
upcoming-release-notes/6629.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [StephenBrown2]
|
||||
---
|
||||
|
||||
Add ability to specify custom starting date and balance when linking new bank sync accounts
|
||||
6
upcoming-release-notes/6850.md
Normal file
6
upcoming-release-notes/6850.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [kleinweby]
|
||||
---
|
||||
|
||||
Add bank sync option to allow overwriting dates with the bank's value.
|
||||
6
upcoming-release-notes/6857.md
Normal file
6
upcoming-release-notes/6857.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [noahjalex]
|
||||
---
|
||||
|
||||
Add Catppuccin Themes to custom theme catalog
|
||||
6
upcoming-release-notes/6864.md
Normal file
6
upcoming-release-notes/6864.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Fix react-hooks/exhaustive-deps in DateSelect
|
||||
6
upcoming-release-notes/6874.md
Normal file
6
upcoming-release-notes/6874.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Adding more components to the Storybook docs & improving storybook caching
|
||||
6
upcoming-release-notes/6875.md
Normal file
6
upcoming-release-notes/6875.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [tabedzki]
|
||||
---
|
||||
|
||||
Budget Analysis Fix: correct date range calculation for card view
|
||||
6
upcoming-release-notes/6878.md
Normal file
6
upcoming-release-notes/6878.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [StephenBrown2]
|
||||
---
|
||||
|
||||
Avoid duplicate category import errors in YNAB5 importer
|
||||
6
upcoming-release-notes/6883.md
Normal file
6
upcoming-release-notes/6883.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Add Electron build artifacts to TypeScript configuration excludes to improve compilation efficiency.
|
||||
6
upcoming-release-notes/6891.md
Normal file
6
upcoming-release-notes/6891.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [distantvapor]
|
||||
---
|
||||
|
||||
Add "You Need A Dark Mode" custom theme to the theme catalog
|
||||
6
upcoming-release-notes/6911.md
Normal file
6
upcoming-release-notes/6911.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [xaviuzz]
|
||||
---
|
||||
|
||||
Fix Ctrl+Enter losing amount value when adding transaction
|
||||
6
upcoming-release-notes/6917.md
Normal file
6
upcoming-release-notes/6917.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Enhance auto-generated release notes with improved model, category extraction, and GitHub App token integration.
|
||||
Reference in New Issue
Block a user