From cc208dd748abc9bfcdb6c5110143f5e3cec48249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Pluta?= Date: Wed, 21 May 2025 22:52:48 +0200 Subject: [PATCH] Add support for existing transactions --- src/backend/index.ts | 66 +++++++++++++ src/runner/index.ts | 23 +++++ src/server/index.ts | 18 +++- web/src/hooks/useLoader.ts | 19 ++-- .../ExistingTransactions.tsx | 54 +++++++++++ .../PrepareTransactionsPage.tsx | 95 ++++++++++++++++--- .../TransactionsTable.module.css | 4 - .../TransactionsTable.tsx | 6 +- web/src/services/api.service.ts | 20 ++++ 9 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 web/src/pages/PrepareTransactionsPage/ExistingTransactions.tsx diff --git a/src/backend/index.ts b/src/backend/index.ts index 1d93a4d..4d983b4 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -186,6 +186,72 @@ export class Actual { return this.#doSubmit(prepared, opts); }); } + + async getTransactions(limit: number): Promise { + 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 { + 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 { diff --git a/src/runner/index.ts b/src/runner/index.ts index 845d2eb..cfa3113 100644 --- a/src/runner/index.ts +++ b/src/runner/index.ts @@ -15,6 +15,29 @@ export type ImportResult = PrepareResult & { result: ActualImportResult; }; +export const getTransactions = async (server: string, config: Config, limit: number = 5): Promise => { + 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 => { + 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 => new Promise((resolve, reject) => { const profileConfig = config.profiles[profile]; diff --git a/src/server/index.ts b/src/server/index.ts index 3a9b787..e6760c9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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"); diff --git a/web/src/hooks/useLoader.ts b/web/src/hooks/useLoader.ts index 1523fbd..63c7444 100644 --- a/web/src/hooks/useLoader.ts +++ b/web/src/hooks/useLoader.ts @@ -7,23 +7,30 @@ export type UseLoaderReturnType = { error?: unknown; }; -export function useLoader(fn: (...args: T) => Promise, deps?: React.DependencyList) { +export function useLoader(fn: (...args: T) => Promise, deps: React.DependencyList = []) { const [loading, setLoading] = useState(false); const [completed, setCompleted] = useState(false); const [error, setError] = useState(undefined); - const callback = useCallback(async (...args: T) => { + const callback = useCallback(async (...args: T): Promise => { + 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(fn: (...args: T) => Promise, de completed, error }; -} \ No newline at end of file +} diff --git a/web/src/pages/PrepareTransactionsPage/ExistingTransactions.tsx b/web/src/pages/PrepareTransactionsPage/ExistingTransactions.tsx new file mode 100644 index 0000000..9377131 --- /dev/null +++ b/web/src/pages/PrepareTransactionsPage/ExistingTransactions.tsx @@ -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) => ( +
0, + 'is-danger': transaction.kind === 'regular' && transaction.amount < 0, + 'is-info': transaction.kind === 'transfer', + })}> +
+ {transactionEmoji(transaction)} {transaction.date} +
+ From: {transaction.from}
+ To: {transaction.to}
+ Title: {transaction.title}
+ Amount: {transaction.amount} +
+ ), []); + + return ( + <> + {loading + ?
+ Loading... +
+ : transactions.map(renderRow)} + + ); +} \ No newline at end of file diff --git a/web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx b/web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx index feaf272..224b2cb 100644 --- a/web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx +++ b/web/src/pages/PrepareTransactionsPage/PrepareTransactionsPage.tsx @@ -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({}); const [deselected, setDeselected] = useState([]); + const [enabledExistingTransactions, setEnabledExistingTransactions] = useState(true); + const [existingTransactions, setExistingTransactions] = useState([]); 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) { @@ -60,9 +67,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 (
@@ -74,15 +108,47 @@ export default function PrepareTransactionsPage() {

Filters

- -
-

Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})

- -
+ +
+ +
+ 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 10 raw transactions will be fetched. Because of transfer consolidation, + the ultimate number of transactions can be lower. +
+
+
+ + +
+
+
+ +
+
+

Considered transactions ({selectedTransactions.length}/{filteredTransactions.length})

+ +
+ + {enabledExistingTransactions && +
+

Existing transactions ({existingTransactions.length})

+ +
} +

Skipped lines ({state.skipped.length})

@@ -92,7 +158,6 @@ export default function PrepareTransactionsPage() {
location.reload()}/>
-
diff --git a/web/src/pages/PrepareTransactionsPage/TransactionsTable.module.css b/web/src/pages/PrepareTransactionsPage/TransactionsTable.module.css index 24dc5e5..db83486 100644 --- a/web/src/pages/PrepareTransactionsPage/TransactionsTable.module.css +++ b/web/src/pages/PrepareTransactionsPage/TransactionsTable.module.css @@ -2,8 +2,4 @@ .transaction { cursor: pointer; } - - .amount { - - } } diff --git a/web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx b/web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx index c170ed5..9f4094f 100644 --- a/web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx +++ b/web/src/pages/PrepareTransactionsPage/TransactionsTable.tsx @@ -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) => (
- {reversedTransactions.map(renderRow)} + {transactions.map(renderRow)}
); } \ No newline at end of file diff --git a/web/src/services/api.service.ts b/web/src/services/api.service.ts index 616b4f2..b9a0ecb 100644 --- a/web/src/services/api.service.ts +++ b/web/src/services/api.service.ts @@ -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 { + 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 { + 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[]; } \ No newline at end of file