Add support for existing transactions
This commit is contained in:
@@ -186,6 +186,72 @@ export class Actual {
|
|||||||
return this.#doSubmit(prepared, opts);
|
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> {
|
async function sleep(ms: number): Promise<void> {
|
||||||
|
|||||||
@@ -15,6 +15,29 @@ export type ImportResult = PrepareResult & {
|
|||||||
result: ActualImportResult;
|
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) => {
|
export const prepareTransactions = async (stream: Readable, profile: string, server: string, config: Config): Promise<PrepareResult> => new Promise((resolve, reject) => {
|
||||||
const profileConfig = config.profiles[profile];
|
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 { Config } from "@/types/config";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import multer from "multer";
|
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) => {
|
app.post("/prepare", upload.single("file"), async (req, res) => {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
throw new Error("No file to upload");
|
throw new Error("No file to upload");
|
||||||
|
|||||||
@@ -7,23 +7,30 @@ export type UseLoaderReturnType<T extends any[], R> = {
|
|||||||
error?: unknown;
|
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 [loading, setLoading] = useState(false);
|
||||||
const [completed, setCompleted] = useState(false);
|
const [completed, setCompleted] = useState(false);
|
||||||
const [error, setError] = useState<unknown|undefined>(undefined);
|
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 {
|
try {
|
||||||
setLoading(true);
|
const value = await fn(...args);
|
||||||
const value = await fn(...args);
|
|
||||||
setCompleted(true);
|
setCompleted(true);
|
||||||
return value;
|
return value;
|
||||||
} catch(e: unknown) {
|
} catch(e: unknown) {
|
||||||
setError(e);
|
setError(e);
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [setLoading, setCompleted, setError, ...(deps ?? [])]);
|
}, [fn, ...deps]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fn: callback,
|
fn: callback,
|
||||||
@@ -31,4 +38,4 @@ export function useLoader<T extends any[], R>(fn: (...args: T) => Promise<R>, de
|
|||||||
completed,
|
completed,
|
||||||
error
|
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,13 @@ import { useStore } from "../../store/AppStore";
|
|||||||
import TransactionsTable from "./TransactionsTable";
|
import TransactionsTable from "./TransactionsTable";
|
||||||
import { SkippedLines } from "./SkippedLines";
|
import { SkippedLines } from "./SkippedLines";
|
||||||
import ImportBar from "./ImportBar";
|
import ImportBar from "./ImportBar";
|
||||||
import type { ImportOptions } from "../../types/api";
|
import { type ImportOptions, type Transaction } from "../../types/api";
|
||||||
import { submitTransactions } from "../../services/api.service";
|
import { getDateRangedTransactions, getLatestTransactions, submitTransactions } from "../../services/api.service";
|
||||||
import { useLoader } from "../../hooks/useLoader";
|
import { useLoader } from "../../hooks/useLoader";
|
||||||
import { FilterPanel, type FilterData } from "./FilterPanel";
|
import { FilterPanel, type FilterData } from "./FilterPanel";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { ExistingTransactions } from "./ExistingTransactions";
|
||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
|
|
||||||
export default function PrepareTransactionsPage() {
|
export default function PrepareTransactionsPage() {
|
||||||
@@ -15,6 +17,8 @@ export default function PrepareTransactionsPage() {
|
|||||||
|
|
||||||
const [userFilter, setUserFilter] = useState<FilterData>({});
|
const [userFilter, setUserFilter] = useState<FilterData>({});
|
||||||
const [deselected, setDeselected] = useState<number[]>([]);
|
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 select = useCallback((index: number) => setDeselected(deselected.filter(x => x !== index)), [deselected, setDeselected]);
|
||||||
const deselect = useCallback((index: number) => setDeselected([...deselected, 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');
|
return date.isBefore(userFilter.to, 'day') || date.isSame(userFilter.to, 'day');
|
||||||
})
|
})
|
||||||
|
.slice().reverse()
|
||||||
, [state.transactions, userFilter]);
|
, [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) => {
|
const { fn: handleSubmit, loading } = useLoader(async (opts: ImportOptions) => {
|
||||||
if(!state.server) {
|
if(!state.server) {
|
||||||
@@ -60,9 +67,36 @@ export default function PrepareTransactionsPage() {
|
|||||||
offset: 1
|
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 (
|
return (
|
||||||
<div className="columns mt-6">
|
<div className="columns mt-6">
|
||||||
@@ -74,15 +108,47 @@ export default function PrepareTransactionsPage() {
|
|||||||
<h3 className="title is-5">Filters</h3>
|
<h3 className="title is-5">Filters</h3>
|
||||||
<FilterPanel value={userFilter} setValue={setUserFilter} />
|
<FilterPanel value={userFilter} setValue={setUserFilter} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content">
|
<div className="field">
|
||||||
<h3 className="title is-5">Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})</h3>
|
<label className="label">Fetch existing transactions</label>
|
||||||
<TransactionsTable
|
<div className="notification is-info is-light">
|
||||||
transactions={filteredTransactions}
|
If enabled, the existing transactions will be fetched automatically basing on date filter.
|
||||||
deselected={deselected}
|
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).
|
||||||
deselect={deselect}
|
Otherwise, the latest <abbr title="Transaction before transfer consolidation.">10 raw transactions</abbr> will be fetched. Because of transfer consolidation,
|
||||||
select={select} />
|
the ultimate number of transactions can be lower.
|
||||||
</div>
|
</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">
|
<div className="content">
|
||||||
<h3 className="title is-5">Skipped lines ({state.skipped.length})</h3>
|
<h3 className="title is-5">Skipped lines ({state.skipped.length})</h3>
|
||||||
@@ -92,7 +158,6 @@ export default function PrepareTransactionsPage() {
|
|||||||
<div className="content">
|
<div className="content">
|
||||||
<ImportBar loading={loading} onSubmit={handleSubmit} onStartOver={() => location.reload()}/>
|
<ImportBar loading={loading} onSubmit={handleSubmit} onStartOver={() => location.reload()}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,4 @@
|
|||||||
.transaction {
|
.transaction {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback } from "react";
|
||||||
import type { Transaction } from "../../types/api";
|
import type { Transaction } from "../../types/api";
|
||||||
import styles from "./TransactionsTable.module.css";
|
import styles from "./TransactionsTable.module.css";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -43,8 +43,6 @@ export default function TransactionsTable({ transactions, deselected, deselect,
|
|||||||
}
|
}
|
||||||
}, [deselected, deselect, select]);
|
}, [deselected, deselect, select]);
|
||||||
|
|
||||||
const reversedTransactions = useMemo(() => transactions.slice().reverse(), [transactions]);
|
|
||||||
|
|
||||||
const renderRow = useCallback((transaction: Transaction, index: number) => (
|
const renderRow = useCallback((transaction: Transaction, index: number) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
@@ -68,7 +66,7 @@ export default function TransactionsTable({ transactions, deselected, deselect,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.transactionsTable}>
|
<div className={styles.transactionsTable}>
|
||||||
{reversedTransactions.map(renderRow)}
|
{transactions.map(renderRow)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,4 +38,24 @@ export async function submitTransactions(transactions: Transaction[], server: st
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data as SubmitResponse;
|
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