Skip to content

Commit

Permalink
feat: split available funds in "income" and "not budgeted" and "Start…
Browse files Browse the repository at this point in the history
…ing Balance"

also im to lazy to split this out into a single commit
sorry future maintainers

fix #51
  • Loading branch information
Xiphe committed Oct 20, 2020
1 parent abd7de3 commit 82d5891
Show file tree
Hide file tree
Showing 25 changed files with 424 additions and 261 deletions.
74 changes: 49 additions & 25 deletions main/moneymoney/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,54 @@ function withRetry<T extends (...args: any[]) => Promise<any>>(
}) as any;
}

function extractTransactions(val: unknown): unknown[] {
if (
typeof val === 'object' &&
val !== null &&
Array.isArray((val as any).transactions)
) {
return (val as any).transactions;
}

throw new Error('Unexpected transactions object');
}

async function exportTransactions(
_: any,
accountNumbers: string[],
startDate: string,
): Promise<unknown[]>;
async function exportTransactions(_: any): Promise<unknown[]>;
async function exportTransactions(
_: any,
accountNumbers?: string[],
startDate?: string,
): Promise<unknown[]> {
if (accountNumbers && startDate) {
const transactions = await Promise.all(
accountNumbers.map(async (accountNumber) => {
return parse(
await osascript(
join(scriptsDir, 'exportTransactions.applescript'),
accountNumber,
startDate,
),
);
}),
);

return transactions
.map(extractTransactions)
.reduce((m, ts) => m.concat(ts), []);
}

return extractTransactions(
parse(
await osascript(join(scriptsDir, 'exportAllTransactions.applescript')),
),
);
}

export default function moneymoneyHandlers(ipcMain: IpcMain) {
ipcMain.handle(
'MM_EXPORT_ACCOUNTS',
Expand All @@ -119,31 +167,7 @@ export default function moneymoneyHandlers(ipcMain: IpcMain) {
}),
);

ipcMain.handle(
'MM_EXPORT_TRANSACTIONS',
withRetry(async (_, accountNumbers: string[], startDate: string) => {
return Promise.all(
accountNumbers.map(async (accountNumber) => {
return parse(
await osascript(
join(scriptsDir, 'exportTransactions.applescript'),
accountNumber,
startDate,
),
);
}),
);
}),
);

ipcMain.handle(
'MM_EXPORT_ALL_TRANSACTIONS',
withRetry(async () => {
return parse(
await osascript(join(scriptsDir, 'exportAllTransactions.applescript')),
);
}),
);
ipcMain.handle('MM_EXPORT_TRANSACTIONS', withRetry(exportTransactions));

