Analyse now classifies up to ten same-kind transactions per provider request: the registry and history travel once per batch, so a thousand-row backfill costs about a hundred paced requests instead of a thousand. The answer schema appears once — an array item carrying an enum-bound ref — because providers meter strict schemas by token cost: duplicating registry enums per row, or bounding arrays with minItems/maxItems that Gemini expands per element, rejects real registries with a bare HTTP 400. Row count, duplicate refs, duplicate tags and taxonomy bounds are all enforced server-side instead, and a request still rejected outright halves until accepted, remembering the working size for the run. Batch requests scale the HTTP budget by row count, chunk failures cannot abort a run whose later rows succeeded, and rows resolved against one snapshot share one minted merchant. Measured on a real 165-row month over a zero-data-retention route: 165 analysed, 152 proposals, 0 errors, 17 requests, under 8 minutes. Fresh installs default to google/gemini-3.8-flash, the model that demonstrably honors strict structured outputs over a ZDR route. Preview changes now carry counterparty, amount and currency, and the review list shows the amount with a counterparty fallback for banks that leave descriptions empty.
633 lines
22 KiB
TypeScript
633 lines
22 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
ShieldCheck,
|
|
Database,
|
|
RefreshCw,
|
|
Save,
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
} from "lucide-react";
|
|
import type { State } from "./api";
|
|
import { APIError } from "./api";
|
|
import { ErrorMessage, Field, Modal, ModelOptions } from "./ui";
|
|
import type { Mutate } from "./ui";
|
|
export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
|
const [model, setModel] = useState(state.settings.model);
|
|
const [privateNames, setPrivateNames] = useState(
|
|
state.settings.private_names.join("; "),
|
|
);
|
|
const [classifyOnImport, setClassifyOnImport] = useState(
|
|
state.settings.classify_on_import,
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [rebuild, setRebuild] = useState(false);
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [keyAction, setKeyAction] = useState<"save" | "remove" | null>(null);
|
|
const [bankingAppID, setBankingAppID] = useState(state.banking_app_id);
|
|
const [bankingAction, setBankingAction] = useState<"save" | "remove" | null>(
|
|
null,
|
|
);
|
|
const [removeBanking, setRemoveBanking] = useState(false);
|
|
const [callbackCopied, setCallbackCopied] = useState(false);
|
|
const privateKeyInput = useRef<HTMLInputElement>(null);
|
|
const callbackURL = `${window.location.origin}/api/banking/callback`;
|
|
const needsPrivateKey =
|
|
!state.status.banking_configured ||
|
|
bankingAppID.trim() !== state.banking_app_id;
|
|
const credentialsBusy = keyAction !== null || bankingAction !== null;
|
|
useEffect(() => {
|
|
setBankingAppID(state.banking_app_id);
|
|
}, [state.banking_app_id]);
|
|
const updateOpenRouterKey = async (key: string) => {
|
|
if (busy || credentialsBusy) return;
|
|
setKeyAction(key ? "save" : "remove");
|
|
setError("");
|
|
try {
|
|
await mutate(
|
|
"/api/settings/openrouter",
|
|
{ api_key: key },
|
|
key ? "OpenRouter key saved" : "OpenRouter key removed",
|
|
);
|
|
setApiKey("");
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof Error
|
|
? err.message
|
|
: "Could not update the OpenRouter key.",
|
|
);
|
|
} finally {
|
|
setKeyAction(null);
|
|
}
|
|
};
|
|
const saveBankingSettings = async () => {
|
|
if (busy || credentialsBusy) return;
|
|
setError("");
|
|
const appID = bankingAppID.trim();
|
|
const file = privateKeyInput.current?.files?.[0];
|
|
if (!appID) {
|
|
setError("Enter the Enable Banking application ID.");
|
|
return;
|
|
}
|
|
if (needsPrivateKey && !file) {
|
|
setError("Upload a private key for this Enable Banking application.");
|
|
return;
|
|
}
|
|
if (file && (file.size === 0 || file.size > 32 * 1024)) {
|
|
setError(
|
|
"Upload a non-empty PEM private key file no larger than 32 KiB.",
|
|
);
|
|
return;
|
|
}
|
|
setBankingAction("save");
|
|
try {
|
|
await mutate(
|
|
"/api/settings/enablebanking",
|
|
{
|
|
app_id: appID,
|
|
redirect_url: callbackURL,
|
|
private_key: file ? await file.text() : null,
|
|
},
|
|
"Enable Banking configuration saved",
|
|
);
|
|
setBankingAppID(appID);
|
|
if (privateKeyInput.current) privateKeyInput.current.value = "";
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof APIError
|
|
? err.message
|
|
: "Could not save Enable Banking configuration. Check the application ID, RSA PEM key and callback URL, then try again.",
|
|
);
|
|
} finally {
|
|
setBankingAction(null);
|
|
}
|
|
};
|
|
return (
|
|
<>
|
|
<div className="section-heading">
|
|
<div>
|
|
<h2>Settings & privacy</h2>
|
|
<p>Your data, your infrastructure, your choices.</p>
|
|
</div>
|
|
<span className="badge">
|
|
<ShieldCheck size={14} />
|
|
Self-hosted
|
|
</span>
|
|
</div>
|
|
<ErrorMessage error={error} />
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>OpenRouter credentials</h3>
|
|
<p>Manage the API key used for AI classification.</p>
|
|
</div>
|
|
<span className="badge" role="status">
|
|
{state.status.ai_configured ? (
|
|
<CheckCircle2 size={14} />
|
|
) : (
|
|
<AlertCircle size={14} />
|
|
)}
|
|
{state.status.ai_configured ? "Configured" : "Not configured"}
|
|
</span>
|
|
</div>
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
const key = apiKey.trim();
|
|
if (!key) return;
|
|
await updateOpenRouterKey(key);
|
|
}}
|
|
>
|
|
<div className="form-body">
|
|
<Field
|
|
label="OpenRouter API key"
|
|
hint={
|
|
state.status.ai_configured
|
|
? "The current key is never displayed. Enter a new key to replace it."
|
|
: "Enter your OpenRouter API key to enable AI classification."
|
|
}
|
|
>
|
|
<input
|
|
type="password"
|
|
name="openrouter-api-key"
|
|
autoComplete="new-password"
|
|
maxLength={4096}
|
|
spellCheck={false}
|
|
required
|
|
value={apiKey}
|
|
disabled={busy || credentialsBusy}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<p className="muted small">
|
|
Stored locally on this server, not in browser storage. Changes
|
|
apply to future classifications without a restart. Configured
|
|
means a key is present, not that it has been validated.
|
|
</p>
|
|
</div>
|
|
<div className="form-actions">
|
|
{state.status.ai_configured && (
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
disabled={busy || credentialsBusy}
|
|
onClick={() => updateOpenRouterKey("")}
|
|
>
|
|
{keyAction === "remove" ? "Removing…" : "Remove key"}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
className="button primary"
|
|
disabled={busy || credentialsBusy || !apiKey.trim()}
|
|
>
|
|
<Save size={16} />
|
|
{keyAction === "save"
|
|
? "Saving…"
|
|
: state.status.ai_configured
|
|
? "Replace key"
|
|
: "Save key"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>Enable Banking credentials</h3>
|
|
<p>Manage the application used to connect your banks.</p>
|
|
</div>
|
|
<span className="badge" role="status">
|
|
{state.status.banking_configured ? (
|
|
<CheckCircle2 size={14} />
|
|
) : (
|
|
<AlertCircle size={14} />
|
|
)}
|
|
{state.status.banking_configured ? "Configured" : "Not configured"}
|
|
</span>
|
|
</div>
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
await saveBankingSettings();
|
|
}}
|
|
>
|
|
<div className="form-body">
|
|
<p>
|
|
First register your application and its public certificate in the{" "}
|
|
<a
|
|
href="https://enablebanking.com/cp/applications"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Enable Banking control panel
|
|
</a>
|
|
, using the exact callback URL below. Upload only the matching
|
|
private key here. After saving, go to{" "}
|
|
<a href="#accounts">Accounts</a> to authorize your bank.
|
|
</p>
|
|
<Field label="Application ID">
|
|
<input
|
|
name="enablebanking-app-id"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
maxLength={256}
|
|
required
|
|
value={bankingAppID}
|
|
disabled={busy || credentialsBusy}
|
|
onChange={(e) => setBankingAppID(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Private key PEM file"
|
|
hint={
|
|
needsPrivateKey
|
|
? "Required for a new application. RSA PKCS#1 or PKCS#8, at least 2048 bits; maximum 32 KiB."
|
|
: "Optional: leave empty to keep the saved key. RSA PKCS#1 or PKCS#8, at least 2048 bits; maximum 32 KiB."
|
|
}
|
|
>
|
|
<input
|
|
ref={privateKeyInput}
|
|
type="file"
|
|
name="enablebanking-private-key"
|
|
accept=".pem,.key"
|
|
required={needsPrivateKey}
|
|
disabled={busy || credentialsBusy}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Callback URL"
|
|
hint="Register this exact URL in Enable Banking, including scheme, hostname, port and path."
|
|
>
|
|
<input
|
|
readOnly
|
|
value={callbackURL}
|
|
onFocus={(e) => e.target.select()}
|
|
/>
|
|
</Field>
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
onClick={async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(callbackURL);
|
|
setCallbackCopied(true);
|
|
} catch {
|
|
setError(
|
|
"Clipboard unavailable. Select and copy the callback URL above.",
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{callbackCopied ? "Copied" : "Copy callback URL"}
|
|
</button>
|
|
<p className="muted small">
|
|
Credentials are stored locally on this server, not in browser
|
|
storage, and apply without a restart. The saved private key is
|
|
never displayed. Configured means credentials are present, not
|
|
that Enable Banking has verified them. Saving does not register or
|
|
test an application, or authorize a bank.
|
|
</p>
|
|
<p className="muted small">
|
|
Changing the application ID requires reconnecting your banks.
|
|
Rotating the key or updating the callback for the same application
|
|
preserves existing local bank sessions.
|
|
</p>
|
|
</div>
|
|
<div className="form-actions">
|
|
{state.status.banking_configured && (
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
disabled={busy || credentialsBusy}
|
|
onClick={() => {
|
|
setError("");
|
|
setRemoveBanking(true);
|
|
}}
|
|
>
|
|
Remove configuration
|
|
</button>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
className="button primary"
|
|
disabled={busy || credentialsBusy || !bankingAppID.trim()}
|
|
>
|
|
<Save size={16} />
|
|
{bankingAction === "save" ? "Saving…" : "Save configuration"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
<div className="dashboard-grid">
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>Classification preferences</h3>
|
|
<p>Choose the model and what AI classification shares.</p>
|
|
</div>
|
|
</div>
|
|
<form
|
|
className="form-body"
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
if (busy || credentialsBusy) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await mutate(
|
|
"/api/settings",
|
|
{
|
|
model: model.trim(),
|
|
private_names: privateNames
|
|
.split(";")
|
|
.map((name) => name.trim())
|
|
.filter(Boolean),
|
|
classify_on_import: classifyOnImport,
|
|
},
|
|
"Classification preferences saved",
|
|
);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
<Field
|
|
label="Default AI model"
|
|
hint="Use the exact OpenRouter provider/model identifier, for example google/gemini-3.8-flash."
|
|
>
|
|
<input
|
|
required
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
list="verified-models"
|
|
/>
|
|
<ModelOptions id="verified-models" />
|
|
</Field>
|
|
<Field
|
|
label="Private names"
|
|
hint="Semicolon-separated names to redact from every AI text field. A semicolon inside a name is not supported."
|
|
>
|
|
<input
|
|
value={privateNames}
|
|
maxLength={2000}
|
|
onChange={(e) => setPrivateNames(e.target.value)}
|
|
placeholder="Your name; household member"
|
|
/>
|
|
</Field>
|
|
<label className="checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={classifyOnImport}
|
|
onChange={(e) => setClassifyOnImport(e.target.checked)}
|
|
/>
|
|
Classify newly imported transactions with AI
|
|
</label>
|
|
<p className="muted small">
|
|
Applies to CSV imports and bank synchronization. When off, no
|
|
import contacts your AI provider: enabled merchant rules still
|
|
classify, and everything else arrives unclassified and editable.
|
|
AI classification → Analyse is unaffected.
|
|
</p>
|
|
<button
|
|
className="button primary"
|
|
disabled={busy || credentialsBusy}
|
|
>
|
|
<Save size={16} />
|
|
{busy ? "Saving…" : "Save preferences"}
|
|
</button>
|
|
</form>
|
|
</section>
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>
|
|
<ShieldCheck size={18} /> Privacy by design
|
|
</h3>
|
|
<p>Understand what leaves your server.</p>
|
|
</div>
|
|
</div>
|
|
<div className="form-body privacy-copy">
|
|
<h4>Local source of truth</h4>
|
|
<p>
|
|
Your canonical journal is stored in plaintext files on your
|
|
server. The analytical database is a rebuildable index, not the
|
|
master copy.
|
|
</p>
|
|
<h4>Explicit external services</h4>
|
|
<p>
|
|
Bank authorization and sync use Enable Banking. AI classification
|
|
sends merchant and counterparty text, amount, date and currency to
|
|
the configured provider after identifier-only redaction. Your own
|
|
account identifiers and configured private names are never sent. A
|
|
third party's payee name can be sent when it is not in your
|
|
private-name list.
|
|
</p>
|
|
<h4>Immutable originals</h4>
|
|
<p>
|
|
Imported bank facts are retained. Categories, tags and merchant
|
|
associations are separate, editable enrichment with classification
|
|
provenance.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
<section className="panel">
|
|
<div className="panel-heading">
|
|
<div>
|
|
<h3>Service health</h3>
|
|
<p>Errors stay visible so you can resolve the underlying issue.</p>
|
|
</div>
|
|
</div>
|
|
<div className="health-grid">
|
|
<Health
|
|
label="Banking provider"
|
|
ok={state.status.banking_configured}
|
|
detail={
|
|
state.status.banking_configured ? "Configured" : "Not configured"
|
|
}
|
|
/>
|
|
<Health
|
|
label="AI provider"
|
|
ok={state.status.ai_configured}
|
|
detail={
|
|
state.status.ai_configured ? "Configured" : "Not configured"
|
|
}
|
|
/>
|
|
<Health
|
|
label="Last bank sync"
|
|
ok={!state.status.sync_error}
|
|
detail={
|
|
state.status.sync_error ||
|
|
state.status.last_sync ||
|
|
"No sync recorded"
|
|
}
|
|
/>
|
|
<Health
|
|
label="Analytics index"
|
|
ok={!state.status.index_error}
|
|
detail={state.status.index_error || "No index error reported"}
|
|
/>
|
|
</div>
|
|
<div className="index-controls">
|
|
<div>
|
|
<h4>
|
|
<Database size={17} /> Rebuild analytics index
|
|
</h4>
|
|
<p>
|
|
Regenerate the query database from the canonical journal. This
|
|
does not modify your bank facts or enrichment.
|
|
</p>
|
|
</div>
|
|
<button
|
|
className="button secondary"
|
|
disabled={busy || credentialsBusy}
|
|
onClick={() => setRebuild(true)}
|
|
>
|
|
<RefreshCw size={16} />
|
|
Rebuild index
|
|
</button>
|
|
</div>
|
|
<details className="revision-details">
|
|
<summary>Current journal revision</summary>
|
|
<code>{state.revision}</code>
|
|
<p className="muted small">
|
|
Edits use this revision to avoid overwriting concurrent changes.
|
|
Refresh when the journal is edited outside the app.
|
|
</p>
|
|
</details>
|
|
</section>
|
|
{removeBanking && (
|
|
<Modal
|
|
title="Remove Enable Banking configuration?"
|
|
close={() => {
|
|
if (!busy && !credentialsBusy) setRemoveBanking(false);
|
|
}}
|
|
>
|
|
<div className="form-body">
|
|
<p>
|
|
This disables Enable Banking and invalidates saved local bank
|
|
connections and pending authorizations. Your accounts and
|
|
transaction history are kept. It does not revoke upstream bank
|
|
consent; manage that separately with your bank.
|
|
</p>
|
|
<p>
|
|
You will need to configure the application and reconnect to sync
|
|
again.
|
|
</p>
|
|
<ErrorMessage error={error} />
|
|
</div>
|
|
<div className="form-actions">
|
|
<button
|
|
className="button secondary"
|
|
disabled={busy || credentialsBusy}
|
|
onClick={() => setRemoveBanking(false)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="button danger"
|
|
disabled={busy || credentialsBusy}
|
|
onClick={async () => {
|
|
if (busy || credentialsBusy) return;
|
|
setBankingAction("remove");
|
|
setError("");
|
|
try {
|
|
await mutate(
|
|
"/api/settings/enablebanking",
|
|
{ remove: true },
|
|
"Enable Banking configuration removed",
|
|
);
|
|
setBankingAppID("");
|
|
if (privateKeyInput.current)
|
|
privateKeyInput.current.value = "";
|
|
setRemoveBanking(false);
|
|
} catch (err) {
|
|
setError(
|
|
err instanceof APIError
|
|
? err.message
|
|
: "Could not remove Enable Banking configuration.",
|
|
);
|
|
} finally {
|
|
setBankingAction(null);
|
|
}
|
|
}}
|
|
>
|
|
{bankingAction === "remove"
|
|
? "Removing…"
|
|
: "Remove configuration"}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
{rebuild && (
|
|
<Modal
|
|
title="Rebuild the analytics index?"
|
|
close={() => {
|
|
if (!busy && !credentialsBusy) setRebuild(false);
|
|
}}
|
|
>
|
|
<div className="form-body">
|
|
<p>
|
|
The derived index will be regenerated from your journal. Queries
|
|
may be briefly unavailable while rebuilding.
|
|
</p>
|
|
<ErrorMessage error={error} />
|
|
</div>
|
|
<div className="form-actions">
|
|
<button
|
|
className="button secondary"
|
|
disabled={busy || credentialsBusy}
|
|
onClick={() => setRebuild(false)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="button primary"
|
|
disabled={busy || credentialsBusy}
|
|
onClick={async () => {
|
|
if (busy || credentialsBusy) return;
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await mutate("/api/rebuild", {}, "Analytics index rebuilt");
|
|
setRebuild(false);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
{busy ? "Rebuilding…" : "Rebuild from journal"}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
function Health({
|
|
label,
|
|
ok,
|
|
detail,
|
|
}: {
|
|
label: string;
|
|
ok: boolean;
|
|
detail: string;
|
|
}) {
|
|
return (
|
|
<div className="health">
|
|
<span className={ok ? "positive" : "text-danger"}>
|
|
{ok ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
|
|
</span>
|
|
<div>
|
|
<strong>{label}</strong>
|
|
<p>{detail}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|