Authorize consents for the account-holder type the bank supports

Kontist authorized but shared no accounts: psu_type was hardcoded to
personal, and Enable Banking documents that a psu_type mismatch can
yield a consent without the expected accounts. The bank listing now
reports each institution's supported psu_types, the connect form offers
only those, the chosen type reaches POST /auth, and an unsupported
combination is refused before the user is sent to a bank. The choice is
stored per consent so reconnecting reuses it; consents predating the
choice stay personal.

Also repairs the frontend derivation, which the Montserrat dependency
broke: npmDepsHash was stale and web/public was missing from the
fileset, so the traced duck icon never reached the built assets.
This commit is contained in:
Lars Nolden
2026-09-11 13:39:45 +02:00
parent 35d91a5c48
commit c33e8d5573
11 changed files with 282 additions and 61 deletions
+45 -6
View File
@@ -188,11 +188,13 @@ function AccountsContent({
async function authorize(
institution: string,
country: string,
psuType: string,
historyMonths: number,
) {
const response = await request<{ url: string }>("/api/banking/authorize", {
institution,
country,
psu_type: psuType,
history_months: historyMonths,
});
const url = new URL(response.url);
@@ -302,6 +304,7 @@ function AccountCard({
await authorize(
institution,
connection.country || "DE",
connection.psu_type || "personal",
connection.history_months,
);
} catch (err) {
@@ -634,8 +637,19 @@ function ConnectForm({
onError: (error: string) => void;
}) {
const [institution, setInstitution] = useState("");
const [psuTypes, setPSUTypes] = useState<string[] | null>(null);
const [psuType, setPSUType] = useState("personal");
const [country, setCountry] = useState("DE");
const [historyMonths, setHistoryMonths] = useState("12");
// Supported account types come from the bank listing. Authorizing a business
// account with the personal flow yields a consent that shares no accounts,
// so keep the choice inside what the selected bank actually offers.
const chooseInstitution = (name: string, supported?: string[]) => {
setInstitution(name);
setPSUTypes(supported ?? null);
if (supported?.length && !supported.includes(psuType))
setPSUType(supported[0]);
};
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const callback =
@@ -696,7 +710,12 @@ function ConnectForm({
setBusy(true);
onError("");
try {
await authorize(institution.trim(), country, Number(historyMonths));
await authorize(
institution.trim(),
country,
psuType,
Number(historyMonths),
);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setBusy(false);
@@ -707,8 +726,28 @@ function ConnectForm({
country={country}
configured={state.status.banking_configured}
value={institution}
onChange={setInstitution}
onChange={chooseInstitution}
/>
<Field
label="Account type"
hint={
psuTypes?.length === 1
? `${institution} offers account information for ${psuTypes[0]} accounts only.`
: "Business accounts must be authorized as business: the personal flow returns a consent without accounts."
}
>
<select
required
value={psuType}
onChange={(e) => setPSUType(e.target.value)}
>
{(psuTypes ?? ["personal", "business"]).map((kind) => (
<option key={kind} value={kind}>
{kind === "business" ? "Business" : "Personal"}
</option>
))}
</select>
</Field>
<Field label="Country" hint="Two-letter country code">
<input
required
@@ -717,7 +756,7 @@ function ConnectForm({
value={country}
onChange={(e) => {
setCountry(e.target.value.toUpperCase());
setInstitution("");
chooseInstitution("");
}}
/>
</Field>
@@ -765,7 +804,7 @@ function InstitutionSelect({
country: string;
configured: boolean;
value: string;
onChange: (name: string) => void;
onChange: (name: string, psuTypes?: string[]) => void;
}) {
const [institutions, setInstitutions] = useState<Institution[] | null>(null);
const [loadError, setLoadError] = useState("");
@@ -840,7 +879,7 @@ function InstitutionSelect({
if (e.key === "Enter" && open) {
e.preventDefault();
if (shown.length === 1) {
onChange(shown[0].name);
onChange(shown[0].name, shown[0].psu_types);
setOpen(false);
}
}
@@ -860,7 +899,7 @@ function InstitutionSelect({
aria-selected={i.name === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(i.name);
onChange(i.name, i.psu_types);
setOpen(false);
}}
>
+2
View File
@@ -68,6 +68,7 @@ export interface Connection {
account_id: string;
institution: string;
country: string;
psu_type: string;
history_months: number;
status: "local" | "connected" | "reconnect_required" | "error";
valid_until: string;
@@ -77,6 +78,7 @@ export interface Institution {
name: string;
country: string;
logo?: string;
psu_types: string[];
}
export interface State {
data: Dataset;