Add per-account historical bank imports without resetting sync cursors

This commit is contained in:
Lars Nolden
2026-09-10 17:02:17 +02:00
parent f4c7d54575
commit 2259db3e85
7 changed files with 561 additions and 8 deletions
+209 -6
View File
@@ -17,15 +17,41 @@ interface Balance {
currency: string;
type: string;
}
export function Accounts({
state,
mutate,
acceptState,
}: {
interface AccountsProps {
state: State;
mutate: Mutate;
acceptState: (state: State, message?: string) => void;
}) {
}
export function Accounts(props: AccountsProps) {
const [backfilling, setBackfilling] = useState<Account | null>(null);
return (
<>
<AccountsContent
key={props.state.revision}
{...props}
backfill={setBackfilling}
/>
{backfilling && (
<BackfillHistory
account={backfilling}
state={props.state}
acceptState={props.acceptState}
close={() => setBackfilling(null)}
/>
)}
</>
);
}
// Ordinary account forms reset on a new revision; the backfill dialog must
// survive its own imports and error recovery to retain its range and result.
function AccountsContent({
state,
mutate,
acceptState,
backfill,
}: AccountsProps & { backfill: (account: Account) => void }) {
const [editing, setEditing] = useState<Account | null>(null);
const [deleting, setDeleting] = useState<Account | null>(null);
const [error, setError] = useState("");
@@ -62,6 +88,7 @@ export function Accounts({
state={state}
edit={() => setEditing(account)}
remove={() => setDeleting(account)}
backfill={() => backfill(account)}
onError={setError}
/>
))}
@@ -172,17 +199,47 @@ async function authorize(
throw new Error("Bank authorization returned an unsafe redirect URL.");
window.location.assign(url.href);
}
function backfillUnavailable(account: Account, state: State): string {
if (!state.status.banking_configured)
return "Configure Enable Banking in Settings before importing older history.";
const connection = state.connections.find((c) => c.account_id === account.id);
if (connection?.status === "reconnect_required")
return "Reconnect this account before importing older history.";
if (
!account.id ||
!account.external_account_id ||
!connection ||
(connection.status !== "connected" && connection.status !== "error")
)
return "Connect this account to your bank before importing older history.";
for (let i = state.sessions.length - 1; i >= 0; i--) {
const session = state.sessions[i];
if (
session.accounts.some(
(linked) =>
linked.id === account.id &&
linked.external_account_id === account.external_account_id,
)
)
return session.session_id && Date.parse(session.valid_until) > Date.now()
? ""
: "Reconnect this account to renew its saved bank authorization.";
}
return "Reconnect this account to save a matching bank connection.";
}
function AccountCard({
account,
state,
edit,
remove,
backfill,
onError,
}: {
account: Account;
state: State;
edit: () => void;
remove: () => void;
backfill: () => void;
onError: (error: string) => void;
}) {
const [balances, setBalances] = useState<Balance[] | null>(null);
@@ -191,6 +248,7 @@ function AccountCard({
const connection = state.connections.find((c) => c.account_id === account.id);
const institution = connection?.institution || account.institution;
const needsReconnect = connection?.status === "reconnect_required";
const backfillReason = backfillUnavailable(account, state);
return (
<section className="panel account-card">
<div className="account-card-heading">
@@ -255,6 +313,16 @@ function AccountCard({
{connecting ? "Opening bank…" : `Reconnect ${institution}`}
</button>
)}
<button
className="button secondary"
disabled={connecting || !!backfillReason}
title={backfillReason || undefined}
onClick={backfill}
>
<Upload size={14} />
Import older history
</button>
{backfillReason && <small>{backfillReason}</small>}
</div>
<div className="balance-area">
{balances ? (
@@ -329,6 +397,141 @@ function AccountCard({
</section>
);
}
function BackfillHistory({
account,
state,
acceptState,
close,
}: {
account: Account;
state: State;
acceptState: (state: State, message?: string) => void;
close: () => void;
}) {
const [historyMonths, setHistoryMonths] = useState("12");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const submitting = useRef(false);
const currentAccount = state.data.accounts.find((a) => a.id === account.id);
const unavailable = currentAccount
? backfillUnavailable(currentAccount, state)
: "This account is no longer saved. Close this dialog and choose an account.";
const closeWhenIdle = () => {
if (!submitting.current) close();
};
return (
<Modal title="Import older history" close={closeWhenIdle}>
<form
aria-busy={busy}
onSubmit={async (e) => {
e.preventDefault();
if (submitting.current || !e.currentTarget.reportValidity()) return;
const months = Number(historyMonths);
if (!Number.isInteger(months) || months < 1 || months > 120) {
setError("Choose a whole number of months from 1 to 120.");
return;
}
if (unavailable) {
setError(unavailable);
return;
}
submitting.current = true;
setBusy(true);
setError("");
setSuccess("");
try {
const response = await request<{ imported: number; state: State }>(
"/api/backfill",
{
revision: state.revision,
account_id: account.id,
history_months: months,
},
);
const message =
response.imported === 0
? `No new transactions imported for ${account.display_name}. Existing transactions were not duplicated.`
: `Imported ${response.imported} new transactions for ${account.display_name}. Existing transactions were not duplicated.`;
acceptState(response.state, message);
setSuccess(message);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message);
try {
acceptState(await request<State>("/api/state"));
setError(
`${message} The journal has been refreshed in case any records were saved. Review it before trying again; no import was automatically retried.`,
);
} catch (refreshError) {
setError(
`${message} Could not refresh the journal: ${refreshError instanceof Error ? refreshError.message : String(refreshError)} Reload the page to check for saved records before trying again. No import was automatically retried.`,
);
}
} finally {
submitting.current = false;
setBusy(false);
}
}}
>
<div className="form-body">
<p>
Import bank transactions for <strong>{account.display_name}</strong>{" "}
({account.institution} · {account.currency}).
</p>
<ErrorMessage error={error} />
{success && <p role="status">{success}</p>}
<Field
label="Months back"
hint="Request history from this many calendar months ago through today. Your bank may provide less history."
>
<input
autoFocus
type="number"
required
min={1}
max={120}
step={1}
disabled={busy}
value={historyMonths}
onChange={(e) => setHistoryMonths(e.target.value)}
/>
</Field>
<p className="muted small">
Repeating or overlapping a date range skips transactions already
imported. Normal sync and its cursor stay unchanged, as does the
initial-history setting. Inactive connected accounts can also import
older history.
</p>
{unavailable && <p className="muted small">{unavailable}</p>}
{busy && (
<p className="muted small" role="status">
Importing older history Keep this dialog open while the bank
request and journal update finish.
</p>
)}
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={closeWhenIdle}
disabled={busy}
>
{success ? "Close" : "Cancel"}
</button>
<button
type="submit"
className="button primary"
disabled={busy || !!unavailable}
>
{busy ? "Importing history…" : "Import older history"}
</button>
</div>
</form>
</Modal>
);
}
function ImportForm({
state,
acceptState,
+1 -2
View File
@@ -162,7 +162,7 @@ function App() {
</span>
<span>
finance<span className="brand-light">duck</span>
<small>YOUR MONEY, CLEARLY</small>
<small>YOUR MONEY</small>
</span>
</a>
<span className="nav-label">WORKSPACE</span>
@@ -355,7 +355,6 @@ function App() {
)}
{page === "accounts" && (
<Accounts
key={state.revision}
state={state}
mutate={mutate}
acceptState={acceptState}