Files
firefly-ui/src/lib/components/accounts/Reconcile.svelte
T
hh 617a4700e2 feat: every page follows the one-currency mode
Totals, info boxes, section headers, group subtotals, shares and the
per-currency charts on the dashboard, transactions, search, accounts,
merchants, statement, budgets, categories, tags, groups, piggy banks,
reports and the category, tag, budget and merchant pages sum into one
converted amount when the mode is on; charts split by currency merge
into one lane. Inputs, reconciliation and the Foreign column stay exact.

Money moved to or from a debt, loan or mortgage no longer counts as
income or spending in the transaction list boxes; it shows as From
debts and To debts. The running balance of an incoming foreign-currency
transfer shows in the account's currency.
2026-10-16 18:49:16 +00:00

607 lines
19 KiB
Svelte

<script lang="ts">
import Check from "@lucide/svelte/icons/check";
import { createQuery } from "@tanstack/svelte-query";
import { untrack } from "svelte";
import { api } from "#lib/api/client.js";
import { all, invalidate } from "#lib/api/lists.js";
import type {
Account,
One,
Split,
TransactionGroup,
} from "#lib/api/types.js";
import Amount from "#lib/components/Amount.svelte";
import Box from "#lib/components/Box.svelte";
import { currency, money } from "#lib/components/dashboard/data.js";
import DateInput from "#lib/components/inputs/DateInput.svelte";
import { INPUT } from "#lib/components/inputs/styles.js";
import PageHeader from "#lib/components/shell/PageHeader.svelte";
import { Button } from "#lib/components/ui/button/index.js";
import { addDays } from "#lib/dates.js";
import { formatDate, toNumber } from "#lib/format.js";
import { prettyKeys, useKeys } from "#lib/keys.svelte.js";
import { ListNav } from "#lib/list-nav.svelte.js";
import {
type Group,
groupTitle,
signedAmount,
signedForeign,
} from "#lib/transactions/split.js";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { indexUrl, showUrl, TYPE_TITLE } from "./links.js";
const MARGIN_DAYS = 3;
const CENT = 0.005;
interface Row {
amount: number;
cleared: boolean;
date: string;
group: Group;
inRange: boolean;
key: string;
split: Split;
}
let { id, start, end }: { id: string; start: string; end: string } = $props();
let startInput = $state(untrack(() => start));
let endInput = $state(untrack(() => end));
let startText = $state("");
let endText = $state("");
let startTouched = $state(false);
let endTouched = $state(false);
let correct = $state(false);
let busy = $state(false);
let status = $state("");
let failure = $state("");
let pressed = $state(false);
const account = createQuery(() => ({
queryFn: () => api<One<Account>>(`/v1/accounts/${id}`),
queryKey: ["account", id, "reconcile"],
}));
const a = $derived(account.data?.data.attributes);
const cur = $derived(currency(a?.currency_code ?? ""));
const fmt = $derived(money(cur));
const balanceAt = (date: string) => async () => {
const res = await api<One<Account>>(`/v1/accounts/${id}`, {
query: { date },
});
return toNumber(res.data.attributes.current_balance);
};
const before = createQuery(() => ({
queryFn: balanceAt(addDays(start, -1)),
queryKey: ["reconcile-balance", id, addDays(start, -1)],
staleTime: 0,
}));
const after = createQuery(() => ({
queryFn: balanceAt(end),
queryKey: ["reconcile-balance", id, end],
staleTime: 0,
}));
const list = createQuery(() => ({
queryFn: () =>
all<TransactionGroup>(`/v1/accounts/${id}/transactions`, {
end: addDays(end, MARGIN_DAYS),
start: addDays(start, -MARGIN_DAYS),
}),
queryKey: ["reconcile-list", id, start, end],
staleTime: 0,
}));
function net(split: Split): number {
const code = a?.currency_code;
if (split.currency_code !== code && split.foreign_currency_code === code) {
return signedForeign(split, id)?.amount ?? 0;
}
return signedAmount(split, id).amount;
}
const rows = $derived<Row[]>(
(list.data ?? [])
.flatMap((group) =>
group.attributes.transactions
.filter((s) => s.source_id === id || s.destination_id === id)
.map((split) => {
const date = split.date.slice(0, 10);
return {
amount: net(split),
cleared: split.reconciled === true,
date,
group,
inRange: date >= start && date <= end,
key: split.transaction_journal_id ?? "",
split,
};
})
)
.sort((x, y) => y.split.date.localeCompare(x.split.date))
);
const open = $derived(rows.filter((r) => r.inRange && !r.cleared));
const later = $derived(rows.filter((r) => r.date > end));
const inside = $derived(rows.filter((r) => r.inRange));
const earlier = $derived(rows.filter((r) => r.date < start));
const nav = new ListNav<Row>({
extra: () => [
{
group: "Reconcile",
input: true,
keys: "mod+Enter",
label: "Store reconciliation",
run: () => store(),
},
],
id: "reconcile",
items: () => open,
key: (row) => row.key,
selectable: true,
});
nav.use();
useKeys(() => [
{
group: "Reconcile",
keys: "c",
label: "Toggle the correction",
run: () => {
correct = canCorrect && !correct;
},
},
]);
const startBalance = $derived(
startTouched ? toNumber(startText) : (before.data ?? 0)
);
const endBalance = $derived(
endTouched ? toNumber(endText) : (after.data ?? 0)
);
const selected = $derived(open.filter((r) => nav.isSelected(r)));
const selectedSum = $derived(selected.reduce((s, r) => s + r.amount, 0));
const cleared = $derived(inside.filter((r) => r.cleared));
const clearedSum = $derived(cleared.reduce((s, r) => s + r.amount, 0));
const sum = $derived(startBalance + selectedSum + clearedSum);
const difference = $derived.by(() => {
const value = sum - endBalance;
return Math.abs(value) < CENT ? 0 : value;
});
const reconciliationAccounts = createQuery(() => ({
queryFn: () => all<Account>("/v1/accounts", { type: "reconciliation" }),
queryKey: ["accounts", "reconciliation"],
}));
const reconciliationId = $derived(
reconciliationAccounts.data?.find(
(r) => r.attributes.name === `${a?.name} reconciliation (${cur.code})`
)?.id
);
const canCorrect = $derived(Boolean(reconciliationId));
const correcting = $derived(correct && canCorrect && difference !== 0);
const ready = $derived(before.isSuccess && after.isSuccess && list.isSuccess);
const hint = $derived.by(() => {
if (!pressed) {
return "";
}
if (!ready) {
return "Balances are still loading";
}
return selected.length === 0 && !correcting
? "Tick the transactions to clear first"
: "";
});
function setDates(nextStart: string, nextEnd: string) {
if (!(nextStart && nextEnd) || nextStart > nextEnd) {
return;
}
startTouched = false;
endTouched = false;
nav.clear();
goto(`/accounts/reconcile/${id}/index/${nextStart}/${nextEnd}`, {
replace: true,
});
}
async function clear(group: Group, journals: Set<string>) {
let splits = group.attributes.transactions;
let title = group.attributes.group_title;
if (title) {
const full = await api<One<TransactionGroup>>(
`/v1/transactions/${group.id}`
);
splits = full.data.attributes.transactions;
title = full.data.attributes.group_title || groupTitle(full.data);
}
const body: Record<string, unknown> = {
transactions: splits.map((s) =>
journals.has(s.transaction_journal_id ?? "")
? {
reconciled: true,
transaction_journal_id: s.transaction_journal_id,
}
: { transaction_journal_id: s.transaction_journal_id }
),
};
if (splits.length > 1) {
body.group_title = title;
}
await api(`/v1/transactions/${group.id}`, { body, method: "PUT" });
}
function correction(other: string) {
const sides =
difference > 0
? { destination_id: other, source_id: id }
: { destination_id: id, source_id: other };
return {
apply_rules: false,
fire_webhooks: false,
transactions: [
{
amount: Math.abs(difference).toFixed(cur.decimals),
currency_code: cur.code,
date: end,
description: `Reconciliation (${formatDate(start)} to ${formatDate(end)})`,
reconciled: true,
type: "reconciliation",
...sides,
},
],
};
}
async function store() {
pressed = true;
if (busy || hint) {
return;
}
busy = true;
failure = "";
const byGroup = new Map<string, { group: Group; journals: Set<string> }>();
for (const row of selected) {
const entry = byGroup.get(row.group.id) ?? {
group: row.group,
journals: new Set<string>(),
};
entry.journals.add(row.key);
byGroup.set(row.group.id, entry);
}
try {
let done = 0;
status = `Clearing ${selected.length} transactions`;
await Promise.all(
[...byGroup.values()].map(async ({ group, journals }) => {
await clear(group, journals);
done += 1;
status = `Cleared ${done} of ${byGroup.size}`;
})
);
if (correcting) {
status = "Booking the correction";
await api("/v1/transactions", {
body: correction(reconciliationId ?? ""),
method: "POST",
});
}
status = "Reconciliation stored";
await invalidate();
await goto(showUrl(id));
} catch (e) {
failure = e instanceof Error ? e.message : String(e);
status = "";
await list.refetch();
} finally {
busy = false;
}
}
function rowClass(row: Row) {
const i = open.indexOf(row);
if (i >= 0 && nav.cursor === i) {
return "bg-row-cursor shadow-[inset_2px_0_0_var(--ff-brand)]";
}
if (nav.isSelected(row)) {
return "bg-row-selected";
}
return row.inRange ? "hover:bg-row-hover" : "text-cladd-fg-softer";
}
</script>
{#snippet marker(
text: string
)}
<tr class="h-7 border-cladd-outline border-t bg-cladd-surface-cut">
<td class="px-3 font-semibold text-[12px] text-cladd-fg-soft" colspan="6">
{text}
</td>
</tr>
{/snippet}
{#snippet line(
row: Row
)}
{@const i = open.indexOf(row)}
<tr
class="h-7 border-cladd-outline/60 border-t {rowClass(row)}"
id={i >= 0 ? nav.rowId(i) : undefined}
onclick={(event) => i >= 0 && nav.click(i, event)}
>
<td class="w-9 px-3">
{#if row.cleared}
<Check aria-label="Already reconciled" class="size-3.5 text-income" />
{:else if row.inRange}
<input
aria-label="Reconcile {row.split.description}"
checked={nav.isSelected(row)}
class="size-4 accent-[var(--ff-brand)]"
onchange={() => nav.toggle(row)}
onclick={(event) => event.stopPropagation()}
type="checkbox"
>
{/if}
</td>
<td class="num w-28 whitespace-nowrap px-3 text-cladd-fg-soft">
{formatDate(row.date)}
</td>
<td class="max-w-0 truncate px-3">
<a class="hover:underline" href="/transactions/show/{row.group.id}"
>{row.split.description}</a
>
</td>
<td class="whitespace-nowrap px-3 text-right">
<Amount amount={row.amount} exact {...cur} />
</td>
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
{row.split.source_name}
</td>
<td class="hidden max-w-0 truncate px-3 text-cladd-fg-soft md:table-cell">
{row.split.destination_name}
</td>
</tr>
{/snippet}
<PageHeader
crumbs={[
{ href: indexUrl("asset"), label: TYPE_TITLE.asset },
{ href: showUrl(id), label: a?.name ?? "" },
{ label: "Reconcile" },
]}
title="Reconcile {a?.name ?? "account"}"
/>
{#if a && a.type !== "asset"}
<p class="text-expense">
You can only reconcile asset accounts.
<a class="text-brand dark:text-(--ff-series-1) underline" href={showUrl(id)}
>Back to {a.name}</a
>
</p>
{:else}
<div class="grid gap-3 lg:grid-cols-[minmax(0,1fr)_25rem]">
<Box body="flex flex-col p-0" title="Reconciliation range">
<table class="w-full text-[13px]">
<thead class="border-cladd-outline border-b text-left">
<tr class="h-8 text-[12px] text-cladd-fg-soft">
<th class="px-3 font-semibold" scope="col"></th>
<th class="px-3 font-semibold" scope="col">Date</th>
<th class="px-3 text-right font-semibold" scope="col">Balance</th>
</tr>
</thead>
<tbody>
<tr class="h-11">
<th class="px-3 text-left font-semibold" scope="row">
Start balance
</th>
<td class="px-3">
<DateInput aria-label="Start date" bind:value={startInput} />
</td>
<td class="px-3">
<input
aria-label="Start balance"
class="{INPUT} num text-right"
inputmode="decimal"
oninput={(event) => {
startTouched = true;
startText = event.currentTarget.value;
}}
value={startTouched
? startText
: (before.data?.toFixed(cur.decimals) ?? "")}
>
</td>
</tr>
<tr class="h-11 border-cladd-outline/60 border-t">
<th class="px-3 text-left font-semibold" scope="row">
End balance
</th>
<td class="px-3">
<DateInput aria-label="End date" bind:value={endInput} />
</td>
<td class="px-3">
<input
aria-label="End balance"
class="{INPUT} num text-right"
inputmode="decimal"
oninput={(event) => {
endTouched = true;
endText = event.currentTarget.value;
}}
value={endTouched
? endText
: (after.data?.toFixed(cur.decimals) ?? "")}
>
</td>
</tr>
</tbody>
</table>
{#if startInput !== start || endInput !== end}
<div
class="flex items-center gap-3 border-cladd-outline border-t px-3 py-2"
>
<Button onclick={() => setDates(startInput, endInput)} size="sm"
>Update selection</Button
>
<span class="text-[12px] text-warn"
>Changing the range clears the ticks.</span
>
</div>
{/if}
<p
class="mt-auto border-cladd-outline border-t px-3 py-2 text-[12px] text-cladd-fg-soft"
>
Match the dates and balances to your bank statement, then tick the
transactions that appear on it.
<span class="kbd">x</span>
ticks the row under the cursor,
<span class="kbd">*</span>
ticks all.
</p>
</Box>
<Box body="flex flex-col gap-3 p-3" title="Reconciliation options">
<div class="flex items-baseline justify-between gap-2">
<span class="text-cladd-fg-soft">Difference</span>
<span
class="num font-semibold text-[20px] {difference === 0
? "text-income"
: "text-expense"}"
>{fmt(difference)}</span
>
</div>
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-0.5 text-[12px]">
<dt class="text-cladd-fg-soft">Submitted start balance</dt>
<dd class="num text-right">{fmt(startBalance)}</dd>
<dt class="text-cladd-fg-soft">
Selected transactions ({selected.length})
</dt>
<dd class="num text-right">{fmt(selectedSum)}</dd>
<dt class="text-cladd-fg-soft">
Already cleared transactions ({cleared.length})
</dt>
<dd class="num text-right">{fmt(clearedSum)}</dd>
<dt class="text-cladd-fg-soft">Sum of reconciliation</dt>
<dd class="num text-right">{fmt(sum)}</dd>
<dt class="text-cladd-fg-soft">Submitted end balance</dt>
<dd class="num text-right">{fmt(endBalance)}</dd>
</dl>
{#if difference !== 0}
<p class="text-[12px] text-cladd-fg-soft">
Your Firefly III ledger has {difference > 0 ? "more" : "less"} money
in it than your bank says.
</p>
<fieldset class="m-0 flex flex-col gap-1.5 border-0 p-0 text-[12px]">
<legend class="sr-only">What to do with the difference</legend>
<label class="flex items-start gap-2">
<input
class="mt-0.5 accent-[var(--ff-brand)]"
name="correct"
type="radio"
value={false}
bind:group={correct}
>
Clear the selected transactions, but do not correct.
</label>
<label
class="flex items-start gap-2 {canCorrect
? ""
: "text-cladd-fg-softer"}"
>
<input
class="mt-0.5 accent-[var(--ff-brand)]"
disabled={!canCorrect}
name="correct"
type="radio"
value={true}
bind:group={correct}
>
Clear the selected transactions, and create a correction
{difference > 0 ? "removing" : "adding"}
{fmt(Math.abs(difference))}
{difference > 0 ? "from" : "to"}
this asset account.
<span class="kbd">c</span>
</label>
{#if !canCorrect && reconciliationAccounts.isSuccess}
<p class="text-cladd-fg-soft">
The first correction for this account has to be booked in
<a
class="text-brand dark:text-(--ff-series-1) underline"
data-sveltekit-reload
href="{page.url.pathname}?ffui=old"
>Firefly's own reconcile page</a
>: through the API, Firefly III cannot create the account that
holds corrections.
</p>
{/if}
</fieldset>
{/if}
<div class="flex flex-wrap items-center gap-2">
<Button disabled={busy} onclick={store} type="button"
>Store reconciliation</Button
>
<span class="flex gap-1">
{#each prettyKeys("mod+Enter") as key (key)}
<span class="kbd">{key}</span>
{/each}
</span>
{#if hint}
<span class="text-[12px] text-expense" role="alert">{hint}</span>
{/if}
</div>
{#if status}
<p class="text-[12px] text-cladd-fg-soft" role="status">{status}</p>
{/if}
{#if failure}
<p class="text-[12px] text-expense" role="alert">{failure}</p>
{/if}
</Box>
</div>
<Box body="p-0" class="mt-3" title="Transactions">
{#snippet tools()}
<span class="num text-[12px] text-cladd-fg-soft"
>{open.length}
to check, {cleared.length} already cleared</span
>
{/snippet}
{#if list.isPending}
<div class="h-40"></div>
{:else if list.isError}
<p class="p-3 text-expense">{list.error.message}</p>
{:else}
<table class="w-full table-fixed border-collapse text-[13px]">
<colgroup>
<col class="w-9">
<col class="w-28">
<col>
<col class="w-36">
<col class="hidden md:table-column md:w-48">
<col class="hidden md:table-column md:w-48">
</colgroup>
<tbody>
{#each later as row (row.key)}
{@render line(row)}
{/each}
{@render marker(`End of reconcile period: ${formatDate(end)}`)}
{#each inside as row (row.key)}
{@render line(row)}
{/each}
{#if inside.length === 0}
<tr>
<td class="px-3 py-4 text-center text-cladd-fg-soft" colspan="6">
No transactions in this range.
</td>
</tr>
{/if}
{@render marker(`Start of reconcile period: ${formatDate(start)}`)}
{#each earlier as row (row.key)}
{@render line(row)}
{/each}
</tbody>
</table>
{/if}
</Box>
{/if}