Add per-account historical bank imports without resetting sync cursors
This commit is contained in:
+209
-6
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user