ipcMain.handle(
'MM_EXPORT_CATEGORIES',
Expand Down
2 changes: 1 addition & 1 deletion main/scripts/exportAllTransactions.applescript
Original file line number Diff line number Diff line change
@@ -1 +1 @@
tell application "MoneyMoney" to export transactions from date 1900 - 1 - 1 as "plist"
tell application "MoneyMoney" to export transactions from date 1900-1-1 as "plist"
56 changes: 42 additions & 14 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import './theme.scss';
import React, { Suspense, useState, useCallback, ReactNode } from 'react';
import React, {
Suspense,
useState,
useCallback,
ReactNode,
Dispatch,
SetStateAction,
} from 'react';
import classNames from 'classnames';
import {
InitRes,
getInitData,
useBudgetReducer,
initialInitDataRes,
InitDataWithState,
} from './budget';
import { ErrorBoundary, Startup } from './components';
import styles from './App.module.scss';
Expand All @@ -16,29 +24,24 @@ const Welcome = React.lazy(() => import('./views/Welcome'));
const NewBudget = React.lazy(() => import('./views/NewBudget'));
const Main = React.lazy(() => import('./views/Main'));

function App({ readInitialView }: { readInitialView: InitRes }) {
const [initialView, initialState] = readInitialView();
const [view, setView] = useState('new' as typeof initialView);
const [moneyMoney, updateSettings] = useMoneyMoney();
const [state, dispatch] = useBudgetReducer(initialState, updateSettings);
function App(initData: InitDataWithState) {
const [view, setView] = useState(initData.view);
const [moneyMoney, updateSettings] = useMoneyMoney(initData.res);
const [state, dispatch] = useBudgetReducer(initData.state, updateSettings);
const numberFormatter = useNumberFormatter(state.settings.fractionDigits);
const openBudget = useCallback(() => {
setView('budget');
}, []);
const openNew = useCallback(() => {
setView('new');
}, []);
}, [setView]);

return (
<ErrorBoundary>
<Suspense fallback={<Startup />}>
{((): ReactNode => {
switch (view) {
case 'welcome':
return <Welcome onCreate={openNew} />;
case 'new':
return (
<NewBudget
numberFormatter={numberFormatter}
state={state}
dispatch={dispatch}
onCreate={openBudget}
Expand All @@ -48,8 +51,8 @@ function App({ readInitialView }: { readInitialView: InitRes }) {
default:
return (
<Main
view={view}
numberFormatter={numberFormatter}
view={view}
moneyMoney={moneyMoney}
state={state}
dispatch={dispatch}
Expand All @@ -63,6 +66,28 @@ function App({ readInitialView }: { readInitialView: InitRes }) {
);
}

type AppWelcomeSwitchProps = {
readInitialView: InitRes;
setInitRes: Dispatch<SetStateAction<InitRes>>;
};
function AppWelcomeSwitch({
readInitialView,
setInitRes,
}: AppWelcomeSwitchProps) {
const initData = readInitialView();
const openNew = useCallback(() => {
// setInitRes
// setView('new');
}, []);

switch (initData.view) {
case 'welcome':
return <Welcome onCreate={openNew} />;
default:
return <App {...initData} />;
}
}

export default function AppWrapper() {
const [readInit, setInitRes] = useState(() => initialInitDataRes);
const retryReadInit = useRetryResource(
Expand All @@ -79,7 +104,10 @@ export default function AppWrapper() {
>
<Suspense fallback={<Startup />}>
<ErrorBoundary>
<App readInitialView={retryReadInit} />
<AppWelcomeSwitch
readInitialView={retryReadInit}
setInitRes={setInitRes}
/>
</ErrorBoundary>
</Suspense>
</div>
Expand Down
8 changes: 6 additions & 2 deletions src/budget/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,15 +122,19 @@ export type OverspendRollover = { [key: string]: boolean };
export type Rollover = { total: number; [key: string]: number };

export type InterMonthData = {
startBalance?: number;
uncategorized: AmountWithTransactions;
categories: (BudgetCategoryRow | BudgetCategoryGroup)[];
toBudget: number;
total: BudgetRow;
income: AmountWithPartialTransactions;
overspendPrevMonth: number;
prevMonth: {
overspend: number;
startBalance?: number;
toBudget: number;
};
overspendRolloverState: OverspendRollover;
available: AmountWithPartialTransactions[];
availableThisMonth: AmountWithPartialTransactions;
rollover: Rollover;
};
export type MonthData = {
Expand Down
108 changes: 108 additions & 0 deletions src/budget/createInitialState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import startOfMonth from 'date-fns/startOfMonth';
import subMonths from 'date-fns/subMonths';
import isAfter from 'date-fns/isAfter';
import { getToday } from '../lib';
import {
getAccounts,
getTransactions,
filterAccounts,
Transaction,
Account,
} from '../moneymoney';

import { BudgetState, IncomeCategory, VERSION } from './Types';

function getStartBalance(
startDate: Date,
transactions: Transaction[],
accounts: Account[],
): number {
const transactionsSinceStart = transactions.filter(({ bookingDate }) =>
isAfter(bookingDate, startDate),
);
const transactionBal = transactionsSinceStart.reduce(
(m, { amount }) => m + amount,
0,
);
const accountsBal = accounts.reduce((m, { balance }) => m + balance, 0);

return accountsBal + transactionBal * -1;
}

function isLaterHalfOfMonth({ bookingDate }: Transaction) {
return bookingDate.getDate() >= 15;
}

function getIncomeCategories(transactions: Transaction[]): IncomeCategory[] {
const transactionsByCat = transactions.reduce((memo, transaction) => {
const { categoryUuid, amount } = transaction;

if (!memo[categoryUuid]) {
memo[categoryUuid] = { transactions: [], balance: 0, hasNegative: false };
}

memo[categoryUuid].transactions.push(transaction);
memo[categoryUuid].balance += amount;
if (amount < 0) {
memo[categoryUuid].hasNegative = true;
}

return memo;
}, {} as { [key: string]: { transactions: Transaction[]; balance: number; hasNegative: boolean } });

const positiveCats = Object.entries(transactionsByCat)
.filter(([_, { hasNegative }]) => !hasNegative)
.sort(([_, { balance: a }], [__, { balance: b }]) => b - a);

return positiveCats.map(([id, { transactions }]) => ({
id,
availableIn: transactions.some(isLaterHalfOfMonth) ? 1 : 0,
}));
}

export default async function createInitialState(): Promise<BudgetState> {
const [allAccounts, allTransactions] = await Promise.all([
getAccounts(),
getTransactions(),
]);
const currenciesWithUsage = allAccounts.reduce(
(memo, { group, currency }) => {
if (group) {
return memo;
}
memo[currency] = (memo[currency] || 0) + 1;
return memo;
},
{ USD: 1 } as { [key: string]: number },
);
const currenciesByUsage = Object.entries(currenciesWithUsage)
.sort(([_, a], [__, b]) => b - a)
.map(([c]) => c);
const currency = currenciesByUsage[0];
const accounts = filterAccounts(currency, allAccounts).filter(
({ group, portfolio }) => !group && !portfolio,
);
const accountUuids = accounts.map(({ uuid }) => uuid);
const transactionsOfAccounts = allTransactions.filter(({ accountUuid }) =>
accountUuids.includes(accountUuid),
);
const startDate = startOfMonth(subMonths(getToday(), 1));

return {
name: '',
version: VERSION,
budgets: {},
settings: {
currency,
incomeCategories: getIncomeCategories(transactionsOfAccounts),
accounts: accountUuids,
fractionDigits: 2,
startDate: startDate.getTime(),
startBalance: getStartBalance(
startDate,
transactionsOfAccounts,
accounts,
),
},
};
}
44 changes: 34 additions & 10 deletions src/budget/getInitData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,51 @@ import { ipcRenderer } from 'electron';
import { readFile as rf } from 'fs';
import { promisify } from 'util';
import { createResource, Resource } from '../lib';
import { View } from '../shared/types';
import { INITIAL_STATE } from './budgetReducer';
import { InitialRes, createInitialRes } from '../moneymoney';
import {
ViewBudget,
ViewNew,
ViewSettings,
ViewWelcome,
View,
} from '../shared/types';
import { validateBudgetState, BudgetState } from './Types';
import createInitialState from './createInitialState';

const readFile = promisify(rf);
type InitData = [View['type'], BudgetState];
export type InitDataWithState = {
view: (ViewBudget | ViewNew | ViewSettings)['type'];
state: BudgetState;
res: InitialRes;
};
export type InitData = InitDataWithState | { view: ViewWelcome['type'] };
export type InitRes = Resource<InitData>;

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

switch (init.type) {
case 'welcome':
case 'new':
return [init.type, INITIAL_STATE];
return { view: init.type };
case 'new': {
const initialState = await createInitialState();
return {
view: init.type,
state: initialState,
res: createInitialRes(init.type, initialState.settings),
};
}
case 'budget':
case 'settings':
return [
init.type,
validateBudgetState(JSON.parse((await readFile(init.file)).toString())),
];
case 'settings': {
const state = validateBudgetState(
JSON.parse((await readFile(init.file)).toString()),
);
return {
view: init.type,
state,
res: createInitialRes(init.type, state.settings),
};
}
}
}

Expand Down
Loading

0 comments on commit 82d5891

Please sign in to comment.