Add support for existing transactions

This commit is contained in:
2025-05-21 22:52:48 +02:00
parent e1dc42c254
commit b72d78b508
7 changed files with 235 additions and 25 deletions

View File

@@ -186,6 +186,72 @@ export class Actual {
return this.#doSubmit(prepared, opts);
});
}
async getTransactions(limit: number): Promise<Transaction[]> {
return this.#api(async () => {
const accounts: ActualAccount[] = await api.getAccounts();
const payees: ActualPayee[] = await api.getPayees();
const query = api.q('transactions')
.limit(limit)
.select(['*']);
const { data } = await api.runQuery(query) as { data: ActualTransaction[] };
return this.#mapActualTransactionsToTransactions(data, accounts, payees);
});
}
async getTransactionsFromRange(start: string, end: string): Promise<Transaction[]> {
return this.#api(async () => {
const accounts: ActualAccount[] = await api.getAccounts();
const payees: ActualPayee[] = await api.getPayees();
const query = api.q('transactions')
.filter({ date: [{ $gte: start }, { $lte: end }] })
.select(['*']);
const { data } = await api.runQuery(query) as { data: ActualTransaction[] };
return this.#mapActualTransactionsToTransactions(data, accounts, payees);
});
}
#mapActualTransactionsToTransactions(transactions: ActualTransaction[], accounts: ActualAccount[], payees: ActualPayee[]): Transaction[] {
const transfers: string[] = [];
return transactions.map(t => {
const account = accounts.find(a => a.id === t.account)?.name ?? t.account ?? "--unknown--";
const payee = payees.find(p => p.id === t.payee)?.name ?? t.payee ?? "--unknown--";
let from = account;
let to = payee;
if (t.amount && t.amount > 0) {
from = payee;
to = account;
}
if (t.transfer_id) {
if (transfers.includes(t.id!!)) {
return undefined;
}
transfers.push(t.transfer_id);
}
return {
kind: t.transfer_id ? 'transfer' : 'regular',
date: t.date,
from,
to,
fromDetails: from,
toDetails: to,
amount: (t.amount ?? 0)/100,
title: t.notes
} as Transaction;
}).filter(t => t !== undefined);
}
}
async function sleep(ms: number): Promise<void> {

View File

@@ -15,6 +15,29 @@ export type ImportResult = PrepareResult & {
result: ActualImportResult;
};
export const getTransactions = async (server: string, config: Config, limit: number = 5): Promise<Transaction[]> => {
const serverConfig = config.servers[server];
if(!serverConfig) {
throw new Error(`Unknown server: ${server}`);
}
const actualServer = new Actual(serverConfig);
return await actualServer.getTransactions(limit);
};
export const getTransactionsFromRange = async (server: string, config: Config, start: string, end: string): Promise<Transaction[]> => {
const serverConfig = config.servers[server];
if(!serverConfig) {
throw new Error(`Unknown server: ${server}`);
}
const actualServer = new Actual(serverConfig);
return await actualServer.getTransactionsFromRange(start, end);
};
export const prepareTransactions = async (stream: Readable, profile: string, server: string, config: Config): Promise<PrepareResult> => new Promise((resolve, reject) => {
const profileConfig = config.profiles[profile];

View File

@@ -7,23 +7,30 @@ export type UseLoaderReturnType<T extends any[], R> = {
error?: unknown;
};
export function useLoader<T extends any[], R>(fn: (...args: T) => Promise<R>, deps?: React.DependencyList) {
export function useLoader<T extends any[], R>(fn: (...args: T) => Promise<R>, deps: React.DependencyList = []) {
const [loading, setLoading] = useState(false);
const [completed, setCompleted] = useState(false);
const [error, setError] = useState<unknown|undefined>(undefined);
const callback = useCallback(async (...args: T) => {
const callback = useCallback(async (...args: T): Promise<R> => {
setLoading(true);
setCompleted(false);
setError(undefined);
// Wait 1 tick
await new Promise(resolve => setTimeout(resolve, 0));
try {
setLoading(true);
const value = await fn(...args);
const value = await fn(...args);
setCompleted(true);
return value;
} catch(e: unknown) {
setError(e);
throw e;
} finally {
setLoading(false);
}
}, [setLoading, setCompleted, setError, ...(deps ?? [])]);
}, [fn, ...deps]);
return {
fn: callback,
@@ -31,4 +38,4 @@ export function useLoader<T extends any[], R>(fn: (...args: T) => Promise<R>, de
completed,
error
};
}
}

View File

@@ -0,0 +1,54 @@
import classNames from "classnames";
import type { Transaction } from "../../types/api";
import { useCallback } from "react";
export type ExistingTransactionsProps = {
transactions: Transaction[];
loading: boolean;
};
const transactionEmoji = (transaction: Transaction): string => {
if (transaction.kind === 'transfer') {
return '➡️'
};
return transaction.amount > 0 ? '⬇️' : '⬆️';
};
const transactionType = (transaction: Transaction): string => {
if (transaction.kind === 'transfer') {
return 'Transfer'
};
return transaction.amount > 0 ? 'Inflow' : 'Outflow';
};
export function ExistingTransactions({ transactions, loading }: ExistingTransactionsProps) {
const renderRow = useCallback((transaction: Transaction, index: number) => (
<div
key={index}
className={classNames('notification', 'is-light', {
'is-success': transaction.kind === 'regular' && transaction.amount > 0,
'is-danger': transaction.kind === 'regular' && transaction.amount < 0,
'is-info': transaction.kind === 'transfer',
})}>
<h6 className="title is-6" title={transactionType(transaction)}>
{transactionEmoji(transaction)} {transaction.date}
</h6>
<strong>From:</strong> {transaction.from}<br />
<strong>To:</strong> {transaction.to}<br />
<strong>Title:</strong> {transaction.title}<br />
<strong>Amount:</strong> {transaction.amount}
</div>
), []);
return (
<>
{loading
? <div className="notification is-warning is-light">
<strong>Loading...</strong>
</div>
: transactions.map(renderRow)}
</>
);
}

View File

@@ -3,11 +3,12 @@ import { useStore } from "../../store/AppStore";
import TransactionsTable from "./TransactionsTable";
import { SkippedLines } from "./SkippedLines";
import ImportBar from "./ImportBar";
import type { ImportOptions } from "../../types/api";
import { submitTransactions } from "../../services/api.service";
import { type ImportOptions, type Transaction } from "../../types/api";
import { getDateRangedTransactions, getLatestTransactions, submitTransactions } from "../../services/api.service";
import { useLoader } from "../../hooks/useLoader";
import { FilterPanel, type FilterData } from "./FilterPanel";
import dayjs from "dayjs";
import { ExistingTransactions } from "./ExistingTransactions";
export default function PrepareTransactionsPage() {
@@ -15,6 +16,8 @@ export default function PrepareTransactionsPage() {
const [userFilter, setUserFilter] = useState<FilterData>({});
const [deselected, setDeselected] = useState<number[]>([]);
const [enabledExistingTransactions, setEnabledExistingTransactions] = useState(true);
const [existingTransactions, setExistingTransactions] = useState<Transaction[]>([]);
const select = useCallback((index: number) => setDeselected(deselected.filter(x => x !== index)), [deselected, setDeselected]);
const deselect = useCallback((index: number) => setDeselected([...deselected, index]), [deselected, setDeselected]);
@@ -40,7 +43,9 @@ export default function PrepareTransactionsPage() {
})
, [state.transactions, userFilter]);
const selectedTransactions = useMemo(() => filteredTransactions.filter((_, i) => !deselected.includes(i)), [filteredTransactions, deselected]);
const selectedTransactions = useMemo(() =>
filteredTransactions.filter((_, i) => !deselected.includes(i)),
[filteredTransactions, deselected]);
const { fn: handleSubmit, loading } = useLoader(async (opts: ImportOptions) => {
if(!state.server) {
@@ -60,9 +65,36 @@ export default function PrepareTransactionsPage() {
offset: 1
}
});
}, [selectedTransactions, state]);
}, [selectedTransactions, state]);
useEffect(() => setDeselected([]), [filteredTransactions]);
const { fn: refreshExistingTransactions, loading: loadingExistingTransactions } = useLoader(async () => {
if (!state.server || !enabledExistingTransactions) {
setExistingTransactions([]);
return;
}
// If no "from" filter active, pull the latest 10 transactions
if (!userFilter.from) {
const transactions = await getLatestTransactions(state.server, 10);
setExistingTransactions(transactions);
return;
}
const start = userFilter.from.format("YYYY-MM-DD");
const end = (userFilter.to ?? dayjs()).format("YYYY-MM-DD");
const transactions = await getDateRangedTransactions(state.server, start, end);
setExistingTransactions(transactions);
}, [enabledExistingTransactions, state.server, userFilter]);
useEffect(() => {
setDeselected([])
}, [filteredTransactions]);
useEffect(() => {
refreshExistingTransactions()
}, [enabledExistingTransactions, state.server, userFilter]);
return (
<div className="columns mt-6">
@@ -74,15 +106,28 @@ export default function PrepareTransactionsPage() {
<h3 className="title is-5">Filters</h3>
<FilterPanel value={userFilter} setValue={setUserFilter} />
</div>
<div className="content">
<h3 className="title is-5">Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})</h3>
<TransactionsTable
transactions={filteredTransactions}
deselected={deselected}
deselect={deselect}
select={select} />
</div>
<div className="columns">
<div className="column is-6">
<h3 className="title is-5">Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})</h3>
<TransactionsTable
transactions={filteredTransactions}
deselected={deselected}
deselect={deselect}
select={select} />
</div>
<div className="column is-6">
<h3 className="title is-5">Existing transactions ({existingTransactions.length})&nbsp;
<a
title={enabledExistingTransactions ? "Disable fetching existing transactions" : "Enable fetching existing transactions"}
onClick={() => setEnabledExistingTransactions(!enabledExistingTransactions)}>{enabledExistingTransactions ? "🟢" : "🔴"}</a>
</h3>
<ExistingTransactions
loading={loadingExistingTransactions}
transactions={existingTransactions} />
</div>
</div>
<div className="content">
<h3 className="title is-5">Skipped lines ({state.skipped.length})</h3>
@@ -92,7 +137,6 @@ export default function PrepareTransactionsPage() {
<div className="content">
<ImportBar loading={loading} onSubmit={handleSubmit} onStartOver={() => location.reload()}/>
</div>
</div>
</div>
</div>

View File

@@ -2,8 +2,4 @@
.transaction {
cursor: pointer;
}
.amount {
}
}

View File

@@ -38,4 +38,24 @@ export async function submitTransactions(transactions: Transaction[], server: st
const data = await response.json();
return data as SubmitResponse;
}
export async function getLatestTransactions(server: string, limit: number = 5): Promise<Transaction[]> {
const params = new URLSearchParams();
params.append('limit', limit.toString());
const response = await fetch(`/servers/${server}/transactions?${params.toString()}`);
const data = await response.json();
return data as Transaction[];
}
export async function getDateRangedTransactions(server: string, start: string, end: string): Promise<Transaction[]> {
const params = new URLSearchParams();
params.append('start', start);
params.append('end', end);
const response = await fetch(`/servers/${server}/transactions?${params.toString()}`);
const data = await response.json();
return data as Transaction[];
}