607 lines
19 KiB
Svelte
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} {...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}
|