diff --git a/AGENTS.md b/AGENTS.md index 85cba82..0d16c30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,8 +173,9 @@ A React hook that manages loading and processing Actual Budget data from a zip f - `loading`: `boolean` - Loading state - `error`: `string | null` - Error message if loading failed - `progress`: `number` - Loading progress (0-100) -- `fetchData`: `(file: File) => Promise` - Function to load data from a file -- `refreshData`: `() => Promise` - Function to reload data from the last loaded file +- `fetchData`: `(file: File, includeOffBudget?: boolean, includeBudgetedTransfers?: boolean, includeAllTransfers?: boolean, overrideCurrencySymbol?: string) => Promise` - Function to load data from a file +- `refreshData`: `(includeOffBudget?: boolean, includeBudgetedTransfers?: boolean, includeAllTransfers?: boolean, overrideCurrencySymbol?: string) => Promise` - Function to reload data from the last loaded file +- `retransformData`: `(includeOffBudget: boolean, includeBudgetedTransfers: boolean, includeAllTransfers: boolean, overrideCurrencySymbol?: string) => void` - Function to re-transform data with new filter settings - `retry`: `() => Promise | undefined` - Function to retry loading the last file **Example:** @@ -199,10 +200,18 @@ function MyComponent() { **Usage Pattern:** -1. Call `fetchData(file)` when a user uploads a budget zip file +1. Call `fetchData(file, includeOffBudget, includeBudgetedTransfers, includeAllTransfers, overrideCurrencySymbol)` when a user uploads a budget zip file 2. The hook automatically initializes the database, fetches all data, and transforms it 3. Access the processed `data` once loading completes -4. The hook automatically cleans up the database on component unmount +4. Use `retransformData()` to re-process data when filter toggles change (without reloading from file) +5. The hook automatically cleans up the database on component unmount + +**Filter Parameters:** + +- `includeOffBudget`: Include transactions from off-budget accounts (default: `false`) +- `includeBudgetedTransfers`: Include transfers between on-budget and off-budget accounts (on→off or off→on) (default: `true`). When `true` but `includeAllTransfers` is `false`, excludes transfers between two on-budget accounts (on→on) and transfers between two off-budget accounts (off→off) +- `includeAllTransfers`: Include all transfers including between two on-budget accounts and between two off-budget accounts (default: `false`). When enabled, automatically enables `includeBudgetedTransfers` and includes ALL transfer types +- `overrideCurrencySymbol`: Override the currency symbol from the database (optional) ### `useAnimatedNumber(target: number, duration?: number, decimals?: number): number` @@ -271,7 +280,7 @@ function Settings() { ## Data Transformation Utilities -### `transformToWrappedData(transactions, categories, payees, accounts, year?): WrappedData` +### `transformToWrappedData(transactions, categories, payees, accounts, year?, includeOffBudget?, includeBudgetedTransfers?, includeAllTransfers?, currencySymbol?, budgetData?, groupSortOrders?): WrappedData` Transforms raw transaction data into a structured `WrappedData` object with all calculated metrics and aggregations. @@ -282,6 +291,12 @@ Transforms raw transaction data into a structured `WrappedData` object with all - `payees`: Array of Payee objects - `accounts`: Array of Account objects - `year`: Optional year number (defaults to 2025) +- `includeOffBudget`: Optional boolean to include off-budget transactions (defaults to `false`) +- `includeBudgetedTransfers`: Optional boolean to include transfers between on-budget and off-budget accounts (on→off or off→on) (defaults to `true`). When `true` but `includeAllTransfers` is `false`, excludes transfers between two on-budget accounts (on→on) and transfers between two off-budget accounts (off→off). When `false`, excludes ALL transfers +- `includeAllTransfers`: Optional boolean to include all transfers including between two on-budget accounts and between two off-budget accounts (defaults to `false`). When `true`, automatically enables `includeBudgetedTransfers` and includes ALL transfer types +- `currencySymbol`: Optional currency symbol string (defaults to `'$'`) +- `budgetData`: Optional array of budget data for budget comparison +- `groupSortOrders`: Optional map of category group sort orders **Returns:** `WrappedData` object containing: @@ -312,7 +327,17 @@ const categories = await getCategories(); const payees = await getPayees(); const accounts = await getAccounts(); -const wrappedData = transformToWrappedData(transactions, categories, payees, accounts, 2025); +const wrappedData = transformToWrappedData( + transactions, + categories, + payees, + accounts, + 2025, + false, // includeOffBudget + true, // includeBudgetedTransfers (default: true) + false, // includeAllTransfers + '$' // currencySymbol +); console.log(wrappedData.totalIncome); console.log(wrappedData.topCategories); @@ -322,8 +347,15 @@ console.log(wrappedData.monthlyData); **Important Notes:** - Automatically filters transactions to the specified year (defaults to 2025) -- Excludes transfer transactions (transactions where the payee has a `transfer_acct` field) -- Excludes off-budget transactions (transactions from accounts where `offbudget` is true) +- **Transfer Filtering**: + - By default (`includeBudgetedTransfers = true`, `includeAllTransfers = false`): Includes transfers between on-budget and off-budget accounts (on→off or off→on). Excludes transfers between two on-budget accounts (on→on) and transfers between two off-budget accounts (off→off) + - When `includeBudgetedTransfers = false`: Excludes ALL transfers regardless of account types + - When `includeAllTransfers = true`: Includes ALL transfers (on→on, on→off, off→on, off→off). Automatically enables `includeBudgetedTransfers` +- **Transfer Labeling**: When transfers are included: + - **Categories**: Transfers without categories are automatically labeled with the destination account name (e.g., "Transfer: Savings Account") instead of showing as "Uncategorized" + - **Payees**: Transfer payees are automatically labeled with the destination account name (e.g., "Transfer: Savings Account") instead of showing as "Unknown" + - Multiple transfers to the same account are grouped together in both categories and payees +- **Off-Budget Filtering**: Excludes off-budget transactions by default. Set `includeOffBudget = true` to include them - Excludes starting balance transactions (transactions where payee name is "Starting Balance") - Handles deleted categories/payees (marks with "deleted: " prefix) - Converts amounts from cents to dollars diff --git a/README.md b/README.md index 9deab0f..cfa0640 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ A beautiful year-in-review application for your Actual Budget data, styled like - ⌨️ **Keyboard Navigation**: Navigate with arrow keys (← →) - 📱 **Responsive Design**: Works on desktop and mobile devices - 🧪 **Well Tested**: Unit tests with Vitest and E2E tests with Playwright +- ⚙️ **Flexible Filtering**: Toggle to include/exclude off-budget transactions, on-budget transfers, and cross-account transfers +- 💱 **Currency Override**: Change currency display without modifying your budget data +- 🔄 **Smart Transfer Labeling**: Transfers are automatically labeled with destination account names (e.g., "Transfer: Savings Account") in both categories and payees lists, instead of showing as uncategorized or unknown ## Prerequisites @@ -80,7 +83,12 @@ yarn preview 1. **Upload Your Budget**: Click "Choose File" and select your exported Actual Budget `.zip` file 2. **Wait for Processing**: The app will extract and process your 2025 budget data (this happens entirely in your browser) -3. **Navigate Through Pages**: Use the Next/Previous buttons or arrow keys (← →) to navigate through the wrapped pages +3. **Adjust Settings** (optional): Click the settings menu (☰) in the top-right corner to: + - Include/exclude off-budget transactions + - Include/exclude budgeted transfers (transfers between on-budget and off-budget accounts) + - Include all transfers (includes all transfer types, including between two on-budget accounts) + - Override currency display +4. **Navigate Through Pages**: Use the Next/Previous buttons or arrow keys (← →) to navigate through the wrapped pages ## Technology Stack diff --git a/src/App.tsx b/src/App.tsx index 56bf7ad..43133f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,8 @@ import { ConnectionForm } from './components/ConnectionForm'; import { CurrencySelector } from './components/CurrencySelector'; import { Navigation } from './components/Navigation'; import { OffBudgetToggle } from './components/OffBudgetToggle'; +import { AllTransfersToggle } from './components/OffBudgetTransfersToggle'; +import { OnBudgetTransfersToggle } from './components/OnBudgetTransfersToggle'; import { AccountBreakdownPage } from './components/pages/AccountBreakdownPage'; import { BudgetVsActualPage } from './components/pages/BudgetVsActualPage'; import { CalendarHeatmapPage } from './components/pages/CalendarHeatmapPage'; @@ -17,6 +19,7 @@ import { SavingsRatePage } from './components/pages/SavingsRatePage'; import { SpendingVelocityPage } from './components/pages/SpendingVelocityPage'; import { TopCategoriesPage } from './components/pages/TopCategoriesPage'; import { TopPayeesPage } from './components/pages/TopPayeesPage'; +import { SettingsMenu } from './components/SettingsMenu'; import { useActualData } from './hooks/useActualData'; import { useLocalStorage } from './hooks/useLocalStorage'; @@ -38,6 +41,14 @@ const PAGES = [ function App() { const [currentPage, setCurrentPage] = useState(0); const [includeOffBudget, setIncludeOffBudget] = useLocalStorage('includeOffBudget', false); + const [includeOnBudgetTransfers, setIncludeOnBudgetTransfers] = useLocalStorage( + 'includeOnBudgetTransfers', + true, // Default to true (on by default) + ); + const [includeAllTransfers, setIncludeAllTransfers] = useLocalStorage( + 'includeAllTransfers', + false, + ); const [overrideCurrency, setOverrideCurrency] = useLocalStorage( 'overrideCurrency', null, @@ -45,12 +56,43 @@ function App() { const { data, loading, error, progress, fetchData, retransformData, retry } = useActualData(); const handleConnect = async (file: File) => { - await fetchData(file, includeOffBudget, overrideCurrency || undefined); + await fetchData( + file, + includeOffBudget, + includeOnBudgetTransfers, + includeAllTransfers, + overrideCurrency || undefined, + ); }; - const handleToggle = (value: boolean) => { + const handleOffBudgetToggle = (value: boolean) => { setIncludeOffBudget(value); - retransformData(value, overrideCurrency || undefined); + retransformData( + value, + includeOnBudgetTransfers, + includeAllTransfers, + overrideCurrency || undefined, + ); + }; + + const handleOnBudgetTransfersToggle = (value: boolean) => { + setIncludeOnBudgetTransfers(value); + retransformData(includeOffBudget, value, includeAllTransfers, overrideCurrency || undefined); + }; + + const handleAllTransfersToggle = (value: boolean) => { + setIncludeAllTransfers(value); + // When "Include All Transfers" is enabled, automatically enable "Include Budgeted Transfers" + const effectiveIncludeOnBudgetTransfers = value ? true : includeOnBudgetTransfers; + if (value && !includeOnBudgetTransfers) { + setIncludeOnBudgetTransfers(true); + } + retransformData( + includeOffBudget, + effectiveIncludeOnBudgetTransfers, // If includeAllTransfers is true, also enable on-budget transfers + value, + overrideCurrency || undefined, + ); }; const handleCurrencyChange = (currencySymbol: string) => { @@ -58,10 +100,15 @@ function App() { const defaultCurrency = data?.currencySymbol || '$'; if (currencySymbol === defaultCurrency) { setOverrideCurrency(null); - retransformData(includeOffBudget, undefined); + retransformData(includeOffBudget, includeOnBudgetTransfers, includeAllTransfers, undefined); } else { setOverrideCurrency(currencySymbol); - retransformData(includeOffBudget, currencySymbol); + retransformData( + includeOffBudget, + includeOnBudgetTransfers, + includeAllTransfers, + currencySymbol, + ); } }; @@ -116,12 +163,23 @@ function App() { return (
- - + + + + + + {isIntroPage ? ( ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<'div'>) =>
{children}
, + }, +})); + +describe('AllTransfersToggle', () => { + it('renders without crashing', () => { + const mockToggle = vi.fn(); + render(); + + expect(screen.getByText('Include All Transfers')).toBeInTheDocument(); + }); + + it('displays toggle switch', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('shows active state when includeAllTransfers is true', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('calls onToggle when clicked', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + fireEvent.click(toggle); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(true); + }); + + it('calls onToggle with false when toggling from true to false', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + fireEvent.click(toggle); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(false); + }); + + it('calls onToggle when Enter key is pressed', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + toggle.focus(); + fireEvent.keyDown(toggle, { key: 'Enter', code: 'Enter' }); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(true); + }); + + it('calls onToggle when Space key is pressed', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + toggle.focus(); + fireEvent.keyDown(toggle, { key: ' ', code: 'Space' }); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(true); + }); + + it('has correct aria-label', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('aria-label', 'Include all transfers'); + }); +}); diff --git a/src/components/OffBudgetTransfersToggle.tsx b/src/components/OffBudgetTransfersToggle.tsx new file mode 100644 index 0000000..751a439 --- /dev/null +++ b/src/components/OffBudgetTransfersToggle.tsx @@ -0,0 +1,39 @@ +import { motion } from 'framer-motion'; + +import styles from './OffBudgetTransfersToggle.module.css'; + +interface AllTransfersToggleProps { + includeAllTransfers: boolean; + onToggle: (value: boolean) => void; +} + +export function AllTransfersToggle({ includeAllTransfers, onToggle }: AllTransfersToggleProps) { + return ( + +
+ Include All Transfers +
onToggle(!includeAllTransfers)} + role="switch" + aria-checked={includeAllTransfers} + aria-label="Include all transfers" + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.code === 'Space') { + e.preventDefault(); + onToggle(!includeAllTransfers); + } + }} + > +
+
+
+ + ); +} diff --git a/src/components/OnBudgetTransfersToggle.module.css b/src/components/OnBudgetTransfersToggle.module.css new file mode 100644 index 0000000..ae4b244 --- /dev/null +++ b/src/components/OnBudgetTransfersToggle.module.css @@ -0,0 +1,70 @@ +.toggle { + position: fixed; + top: calc(2rem + 3.5rem); /* Position below OffBudgetToggle (2rem top + ~3.5rem height) */ + right: 2rem; + z-index: 1001; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + padding: 0.75rem 1.25rem; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + gap: 0.75rem; + color: white; + font-size: 0.9rem; + font-weight: 500; +} + +.toggleLabel { + user-select: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 0.75rem; +} + +.toggleSwitch { + position: relative; + width: 44px; + height: 24px; + background: rgba(255, 255, 255, 0.2); + border-radius: 12px; + cursor: pointer; + transition: background-color 0.3s; + flex-shrink: 0; + margin-left: auto; +} + +.toggleSwitch.active { + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); +} + +.toggleSlider { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: transform 0.3s; +} + +.toggleSwitch.active .toggleSlider { + transform: translateX(20px); +} + +.toggleSwitch.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toggleText { + font-size: 0.85rem; + opacity: 0.9; + text-align: left; + flex: 1; +} diff --git a/src/components/OnBudgetTransfersToggle.test.tsx b/src/components/OnBudgetTransfersToggle.test.tsx new file mode 100644 index 0000000..7a80b54 --- /dev/null +++ b/src/components/OnBudgetTransfersToggle.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { render, screen, fireEvent } from '../test-utils/test-utils'; +import { OnBudgetTransfersToggle } from './OnBudgetTransfersToggle'; + +// Mock framer-motion +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<'div'>) =>
{children}
, + }, +})); + +describe('OnBudgetTransfersToggle', () => { + it('renders without crashing', () => { + const mockToggle = vi.fn(); + render(); + + expect(screen.getByText('Include Budgeted Transfers')).toBeInTheDocument(); + }); + + it('displays toggle switch', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toBeInTheDocument(); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); + + it('shows active state when includeOnBudgetTransfers is true', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + }); + + it('calls onToggle when clicked', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + fireEvent.click(toggle); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(true); + }); + + it('calls onToggle with false when toggling from true to false', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + fireEvent.click(toggle); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(false); + }); + + it('calls onToggle when Enter key is pressed', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + toggle.focus(); + fireEvent.keyDown(toggle, { key: 'Enter', code: 'Enter' }); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(true); + }); + + it('calls onToggle when Space key is pressed', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + toggle.focus(); + fireEvent.keyDown(toggle, { key: ' ', code: 'Space' }); + + expect(mockToggle).toHaveBeenCalledTimes(1); + expect(mockToggle).toHaveBeenCalledWith(true); + }); + + it('has correct aria-label', () => { + const mockToggle = vi.fn(); + render(); + + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('aria-label', 'Include budgeted transfers'); + }); + + describe('disabled state', () => { + it('does not call onToggle when disabled and clicked', () => { + const mockToggle = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole('switch'); + fireEvent.click(toggle); + + expect(mockToggle).not.toHaveBeenCalled(); + }); + + it('has aria-disabled when disabled', () => { + const mockToggle = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('aria-disabled', 'true'); + }); + + it('has tabIndex of -1 when disabled', () => { + const mockToggle = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole('switch'); + expect(toggle).toHaveAttribute('tabIndex', '-1'); + }); + + it('does not call onToggle when disabled and Enter key is pressed', () => { + const mockToggle = vi.fn(); + render( + , + ); + + const toggle = screen.getByRole('switch'); + toggle.focus(); + fireEvent.keyDown(toggle, { key: 'Enter', code: 'Enter' }); + + expect(mockToggle).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/OnBudgetTransfersToggle.tsx b/src/components/OnBudgetTransfersToggle.tsx new file mode 100644 index 0000000..51c695b --- /dev/null +++ b/src/components/OnBudgetTransfersToggle.tsx @@ -0,0 +1,45 @@ +import { motion } from 'framer-motion'; + +import styles from './OnBudgetTransfersToggle.module.css'; + +interface OnBudgetTransfersToggleProps { + includeOnBudgetTransfers: boolean; + onToggle: (value: boolean) => void; + disabled?: boolean; +} + +export function OnBudgetTransfersToggle({ + includeOnBudgetTransfers, + onToggle, + disabled = false, +}: OnBudgetTransfersToggleProps) { + return ( + +
+ Include Budgeted Transfers +
!disabled && onToggle(!includeOnBudgetTransfers)} + role="switch" + aria-checked={includeOnBudgetTransfers} + aria-label="Include budgeted transfers" + aria-disabled={disabled} + tabIndex={disabled ? -1 : 0} + onKeyDown={e => { + if (!disabled && (e.key === 'Enter' || e.code === 'Space')) { + e.preventDefault(); + onToggle(!includeOnBudgetTransfers); + } + }} + > +
+
+
+ + ); +} diff --git a/src/components/SettingsMenu.module.css b/src/components/SettingsMenu.module.css new file mode 100644 index 0000000..645d704 --- /dev/null +++ b/src/components/SettingsMenu.module.css @@ -0,0 +1,71 @@ +.menuContainer { + position: fixed; + top: 2rem; + right: 2rem; + z-index: 1001; +} + +.settingsButton { + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 0.75rem 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + transition: all 0.3s; +} + +.settingsButton:hover { + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.2); +} + +.cogIcon { + width: 20px; + height: 20px; + color: white; + transition: transform 0.3s; +} + +.settingsButton:hover .cogIcon { + transform: rotate(90deg); +} + +.settingsLabel { + color: white; + font-size: 0.875rem; + font-weight: 500; + white-space: nowrap; +} + +.menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background: rgba(0, 0, 0, 0.95); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 0.5rem; + min-width: 280px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.menuContent { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Override styles for menu items */ +.menuContent > * { + position: static !important; + margin: 0 !important; + width: 100% !important; + top: auto !important; + right: auto !important; +} diff --git a/src/components/SettingsMenu.test.tsx b/src/components/SettingsMenu.test.tsx new file mode 100644 index 0000000..b7ad65a --- /dev/null +++ b/src/components/SettingsMenu.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { render, screen, fireEvent } from '../test-utils/test-utils'; +import { SettingsMenu } from './SettingsMenu'; + +// Mock framer-motion +vi.mock('framer-motion', () => ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<'div'>) =>
{children}
, + button: ({ children, ...props }: React.ComponentProps<'button'>) => ( + + ), + }, + AnimatePresence: ({ children }: React.PropsWithChildren) =>
{children}
, +})); + +describe('SettingsMenu', () => { + beforeEach(() => { + // Clear any existing event listeners + document.removeEventListener('mousedown', vi.fn()); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders without crashing', () => { + render( + +
Test Content
+
, + ); + + expect(screen.getByLabelText('Toggle settings menu')).toBeInTheDocument(); + }); + + it('renders settings button', () => { + render( + +
Test Content
+
, + ); + + const button = screen.getByLabelText('Toggle settings menu'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(screen.getByText('settings')).toBeInTheDocument(); + }); + + it('shows menu when settings button is clicked', () => { + render( + +
Test Content
+
, + ); + + const button = screen.getByLabelText('Toggle settings menu'); + fireEvent.click(button); + + expect(screen.getByText('Test Content')).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-expanded', 'true'); + }); + + it('hides menu when settings button is clicked again', () => { + render( + +
Test Content
+
, + ); + + const button = screen.getByLabelText('Toggle settings menu'); + + // Open menu + fireEvent.click(button); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + + // Close menu + fireEvent.click(button); + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + expect(button).toHaveAttribute('aria-expanded', 'false'); + }); + + it('renders children when menu is open', () => { + render( + +
Child 1
+
Child 2
+
Child 3
+
, + ); + + const button = screen.getByLabelText('Toggle settings menu'); + fireEvent.click(button); + + expect(screen.getByText('Child 1')).toBeInTheDocument(); + expect(screen.getByText('Child 2')).toBeInTheDocument(); + expect(screen.getByText('Child 3')).toBeInTheDocument(); + }); + + it('closes menu when clicking outside', () => { + render( + +
Test Content
+
, + ); + + const button = screen.getByLabelText('Toggle settings menu'); + + // Open menu + fireEvent.click(button); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + + // Click outside + fireEvent.mouseDown(document.body); + + // Menu should be closed + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + }); + + it('does not close menu when clicking inside', () => { + render( + +
Test Content
+
, + ); + + const button = screen.getByLabelText('Toggle settings menu'); + + // Open menu + fireEvent.click(button); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + + // Click inside menu + const content = screen.getByText('Test Content'); + fireEvent.mouseDown(content); + + // Menu should still be open + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); + + it('renders multiple children correctly', () => { + render( + +
Child 1
+
Child 2
+
Child 3
+
, + ); + + const button = screen.getByLabelText('Toggle settings menu'); + fireEvent.click(button); + + expect(screen.getByTestId('child-1')).toBeInTheDocument(); + expect(screen.getByTestId('child-2')).toBeInTheDocument(); + expect(screen.getByTestId('child-3')).toBeInTheDocument(); + }); + + it('starts with menu closed', () => { + render( + +
Test Content
+
, + ); + + expect(screen.queryByText('Test Content')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/SettingsMenu.tsx b/src/components/SettingsMenu.tsx new file mode 100644 index 0000000..660d1e5 --- /dev/null +++ b/src/components/SettingsMenu.tsx @@ -0,0 +1,76 @@ +import { motion, AnimatePresence } from 'framer-motion'; +import { useState, useEffect, useRef } from 'react'; + +import styles from './SettingsMenu.module.css'; + +interface SettingsMenuProps { + children: React.ReactNode; +} + +export function SettingsMenu({ children }: SettingsMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + const toggleMenu = () => { + setIsOpen(!isOpen); + }; + + // Close menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + return ( +
+ + + + + + settings + + + + {isOpen && ( + +
{children}
+
+ )} +
+
+ ); +} diff --git a/src/hooks/useActualData.ts b/src/hooks/useActualData.ts index a641027..d7c50bf 100644 --- a/src/hooks/useActualData.ts +++ b/src/hooks/useActualData.ts @@ -42,6 +42,8 @@ export function useActualData() { ( raw: RawBudgetData, includeOffBudget: boolean, + includeOnBudgetTransfers: boolean, + includeAllTransfers: boolean, currencySymbol: string, budgetData?: Array<{ categoryId: string; month: string; budgetedAmount: number }>, groupSortOrders: Map = new Map(), @@ -53,6 +55,8 @@ export function useActualData() { raw.accounts, DEFAULT_YEAR, includeOffBudget, + includeOnBudgetTransfers, + includeAllTransfers, currencySymbol, budgetData, groupSortOrders, @@ -66,6 +70,8 @@ export function useActualData() { async ( uploadedFile: File, includeOffBudget: boolean = false, + includeOnBudgetTransfers: boolean = true, // Default to true (on by default) + includeAllTransfers: boolean = false, overrideCurrencySymbol?: string, ) => { setLoading(true); @@ -132,6 +138,8 @@ export function useActualData() { transformData( raw, includeOffBudget, + includeOnBudgetTransfers, + includeAllTransfers, effectiveCurrency, fetchedBudgetData.length > 0 ? fetchedBudgetData : undefined, fetchedGroupSortOrders, @@ -155,20 +163,44 @@ export function useActualData() { ); const refreshData = useCallback( - async (includeOffBudget: boolean = false, overrideCurrencySymbol?: string) => { + async ( + includeOffBudget: boolean = false, + includeOnBudgetTransfers: boolean = true, // Default to true (on by default) + includeAllTransfers: boolean = false, + overrideCurrencySymbol?: string, + ) => { if (!file) { throw new Error('No file available'); } - await fetchData(file, includeOffBudget, overrideCurrencySymbol); + await fetchData( + file, + includeOffBudget, + includeOnBudgetTransfers, + includeAllTransfers, + overrideCurrencySymbol, + ); }, [file, fetchData], ); const retransformData = useCallback( - (includeOffBudget: boolean, overrideCurrencySymbol?: string) => { + ( + includeOffBudget: boolean, + includeOnBudgetTransfers: boolean, + includeAllTransfers: boolean, + overrideCurrencySymbol?: string, + ) => { if (rawData) { const effectiveCurrency = overrideCurrencySymbol || currencySymbol; - transformData(rawData, includeOffBudget, effectiveCurrency, budgetData, groupSortOrders); + transformData( + rawData, + includeOffBudget, + includeOnBudgetTransfers, + includeAllTransfers, + effectiveCurrency, + budgetData, + groupSortOrders, + ); } }, [rawData, transformData, currencySymbol, budgetData, groupSortOrders], diff --git a/src/utils/dataTransform.test.ts b/src/utils/dataTransform.test.ts index 9ca8b5a..79e2fdc 100644 --- a/src/utils/dataTransform.test.ts +++ b/src/utils/dataTransform.test.ts @@ -203,6 +203,117 @@ describe('transformToWrappedData', () => { expect(uncategorized?.amount).toBe(300); }); + it('shows transfers as "Transfer: {accountName}" instead of "Uncategorized" when transfers are enabled', () => { + const transactions: Transaction[] = [ + createMockTransaction({ + id: 't1', + account: 'acc1', + payee: 'payee1', + category: '', + amount: -10000, + }), + createMockTransaction({ + id: 't2', + account: 'acc1', + payee: 'payee2', + category: '', + amount: -20000, + }), + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Savings Account', offbudget: true }), // off-budget + createMockAccount({ id: 'acc3', name: 'Investment', offbudget: true }), // off-budget + ]; + + const payees = [ + { id: 'payee1', name: 'Transfer', transfer_acct: 'acc2' }, // transfer on->off to Savings Account + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc3' }, // transfer on->off to Investment + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + true, // includeOnBudgetTransfers = true + false, // includeAllTransfers = false + ); + + // Should have transfer categories instead of uncategorized + const transferToSavings = result.topCategories.find( + c => c.categoryName === 'Transfer: Savings Account', + ); + const transferToInvestment = result.topCategories.find( + c => c.categoryName === 'Transfer: Investment', + ); + + expect(transferToSavings).toBeDefined(); + expect(transferToSavings?.amount).toBe(100); + expect(transferToInvestment).toBeDefined(); + expect(transferToInvestment?.amount).toBe(200); + + // Should not have uncategorized + const uncategorized = result.topCategories.find(c => c.categoryId === 'uncategorized'); + expect(uncategorized).toBeUndefined(); + }); + + it('groups multiple transfers to the same account together', () => { + const transactions: Transaction[] = [ + createMockTransaction({ + id: 't1', + account: 'acc1', + payee: 'payee1', + category: '', + amount: -10000, + }), + createMockTransaction({ + id: 't2', + account: 'acc1', + payee: 'payee1', + category: '', + amount: -20000, + }), + createMockTransaction({ + id: 't3', + account: 'acc1', + payee: 'payee1', + category: '', + amount: -30000, + }), + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), // off-budget destination + ]; + + const payees = [ + { id: 'payee1', name: 'Transfer', transfer_acct: 'acc2' }, // all transfers from Checking to Investment (on->off) + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + true, // includeOnBudgetTransfers = true + false, + ); + + const transferCategory = result.topCategories.find( + c => c.categoryName === 'Transfer: Investment', + ); + expect(transferCategory).toBeDefined(); + expect(transferCategory?.amount).toBe(600); // $100 + $200 + $300 + expect(transferCategory?.categoryId).toContain('transfer:'); + }); + it('excludes off-budget account transactions', () => { const transactions: Transaction[] = [ createMockTransaction({ id: 't1', account: 'acc1', category: '', amount: -10000 }), @@ -385,6 +496,131 @@ describe('transformToWrappedData', () => { expect(result.topPayees).toHaveLength(10); }); + + it('shows transfers as "Transfer: {accountName}" in payees list when transfers are enabled', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Savings Account', offbudget: true }), // off-budget + createMockAccount({ id: 'acc3', name: 'Investment', offbudget: true }), // off-budget + ]; + + const payees = [ + { id: 'payee1', name: 'Transfer', transfer_acct: 'acc2' }, // transfer on->off to Savings Account + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc3' }, // transfer on->off to Investment + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + true, // includeOnBudgetTransfers = true + false, + ); + + // Should have transfer payees with account names + const transferToSavings = result.topPayees.find(p => p.payee === 'Transfer: Savings Account'); + const transferToInvestment = result.topPayees.find(p => p.payee === 'Transfer: Investment'); + + expect(transferToSavings).toBeDefined(); + expect(transferToSavings?.amount).toBe(100); + expect(transferToInvestment).toBeDefined(); + expect(transferToInvestment?.amount).toBe(200); + + // Should not have "Unknown" payees for transfers + const unknown = result.topPayees.find(p => p.payee === 'Unknown'); + // Unknown might exist for other transactions, but transfers should not be Unknown + if (unknown) { + // If there are other transactions without payees, that's fine + // But transfers should not be in the Unknown category + expect(transferToSavings).toBeDefined(); + expect(transferToInvestment).toBeDefined(); + } + }); + + it('groups multiple transfers to the same account in payees list', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee1', amount: -20000 }), + createMockTransaction({ id: 't3', account: 'acc1', payee: 'payee1', amount: -30000 }), + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), // off-budget destination + ]; + + const payees = [ + { id: 'payee1', name: 'Transfer', transfer_acct: 'acc2' }, // all transfers from Checking to Investment (on->off) + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + true, // includeOnBudgetTransfers = true + false, + ); + + const transferPayee = result.topPayees.find(p => p.payee === 'Transfer: Investment'); + expect(transferPayee).toBeDefined(); + expect(transferPayee?.amount).toBe(600); // $100 + $200 + $300 + expect(transferPayee?.transactionCount).toBe(3); + }); + + it('uses receiving account name for transfer payees, not the source account', () => { + const transactions: Transaction[] = [ + // Transfer FROM Checking (on-budget) TO Investment (off-budget) - should show "Transfer: Investment" + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + // Transfer FROM Investment (off-budget) TO Savings (on-budget) - should show "Transfer: Savings" + createMockTransaction({ id: 't2', account: 'acc3', payee: 'payee2', amount: -20000 }), + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), // Source for t1 + createMockAccount({ id: 'acc2', name: 'Savings', offbudget: false }), // Destination for t2 + createMockAccount({ id: 'acc3', name: 'Investment', offbudget: true }), // Source for t2 + ]; + + const payees = [ + { id: 'payee1', name: 'Transfer', transfer_acct: 'acc3' }, // Checking (on) -> Investment (off) + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // Investment (off) -> Savings (on) + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + true, // includeOffBudget = true (needed for off->on transfer) + true, // includeOnBudgetTransfers = true + false, // includeAllTransfers = false + ); + + // Should show receiving account names, not source account names + const transferToInvestment = result.topPayees.find(p => p.payee === 'Transfer: Investment'); + const transferToSavings = result.topPayees.find(p => p.payee === 'Transfer: Savings'); + + expect(transferToInvestment).toBeDefined(); + expect(transferToInvestment?.amount).toBe(100); + expect(transferToSavings).toBeDefined(); + expect(transferToSavings?.amount).toBe(200); + + // Should NOT show source account names + const transferFromChecking = result.topPayees.find(p => p.payee === 'Transfer: Checking'); + expect(transferFromChecking).toBeUndefined(); + }); }); describe('Transaction Stats', () => { @@ -493,11 +729,477 @@ describe('transformToWrappedData', () => { { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, ]; - const result = transformToWrappedData(transactions, [], payees, []); + // Explicitly disable includeOnBudgetTransfers to test exclusion + const result = transformToWrappedData( + transactions, + [], + payees, + [], + 2025, + false, + false, // includeOnBudgetTransfers = false + false, + ); expect(result.transactionStats.totalCount).toBe(1); expect(result.totalExpenses).toBe(100); }); + + describe('On-Budget Transfers Toggle', () => { + it('excludes transfers between on-budget accounts when includeOnBudgetTransfers is true but includeAllTransfers is false', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->on + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Savings', offbudget: false }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // on-budget to on-budget + ]; + + // With includeOnBudgetTransfers = true but includeAllTransfers = false + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + true, // includeOnBudgetTransfers = true + false, // includeAllTransfers = false + ); + + // On-budget to on-budget transfers should be excluded (only on->off and off->on are included) + expect(result.transactionStats.totalCount).toBe(1); + expect(result.totalExpenses).toBe(100); + }); + + it('excludes transfers between on-budget accounts when includeOnBudgetTransfers is false', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->on + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Savings', offbudget: false }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // on-budget to on-budget + ]; + + // With includeOnBudgetTransfers = false + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + false, // includeOnBudgetTransfers = false + false, + ); + + // All transfers should be excluded when toggle is off + expect(result.transactionStats.totalCount).toBe(1); + expect(result.totalExpenses).toBe(100); + }); + + it('excludes transfers between on-budget accounts when includeOnBudgetTransfers is true but includeAllTransfers is false', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->on + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Savings', offbudget: false }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // on-budget to on-budget + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + true, // includeOnBudgetTransfers = true + false, // includeAllTransfers = false + ); + + // On->on transfers should be excluded when only includeOnBudgetTransfers is true + // Only on->off and off->on transfers are included + expect(result.transactionStats.totalCount).toBe(1); + expect(result.totalExpenses).toBe(100); + }); + + it('includes transfers from on-budget to off-budget accounts by default (includeOnBudgetTransfers defaults to true)', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->off + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // on-budget to off-budget + ]; + + // Test with default (includeOnBudgetTransfers = true) + const result1 = transformToWrappedData(transactions, [], payees, accounts); + + // Transfers between on-budget and off-budget are included by default + expect(result1.transactionStats.totalCount).toBe(2); + expect(result1.totalExpenses).toBe(300); + }); + + it('includes transfers from on-budget to off-budget accounts when includeAllTransfers is on', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->off + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // on-budget to off-budget + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + false, // includeOnBudgetTransfers = false + true, // includeAllTransfers = true + ); + + expect(result.transactionStats.totalCount).toBe(2); + expect(result.totalExpenses).toBe(300); + }); + + it('handles transfers from off-budget to on-budget accounts correctly', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc2', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc2', payee: 'payee2', amount: -20000 }), // transfer off->on + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc1' }, // off-budget to on-budget + ]; + + // With includeOffBudget = false, off-budget transactions are excluded + const result1 = transformToWrappedData(transactions, [], payees, accounts, 2025, false); + + expect(result1.transactionStats.totalCount).toBe(0); + expect(result1.totalExpenses).toBe(0); + + // With includeOffBudget = true, off-budget transactions are included + // The transfer (off->on) is also included by default (includeOnBudgetTransfers = true) + const result2 = transformToWrappedData(transactions, [], payees, accounts, 2025, true); + + // Both the regular transaction and the transfer are included + expect(result2.transactionStats.totalCount).toBe(2); + expect(result2.totalExpenses).toBe(300); // $100 + $200 + }); + + it('includes transfers from off-budget to on-budget when includeAllTransfers is on', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc2', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc2', payee: 'payee2', amount: -20000 }), // transfer off->on + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc1' }, // off-budget to on-budget + ]; + + // With includeOffBudget = true and includeAllTransfers = true + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + true, + false, // includeOnBudgetTransfers = false + true, // includeAllTransfers = true + ); + + expect(result.transactionStats.totalCount).toBe(2); + expect(result.totalExpenses).toBe(300); + }); + }); + + describe('Include All Transfers Toggle', () => { + it('includes transfers between on-budget and off-budget accounts by default (includeOnBudgetTransfers defaults to true)', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->off + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // on-budget to off-budget + ]; + + const result = transformToWrappedData(transactions, [], payees, accounts); + + // Transfers between on-budget and off-budget are included by default + expect(result.transactionStats.totalCount).toBe(2); + expect(result.totalExpenses).toBe(300); + }); + + it('includes transfers between on-budget and off-budget accounts when toggle is on', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->off + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer', transfer_acct: 'acc2' }, // on-budget to off-budget + ]; + + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + false, // includeOnBudgetTransfers = false + true, // includeAllTransfers = true + ); + + expect(result.transactionStats.totalCount).toBe(2); + expect(result.totalExpenses).toBe(300); + }); + + it('automatically enables includeOnBudgetTransfers when includeAllTransfers is on', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->on + createMockTransaction({ id: 't3', account: 'acc1', payee: 'payee3', amount: -30000 }), // transfer on->off + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Savings', offbudget: false }), + createMockAccount({ id: 'acc3', name: 'Investment', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer On->On', transfer_acct: 'acc2' }, // on-budget to on-budget + { id: 'payee3', name: 'Transfer On->Off', transfer_acct: 'acc3' }, // on-budget to off-budget + ]; + + // includeAllTransfers = true should automatically enable includeOnBudgetTransfers + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, + false, // includeOnBudgetTransfers = false (but should be treated as true) + true, // includeAllTransfers = true + ); + + // All transfers should be included + expect(result.transactionStats.totalCount).toBe(3); + expect(result.totalExpenses).toBe(600); + }); + + it('handles transfers in both directions (on->off and off->on)', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->off + createMockTransaction({ id: 't3', account: 'acc3', payee: 'payee3', amount: -30000 }), // transfer off->on + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + createMockAccount({ id: 'acc3', name: 'Brokerage', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer On->Off', transfer_acct: 'acc2' }, // on-budget to off-budget + { id: 'payee3', name: 'Transfer Off->On', transfer_acct: 'acc1' }, // off-budget to on-budget + ]; + + // With includeOffBudget = true and includeAllTransfers = true + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + true, // includeOffBudget = true (needed for off->on transfers) + false, // includeOnBudgetTransfers = false + true, // includeAllTransfers = true + ); + + // All transactions should be included + expect(result.transactionStats.totalCount).toBe(3); + expect(result.totalExpenses).toBe(600); + }); + + it('includes transfers between two off-budget accounts when includeOffBudget is true', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc2', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc2', payee: 'payee2', amount: -20000 }), // transfer off->off + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + createMockAccount({ id: 'acc3', name: 'Brokerage', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer Off->Off', transfer_acct: 'acc3' }, // off-budget to off-budget + ]; + + // With includeOffBudget = true but includeOnBudgetTransfers = false, transfers are still excluded + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + true, // includeOffBudget = true + false, // includeOnBudgetTransfers = false (excludes ALL transfers) + false, // includeAllTransfers = false + ); + + // When includeOnBudgetTransfers is false, ALL transfers are excluded, even off->off + expect(result.transactionStats.totalCount).toBe(1); + expect(result.totalExpenses).toBe(100); + }); + + it('includes transfers between two off-budget accounts when includeAllTransfers is true (even without includeOffBudget)', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc2', payee: 'payee1', amount: -10000 }), + createMockTransaction({ id: 't2', account: 'acc2', payee: 'payee2', amount: -20000 }), // transfer off->off + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + createMockAccount({ id: 'acc3', name: 'Brokerage', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer Off->Off', transfer_acct: 'acc3' }, // off-budget to off-budget + ]; + + // With includeAllTransfers = true, ALL transfers should be included, even off->off + // This should work even without includeOffBudget = true + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, // includeOffBudget = false + false, // includeOnBudgetTransfers = false + true, // includeAllTransfers = true (should include ALL transfers including off->off) + ); + + // Transfer between two off-budget accounts should be included when includeAllTransfers is true + // Regular transaction from off-budget account should be excluded (includeOffBudget = false) + expect(result.transactionStats.totalCount).toBe(1); // Only the transfer, not the regular transaction + expect(result.totalExpenses).toBe(200); // Only the transfer amount + }); + + it('handles mixed transfer scenarios correctly', () => { + const transactions: Transaction[] = [ + createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), // regular + createMockTransaction({ id: 't2', account: 'acc1', payee: 'payee2', amount: -20000 }), // transfer on->on + createMockTransaction({ id: 't3', account: 'acc1', payee: 'payee3', amount: -30000 }), // transfer on->off + createMockTransaction({ id: 't4', account: 'acc2', payee: 'payee4', amount: -40000 }), // transfer off->on + ]; + + const accounts: Account[] = [ + createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), + createMockAccount({ id: 'acc2', name: 'Investment', offbudget: true }), + createMockAccount({ id: 'acc3', name: 'Savings', offbudget: false }), + createMockAccount({ id: 'acc4', name: 'Brokerage', offbudget: true }), + ]; + + const payees = [ + { id: 'payee1', name: 'Regular Payee' }, + { id: 'payee2', name: 'Transfer On->On', transfer_acct: 'acc3' }, + { id: 'payee3', name: 'Transfer On->Off', transfer_acct: 'acc2' }, + { id: 'payee4', name: 'Transfer Off->On', transfer_acct: 'acc1' }, + ]; + + // With includeOffBudget = true, includeOnBudgetTransfers = true, includeAllTransfers = true + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + true, // includeOffBudget = true + true, // includeOnBudgetTransfers = true + true, // includeAllTransfers = true + ); + + // All transactions should be included + expect(result.transactionStats.totalCount).toBe(4); + expect(result.totalExpenses).toBe(1000); + }); + }); }); describe('Off-Budget Filtering', () => { @@ -601,24 +1303,36 @@ describe('transformToWrappedData', () => { const transactions: Transaction[] = [ createMockTransaction({ id: 't1', account: 'acc1', payee: 'payee1', amount: -10000 }), createMockTransaction({ id: 't2', account: 'acc2', payee: 'payee2', amount: -20000 }), // off-budget - createMockTransaction({ id: 't3', account: 'acc1', payee: 'payee3', amount: -30000 }), // transfer + createMockTransaction({ id: 't3', account: 'acc1', payee: 'payee3', amount: -30000 }), // transfer on->off createMockTransaction({ id: 't4', account: 'acc1', payee: 'payee4', amount: -40000 }), // starting balance ]; const accounts: Account[] = [ createMockAccount({ id: 'acc1', name: 'Checking', offbudget: false }), createMockAccount({ id: 'acc2', name: 'Car Value', offbudget: true }), + createMockAccount({ id: 'acc3', name: 'Investment', offbudget: true }), ]; const payees = [ { id: 'payee1', name: 'Regular Payee' }, { id: 'payee2', name: 'Regular Payee 2' }, - { id: 'payee3', name: 'Transfer', transfer_acct: 'acc3' }, + { id: 'payee3', name: 'Transfer', transfer_acct: 'acc3' }, // on->off transfer { id: 'payee4', name: 'Starting Balance' }, ]; - const result = transformToWrappedData(transactions, [], payees, accounts); + // Explicitly disable includeOnBudgetTransfers to test exclusion + const result = transformToWrappedData( + transactions, + [], + payees, + accounts, + 2025, + false, // includeOffBudget = false + false, // includeOnBudgetTransfers = false (explicitly disabled) + false, // includeAllTransfers = false + ); + // Only regular transaction should be included (transfer, off-budget, and starting balance excluded) expect(result.transactionStats.totalCount).toBe(1); expect(result.totalExpenses).toBe(100); }); diff --git a/src/utils/dataTransform.ts b/src/utils/dataTransform.ts index 101e5e7..4ae9ae7 100644 --- a/src/utils/dataTransform.ts +++ b/src/utils/dataTransform.ts @@ -65,6 +65,8 @@ function calculateBudgetComparison( accountOffbudgetMap: Map, categoryIdToName: Map, categoryIdToGroup: Map, + payeeIdToTransferAcct: Map, + accountIdToName: Map, ): BudgetComparisonData | undefined { if (!budgetData || budgetData.length === 0) { return undefined; @@ -91,7 +93,21 @@ function calculateBudgetComparison( let categoryId: string; if (!t.category || t.category === '') { - categoryId = isOffBudget ? 'off-budget' : 'uncategorized'; + // Check if this is a transfer + const isTransfer = t.payee && payeeIdToTransferAcct.has(t.payee); + if (isTransfer && t.payee) { + const destinationAccountId = payeeIdToTransferAcct.get(t.payee); + const destinationAccountName = destinationAccountId + ? accountIdToName.get(destinationAccountId) || destinationAccountId + : 'Unknown Account'; + categoryId = `transfer:${destinationAccountId || 'unknown'}`; + // Store the display name in the categoryIdToName map for later use + categoryIdToName.set(categoryId, `Transfer: ${destinationAccountName}`); + } else if (isOffBudget) { + categoryId = 'off-budget'; + } else { + categoryId = 'uncategorized'; + } } else { categoryId = t.category; } @@ -104,18 +120,37 @@ function calculateBudgetComparison( categorySpending.set(monthName, currentAmount + integerToAmount(Math.abs(t.amount))); }); + // Create a Set of transaction IDs from expenseTransactions to avoid double-counting + const expenseTransactionIds = new Set(expenseTransactions.map(t => t.id)); + // Include transfer transactions (only expense transfers, i.e., negative amounts) + // BUT exclude transfers that are already in expenseTransactions to avoid double-counting transferTransactions.forEach(t => { // Only include transfers that are expenses (negative amounts) if (t.amount >= 0) return; + // Skip if this transfer is already in expenseTransactions (to avoid double-counting) + if (expenseTransactionIds.has(t.id)) return; + const date = parseISO(t.date); const monthName = MONTHS[date.getMonth()]; const isOffBudget = accountOffbudgetMap.get(t.account) || false; let categoryId: string; if (!t.category || t.category === '') { - categoryId = isOffBudget ? 'off-budget' : 'uncategorized'; + // This is a transfer, get the destination account name + const destinationAccountId = t.payee ? payeeIdToTransferAcct.get(t.payee) : undefined; + if (destinationAccountId) { + const destinationAccountName = + accountIdToName.get(destinationAccountId) || destinationAccountId; + categoryId = `transfer:${destinationAccountId}`; + // Store the display name in the categoryIdToName map for later use + categoryIdToName.set(categoryId, `Transfer: ${destinationAccountName}`); + } else if (isOffBudget) { + categoryId = 'off-budget'; + } else { + categoryId = 'uncategorized'; + } } else { categoryId = t.category; } @@ -246,6 +281,8 @@ export function transformToWrappedData( accounts: Account[] = [], year: number = DEFAULT_YEAR, includeOffBudget: boolean = false, + includeOnBudgetTransfers: boolean = true, // Default to true (on by default) + includeAllTransfers: boolean = false, currencySymbol: string = '$', budgetData?: Array<{ categoryId: string; month: string; budgetedAmount: number }>, groupSortOrders: Map = new Map(), @@ -284,27 +321,84 @@ export function transformToWrappedData( if (date < yearStart || date > yearEnd) { return false; } - // Collect transfer transactions (payees with transfer_acct field) but exclude from regular transactions + // Collect transfer transactions (payees with transfer_acct field) const isTransfer = t.payee && payeeIdToTransferAcct.has(t.payee); if (isTransfer) { - // Check off-budget status - const isOffBudget = accountOffbudgetMap.get(t.account) || false; - // Include transfer if: (includeOffBudget is true) OR (account is not off-budget) - if (includeOffBudget || !isOffBudget) { - // Exclude starting balance transfers - const payeeName = t.payee ? payeeIdToName.get(t.payee) || t.payee_name : t.payee_name; - const isStartingBalance = payeeName && payeeName.toLowerCase() === 'starting balance'; - if (!isStartingBalance) { - transferTransactions.push(t); + // Check off-budget status of source account (needed for transferTransactions collection) + const sourceIsOffBudget = accountOffbudgetMap.get(t.account) || false; + // Get destination account ID from transfer payee + const destinationAccountId = t.payee ? payeeIdToTransferAcct.get(t.payee) : undefined; + const destinationIsOffBudget = destinationAccountId + ? accountOffbudgetMap.get(destinationAccountId) || false + : false; + + // Determine transfer types (only need on->off and off->on for filtering) + const isOnBudgetToOffBudget = + !sourceIsOffBudget && destinationAccountId && destinationIsOffBudget; + const isOffBudgetToOnBudget = + sourceIsOffBudget && destinationAccountId && !destinationIsOffBudget; + + // Apply includeAllTransfers filter first (highest priority) + // When includeAllTransfers is true, include ALL transfers (on->on, on->off, off->on, off->off) + if (includeAllTransfers) { + // Include all transfers - continue processing (don't return false) + } else { + // includeAllTransfers is false - apply includeOnBudgetTransfers filter + // If includeOnBudgetTransfers is false, exclude ALL transfers + if (!includeOnBudgetTransfers) { + return false; + } + + // When includeOnBudgetTransfers is true but includeAllTransfers is false: + // Only include transfers between on-budget and off-budget accounts (on->off and off->on) + // Exclude: on->on transfers and off->off transfers + if (includeOnBudgetTransfers && !includeAllTransfers) { + // Only include on->off and off->on transfers + if (!(isOnBudgetToOffBudget || isOffBudgetToOnBudget)) { + // Exclude on->on and off->off transfers + return false; + } } } - return false; + + // Apply includeAllTransfers filter + // When includeAllTransfers is true, include ALL transfers (on->on, on->off, off->on, off->off) + // When includeAllTransfers is false, it doesn't override includeOnBudgetTransfers for on->off/off->on + // but it still controls off->off transfers when includeOffBudget is also true + + // Exclude starting balance transfers + const payeeName = t.payee ? payeeIdToName.get(t.payee) || t.payee_name : t.payee_name; + const isStartingBalance = payeeName && payeeName.toLowerCase() === 'starting balance'; + if (isStartingBalance) { + return false; + } + + // Include transfer in transferTransactions for budget comparison if applicable + // Include transfer if: + // - (includeOffBudget is true) OR (source account is not off-budget) OR + // - (includeAllTransfers is true - includes ALL transfers regardless of off-budget status) + if (includeOffBudget || !sourceIsOffBudget || includeAllTransfers) { + transferTransactions.push(t); + } + + // Include transfer in regular transactions if the toggles allow it + // Continue processing (don't return false) so it's included in yearTransactions + // The transfer will be processed as a regular transaction below + // Note: We've already checked the toggles above, so if we reach here, the transfer should be included + // The off-budget check below will handle transfers with includeAllTransfers enabled } - // Exclude off-budget transactions (unless includeOffBudget is true) + // Exclude off-budget transactions (unless includeOffBudget is true OR it's a transfer with includeAllTransfers enabled) + // When includeAllTransfers is true, ALL transfers should be included regardless of off-budget status if (!includeOffBudget) { const isOffBudget = accountOffbudgetMap.get(t.account) || false; if (isOffBudget) { - return false; + // Check if this is a transfer and includeAllTransfers is enabled + const isTransfer = t.payee && payeeIdToTransferAcct.has(t.payee); + if (!isTransfer || !includeAllTransfers) { + // Not a transfer, or transfer but includeAllTransfers is false - apply normal off-budget exclusion + return false; + } + // It's a transfer and includeAllTransfers is true - include it even if from off-budget account } } // Exclude starting balance transactions @@ -380,11 +474,25 @@ export function transformToWrappedData( // If transaction has no category and account is off-budget, use "off budget" // Otherwise, use "uncategorized" for transactions without category + // If it's a transfer, show "Transfer: {accountName}" let categoryId: string; let categoryName: string; if (!t.category || t.category === '') { - if (isOffBudget) { + // Check if this is a transfer + const isTransfer = t.payee && payeeIdToTransferAcct.has(t.payee); + if (isTransfer && t.payee) { + const destinationAccountId = payeeIdToTransferAcct.get(t.payee); + if (destinationAccountId) { + const destinationAccountName = + accountIdToName.get(destinationAccountId) || destinationAccountId; + categoryId = `transfer:${destinationAccountId}`; + categoryName = `Transfer: ${destinationAccountName}`; + } else { + categoryId = 'uncategorized'; + categoryName = 'Uncategorized'; + } + } else if (isOffBudget) { categoryId = 'off-budget'; categoryName = 'Off Budget'; } else { @@ -464,38 +572,53 @@ export function transformToWrappedData( expenseTransactions.forEach(t => { const payeeId = t.payee; - // Get payee name - prioritize: mapping > transaction payee_name > "Unknown" (never use ID) + + // Check if this is a transfer first + const isTransfer = payeeId && payeeIdToTransferAcct.has(payeeId); let basePayeeName: string; - if (payeeId && payeeIdToName.has(payeeId)) { - // Found in mapping - basePayeeName = payeeIdToName.get(payeeId)!; - } else if (t.payee_name && t.payee_name.trim() !== '') { - // Check if payee_name is "unknown" (case-insensitive) - if (t.payee_name.trim().toLowerCase() === 'unknown') { - basePayeeName = 'Unknown'; + + if (isTransfer && payeeId) { + // This is a transfer - get the RECEIVING (destination) account name + // Note: t.account is the SOURCE account, but we want the RECEIVING account name + // The payee's transfer_acct field points to the destination/receiving account + const receivingAccountId = payeeIdToTransferAcct.get(payeeId); + if (receivingAccountId) { + const receivingAccountName = + accountIdToName.get(receivingAccountId) || receivingAccountId; + basePayeeName = `Transfer: ${receivingAccountName}`; } else { - // Check if payee_name looks like an ID (exists in our mapping but as a key, not a name) - // If it's the same as payeeId, it's likely an ID, not a name - const looksLikeId = t.payee_name === payeeId || payeeIdToName.has(t.payee_name); - if (looksLikeId && payeeIdToName.has(t.payee_name)) { - // It's actually an ID, look it up - basePayeeName = payeeIdToName.get(t.payee_name)!; - } else if (!looksLikeId) { - // It's a real name, use it - basePayeeName = t.payee_name; - } else { - // It looks like an ID but we can't find it in mapping, use "Unknown" + basePayeeName = 'Unknown'; + } + } else { + // Regular payee - get payee name - prioritize: mapping > transaction payee_name > "Unknown" (never use ID) + if (payeeId && payeeIdToName.has(payeeId)) { + // Found in mapping + basePayeeName = payeeIdToName.get(payeeId)!; + } else if (t.payee_name && t.payee_name.trim() !== '') { + // Check if payee_name is "unknown" (case-insensitive) + if (t.payee_name.trim().toLowerCase() === 'unknown') { basePayeeName = 'Unknown'; + } else { + // Check if payee_name looks like an ID (exists in our mapping but as a key, not a name) + // If it's the same as payeeId, it's likely an ID, not a name + const looksLikeId = t.payee_name === payeeId || payeeIdToName.has(t.payee_name); + if (looksLikeId && payeeIdToName.has(t.payee_name)) { + // It's actually an ID, look it up + basePayeeName = payeeIdToName.get(t.payee_name)!; + } else if (!looksLikeId) { + // It's a real name, use it + basePayeeName = t.payee_name; + } else { + // It looks like an ID but we can't find it in mapping, use "Unknown" + basePayeeName = 'Unknown'; + } } + } else { + // Fallback to "Unknown" instead of showing the ID + basePayeeName = 'Unknown'; } - } else { - // Fallback to "Unknown" instead of showing the ID - basePayeeName = 'Unknown'; } - // Note: Transfer transactions are already filtered out earlier, - // so we don't need to check for transfers here anymore - // Check if payee is deleted (from mapping or transaction) const isDeleted = (payeeId && payeeIdToTombstone.get(payeeId)) || t.payee_tombstone || false; const payeeName = isDeleted ? `deleted: ${basePayeeName}` : basePayeeName; @@ -988,6 +1111,8 @@ export function transformToWrappedData( accountOffbudgetMap, categoryIdToName, categoryIdToGroup, + payeeIdToTransferAcct, + accountIdToName, ); // Add group sort orders to budget comparison if available