Add support for existing transactions
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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})
|
||||
<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>
|
||||
|
||||
@@ -2,8 +2,4 @@
|
||||
.transaction {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.amount {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user