Add support for existing transactions

This commit is contained in:
2025-05-21 22:52:48 +02:00
parent e1dc42c254
commit cc208dd748
9 changed files with 275 additions and 30 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

@@ -1,4 +1,4 @@
import { prepareTransactions, submitTransactions } from "@/runner";
import { getTransactions, getTransactionsFromRange, prepareTransactions, submitTransactions } from "@/runner";
import { Config } from "@/types/config";
import express from "express";
import multer from "multer";
@@ -20,6 +20,22 @@ export function serve(config: Config, port: number) {
});
});
app.get("/servers/:server/transactions", async (req, res) => {
const { start, end } = req.query;
if (start && end) {
const data = await getTransactionsFromRange(req.params.server, config, start.toString(), end.toString());
res.json(data);
}
else {
const limitParam = req.query.limit?.toString();
const limit = (limitParam && Number.parseInt(limitParam)) || undefined;
const data = await getTransactions(req.params.server, config, limit);
res.json(data);
}
});
app.post("/prepare", upload.single("file"), async (req, res) => {
if (!req.file) {
throw new Error("No file to upload");

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);
setCompleted(true);
return value;
} catch(e: unknown) {
setError(e);
throw e;
} finally {
setLoading(false);
}
}, [setLoading, setCompleted, setError, ...(deps ?? [])]);
}, [fn, ...deps]);
return {
fn: callback,

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,13 @@ 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";
import classNames from "classnames";
export default function PrepareTransactionsPage() {
@@ -15,6 +17,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]);
@@ -38,9 +42,12 @@ export default function PrepareTransactionsPage() {
return date.isBefore(userFilter.to, 'day') || date.isSame(userFilter.to, 'day');
})
.slice().reverse()
, [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) {
@@ -62,7 +69,34 @@ export default function PrepareTransactionsPage() {
});
}, [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">
@@ -75,13 +109,45 @@ export default function PrepareTransactionsPage() {
<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 className="field">
<label className="label">Fetch existing transactions</label>
<div className="notification is-info is-light">
If enabled, the existing transactions will be fetched automatically basing on date filter.
If "from" is filled, the existing transactions will be fetched basing on set date (if "to" is not filled, the todays date will be considered).
Otherwise, the latest <abbr title="Transaction before transfer consolidation.">10 raw transactions</abbr> will be fetched. Because of transfer consolidation,
the ultimate number of transactions can be lower.
</div>
<div className="control">
<div className="buttons has-addons">
<button
className={classNames('button', { 'is-selected': enabledExistingTransactions, 'is-success': enabledExistingTransactions})}
onClick={() => setEnabledExistingTransactions(true)}
disabled={enabledExistingTransactions}>I</button>
<button
className={classNames('button', { 'is-selected': !enabledExistingTransactions, 'is-danger': !enabledExistingTransactions})}
onClick={() => setEnabledExistingTransactions(false)}
disabled={!enabledExistingTransactions}>O</button>
</div>
</div>
</div>
<div className="columns">
<div className={classNames('column', { 'is-6': enabledExistingTransactions, 'is-12': !enabledExistingTransactions })}>
<h3 className="title is-5">Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})</h3>
<TransactionsTable
transactions={filteredTransactions}
deselected={deselected}
deselect={deselect}
select={select} />
</div>
{enabledExistingTransactions &&
<div className="column is-6">
<h3 className="title is-5">Existing transactions ({existingTransactions.length})</h3>
<ExistingTransactions
loading={loadingExistingTransactions}
transactions={existingTransactions} />
</div>}
</div>
<div className="content">
@@ -92,7 +158,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

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import type { Transaction } from "../../types/api";
import styles from "./TransactionsTable.module.css";
import classNames from "classnames";
@@ -43,8 +43,6 @@ export default function TransactionsTable({ transactions, deselected, deselect,
}
}, [deselected, deselect, select]);
const reversedTransactions = useMemo(() => transactions.slice().reverse(), [transactions]);
const renderRow = useCallback((transaction: Transaction, index: number) => (
<div
key={index}
@@ -68,7 +66,7 @@ export default function TransactionsTable({ transactions, deselected, deselect,
return (
<div className={styles.transactionsTable}>
{reversedTransactions.map(renderRow)}
{transactions.map(renderRow)}
</div>
);
}

View File

@@ -39,3 +39,23 @@ 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[];
}