Skip to content

Commit c1fcaaa

Browse files
committed
feat: split available funds in "income" and "not budgeted" and "Starting Balance"
also im to lazy to split this out into a single commit sorry future maintainers fix #51
1 parent 8dc0983 commit c1fcaaa

25 files changed

+424
-261
lines changed

main/moneymoney/handlers.ts

+49-25
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,54 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(
105105
}) as any;
106106
}
107107

108+
function extractTransactions(val: unknown): unknown[] {
109+
if (
110+
typeof val === 'object' &&
111+
val !== null &&
112+
Array.isArray((val as any).transactions)
113+
) {
114+
return (val as any).transactions;
115+
}
116+
117+
throw new Error('Unexpected transactions object');
118+
}
119+
120+
async function exportTransactions(
121+
_: any,
122+
accountNumbers: string[],
123+
startDate: string,
124+
): Promise<unknown[]>;
125+
async function exportTransactions(_: any): Promise<unknown[]>;
126+
async function exportTransactions(
127+
_: any,
128+
accountNumbers?: string[],
129+
startDate?: string,
130+
): Promise<unknown[]> {
131+
if (accountNumbers && startDate) {
132+
const transactions = await Promise.all(
133+
accountNumbers.map(async (accountNumber) => {
134+
return parse(
135+
await osascript(
136+
join(scriptsDir, 'exportTransactions.applescript'),
137+
accountNumber,
138+
startDate,
139+
),
140+
);
141+
}),
142+
);
143+
144+
return transactions
145+
.map(extractTransactions)
146+
.reduce((m, ts) => m.concat(ts), []);
147+
}
148+
149+
return extractTransactions(
150+
parse(
151+
await osascript(join(scriptsDir, 'exportAllTransactions.applescript')),
152+
),
153+
);
154+
}
155+
108156
export default function moneymoneyHandlers(ipcMain: IpcMain) {
109157
ipcMain.handle(
110158
'MM_EXPORT_ACCOUNTS',
@@ -119,31 +167,7 @@ export default function moneymoneyHandlers(ipcMain: IpcMain) {
119167
}),
120168
);
121169

122-
ipcMain.handle(
123-
'MM_EXPORT_TRANSACTIONS',
124-
withRetry(async (_, accountNumbers: string[], startDate: string) => {
125-
return Promise.all(
126-
accountNumbers.map(async (accountNumber) => {
127-
return parse(
128-
await osascript(
129-
join(scriptsDir, 'exportTransactions.applescript'),
130-
accountNumber,
131-
startDate,
132-
),
133-
);
134-
}),
135-
);
136-
}),
137-
);
138-
139-
ipcMain.handle(
140-
'MM_EXPORT_ALL_TRANSACTIONS',
141-
withRetry(async () => {
142-
return parse(
143-
await osascript(join(scriptsDir, 'exportAllTransactions.applescript')),
144-
);
145-
}),
146-
);
170+
ipcMain.handle('MM_EXPORT_TRANSACTIONS', withRetry(exportTransactions));
147171

148172
ipcMain.handle(
149173
'MM_EXPORT_CATEGORIES',
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
tell application "MoneyMoney" to export transactions from date 1900 - 1 - 1 as "plist"
1+
tell application "MoneyMoney" to export transactions from date 1900-1-1 as "plist"

src/App.tsx

+42-14
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import './theme.scss';
2-
import React, { Suspense, useState, useCallback, ReactNode } from 'react';
2+
import React, {
3+
Suspense,
4+
useState,
5+
useCallback,
6+
ReactNode,
7+
Dispatch,
8+
SetStateAction,
9+
} from 'react';
310
import classNames from 'classnames';
411
import {
512
InitRes,
613
getInitData,
714
useBudgetReducer,
815
initialInitDataRes,
16+
InitDataWithState,
917
} from './budget';
1018
import { ErrorBoundary, Startup } from './components';
1119
import styles from './App.module.scss';
@@ -16,29 +24,24 @@ const Welcome = React.lazy(() => import('./views/Welcome'));
1624
const NewBudget = React.lazy(() => import('./views/NewBudget'));
1725
const Main = React.lazy(() => import('./views/Main'));
1826

19-
function App({ readInitialView }: { readInitialView: InitRes }) {
20-
const [initialView, initialState] = readInitialView();
21-
const [view, setView] = useState('new' as typeof initialView);
22-
const [moneyMoney, updateSettings] = useMoneyMoney();
23-
const [state, dispatch] = useBudgetReducer(initialState, updateSettings);
27+
function App(initData: InitDataWithState) {
28+
const [view, setView] = useState(initData.view);
29+
const [moneyMoney, updateSettings] = useMoneyMoney(initData.res);
30+
const [state, dispatch] = useBudgetReducer(initData.state, updateSettings);
2431
const numberFormatter = useNumberFormatter(state.settings.fractionDigits);
2532
const openBudget = useCallback(() => {
2633
setView('budget');
27-
}, []);
28-
const openNew = useCallback(() => {
29-
setView('new');
30-
}, []);
34+
}, [setView]);
3135

3236
return (
3337
<ErrorBoundary>
3438
<Suspense fallback={<Startup />}>
3539
{((): ReactNode => {
3640
switch (view) {
37-
case 'welcome':
38-
return <Welcome onCreate={openNew} />;
3941
case 'new':
4042
return (
4143
<NewBudget
44+
numberFormatter={numberFormatter}
4245
state={state}
4346
dispatch={dispatch}
4447
onCreate={openBudget}
@@ -48,8 +51,8 @@ function App({ readInitialView }: { readInitialView: InitRes }) {
4851
default:
4952
return (
5053
<Main
51-
view={view}
5254
numberFormatter={numberFormatter}
55+
view={view}
5356
moneyMoney={moneyMoney}
5457
state={state}
5558
dispatch={dispatch}
@@ -63,6 +66,28 @@ function App({ readInitialView }: { readInitialView: InitRes }) {
6366
);
6467
}
6568

69+
type AppWelcomeSwitchProps = {
70+
readInitialView: InitRes;
71+
setInitRes: Dispatch<SetStateAction<InitRes>>;
72+
};
73+
function AppWelcomeSwitch({
74+
readInitialView,
75+
setInitRes,
76+
}: AppWelcomeSwitchProps) {
77+
const initData = readInitialView();
78+
const openNew = useCallback(() => {
79+
// setInitRes
80+
// setView('new');
81+
}, []);
82+
83+
switch (initData.view) {
84+
case 'welcome':
85+
return <Welcome onCreate={openNew} />;
86+
default:
87+
return <App {...initData} />;
88+
}
89+
}
90+
6691
export default function AppWrapper() {
6792
const [readInit, setInitRes] = useState(() => initialInitDataRes);
6893
const retryReadInit = useRetryResource(
@@ -79,7 +104,10 @@ export default function AppWrapper() {
79104
>
80105
<Suspense fallback={<Startup />}>
81106
<ErrorBoundary>
82-
<App readInitialView={retryReadInit} />
107+
<AppWelcomeSwitch
108+
readInitialView={retryReadInit}
109+
setInitRes={setInitRes}
110+
/>
83111
</ErrorBoundary>
84112
</Suspense>
85113
</div>

src/budget/Types.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,19 @@ export type OverspendRollover = { [key: string]: boolean };
122122
export type Rollover = { total: number; [key: string]: number };
123123

124124
export type InterMonthData = {
125+
startBalance?: number;
125126
uncategorized: AmountWithTransactions;
126127
categories: (BudgetCategoryRow | BudgetCategoryGroup)[];
127128
toBudget: number;
128129
total: BudgetRow;
129130
income: AmountWithPartialTransactions;
130-
overspendPrevMonth: number;
131+
prevMonth: {
132+
overspend: number;
133+
startBalance?: number;
134+
toBudget: number;
135+
};
131136
overspendRolloverState: OverspendRollover;
132137
available: AmountWithPartialTransactions[];
133-
availableThisMonth: AmountWithPartialTransactions;
134138
rollover: Rollover;
135139
};
136140
export type MonthData = {

src/budget/createInitialState.ts

+108
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import startOfMonth from 'date-fns/startOfMonth';
2+
import subMonths from 'date-fns/subMonths';
3+
import isAfter from 'date-fns/isAfter';
4+
import { getToday } from '../lib';
5+
import {
6+
getAccounts,
7+
getTransactions,
8+
filterAccounts,
9+
Transaction,
10+
Account,
11+
} from '../moneymoney';
12+
13+
import { BudgetState, IncomeCategory, VERSION } from './Types';
14+
15+
function getStartBalance(
16+
startDate: Date,
17+
transactions: Transaction[],
18+
accounts: Account[],
19+
): number {
20+
const transactionsSinceStart = transactions.filter(({ bookingDate }) =>
21+
isAfter(bookingDate, startDate),
22+
);
23+
const transactionBal = transactionsSinceStart.reduce(
24+
(m, { amount }) => m + amount,
25+
0,
26+
);
27+
const accountsBal = accounts.reduce((m, { balance }) => m + balance, 0);
28+
29+
return accountsBal + transactionBal * -1;
30+
}
31+
32+
function isLaterHalfOfMonth({ bookingDate }: Transaction) {
33+
return bookingDate.getDate() >= 15;
34+
}
35+
36+
function getIncomeCategories(transactions: Transaction[]): IncomeCategory[] {
37+
const transactionsByCat = transactions.reduce((memo, transaction) => {
38+
const { categoryUuid, amount } = transaction;
39+
40+
if (!memo[categoryUuid]) {
41+
memo[categoryUuid] = { transactions: [], balance: 0, hasNegative: false };
42+
}
43+
44+
memo[categoryUuid].transactions.push(transaction);
45+
memo[categoryUuid].balance += amount;
46+
if (amount < 0) {
47+
memo[categoryUuid].hasNegative = true;
48+
}
49+
50+
return memo;
51+
}, {} as { [key: string]: { transactions: Transaction[]; balance: number; hasNegative: boolean } });
52+
53+
const positiveCats = Object.entries(transactionsByCat)
54+
.filter(([_, { hasNegative }]) => !hasNegative)
55+
.sort(([_, { balance: a }], [__, { balance: b }]) => b - a);
56+
57+
return positiveCats.map(([id, { transactions }]) => ({
58+
id,
59+
availableIn: transactions.some(isLaterHalfOfMonth) ? 1 : 0,
60+
}));
61+
}
62+
63+
export default async function createInitialState(): Promise<BudgetState> {
64+
const [allAccounts, allTransactions] = await Promise.all([
65+
getAccounts(),
66+
getTransactions(),
67+
]);
68+
const currenciesWithUsage = allAccounts.reduce(
69+
(memo, { group, currency }) => {
70+
if (group) {
71+
return memo;
72+
}
73+
memo[currency] = (memo[currency] || 0) + 1;
74+
return memo;
75+
},
76+
{ USD: 1 } as { [key: string]: number },
77+
);
78+
const currenciesByUsage = Object.entries(currenciesWithUsage)
79+
.sort(([_, a], [__, b]) => b - a)
80+
.map(([c]) => c);
81+
const currency = currenciesByUsage[0];
82+
const accounts = filterAccounts(currency, allAccounts).filter(
83+
({ group, portfolio }) => !group && !portfolio,
84+
);
85+
const accountUuids = accounts.map(({ uuid }) => uuid);
86+
const transactionsOfAccounts = allTransactions.filter(({ accountUuid }) =>
87+
accountUuids.includes(accountUuid),
88+
);
89+
const startDate = startOfMonth(subMonths(getToday(), 1));
90+
91+
return {
92+
name: '',
93+
version: VERSION,
94+
budgets: {},
95+
settings: {
96+
currency,
97+
incomeCategories: getIncomeCategories(transactionsOfAccounts),
98+
accounts: accountUuids,
99+
fractionDigits: 2,
100+
startDate: startDate.getTime(),
101+
startBalance: getStartBalance(
102+
startDate,
103+
transactionsOfAccounts,
104+
accounts,
105+
),
106+
},
107+
};
108+
}

src/budget/getInitData.ts

+34-10
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,51 @@ import { ipcRenderer } from 'electron';
22
import { readFile as rf } from 'fs';
33
import { promisify } from 'util';
44
import { createResource, Resource } from '../lib';
5-
import { View } from '../shared/types';
6-
import { INITIAL_STATE } from './budgetReducer';
5+
import { InitialRes, createInitialRes } from '../moneymoney';
6+
import {
7+
ViewBudget,
8+
ViewNew,
9+
ViewSettings,
10+
ViewWelcome,
11+
View,
12+
} from '../shared/types';
713
import { validateBudgetState, BudgetState } from './Types';
14+
import createInitialState from './createInitialState';
815

916
const readFile = promisify(rf);
10-
type InitData = [View['type'], BudgetState];
17+
export type InitDataWithState = {
18+
view: (ViewBudget | ViewNew | ViewSettings)['type'];
19+
state: BudgetState;
20+
res: InitialRes;
21+
};
22+
export type InitData = InitDataWithState | { view: ViewWelcome['type'] };
1123
export type InitRes = Resource<InitData>;
1224

1325
async function getInitData(): Promise<InitData> {
1426
const init: View = await ipcRenderer.invoke('INIT');
1527

1628
switch (init.type) {
1729
case 'welcome':
18-
case 'new':
19-
return [init.type, INITIAL_STATE];
30+
return { view: init.type };
31+
case 'new': {
32+
const initialState = await createInitialState();
33+
return {
34+
view: init.type,
35+
state: initialState,
36+
res: createInitialRes(init.type, initialState.settings),
37+
};
38+
}
2039
case 'budget':
21-
case 'settings':
22-
return [
23-
init.type,
24-
validateBudgetState(JSON.parse((await readFile(init.file)).toString())),
25-
];
40+
case 'settings': {
41+
const state = validateBudgetState(
42+
JSON.parse((await readFile(init.file)).toString()),
43+
);
44+
return {
45+
view: init.type,
46+
state,
47+
res: createInitialRes(init.type, state.settings),
48+
};
49+
}
2650
}
2751
}
2852

0 commit comments

Comments
 (0)