// Zentraler API-Client. Alles läuft über /api (im Dev leitet der Vite-Proxy an // das Backend, in Produktion liefert derselbe FastAPI-Prozess API und SPA aus). // Die Anmeldung liegt in einem HttpOnly-Cookie – deshalb IMMER credentials:"include" // und kein Token im JavaScript. const API_BASE = "/api"; // Wird bei einer abgelaufenen/ungültigen Sitzung (HTTP 401) gerufen, damit die // Oberfläche nicht „angemeldet" bleibt, während jede Abfrage scheitert. let beiAbmeldung = null; export function setUnauthorizedHandler(fn) { beiAbmeldung = fn; } export class ApiError extends Error { constructor(message, status) { super(message); this.status = status; } } async function request(path, { method = "GET", body, form, formData, skipAuthHandler = false } = {}) { const headers = {}; let payload; if (formData) { payload = formData; // Content-Type setzt der Browser selbst } else if (form) { headers["Content-Type"] = "application/x-www-form-urlencoded"; payload = new URLSearchParams(form).toString(); } else if (body !== undefined) { headers["Content-Type"] = "application/json"; payload = JSON.stringify(body); } const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload, credentials: "include", }); // Abgelaufene Sitzung zentral behandeln – aber nicht beim Login selbst und // nicht bei der stillen Startprüfung (dort ist 401 = „noch nicht angemeldet"). if (resp.status === 401 && path !== "/auth/login" && !skipAuthHandler) { beiAbmeldung?.(); } if (resp.status === 204) return null; let data = null; const text = await resp.text(); if (text) { try { data = JSON.parse(text); } catch { data = text; } } if (!resp.ok) { let detail; if (data && data.detail) { detail = Array.isArray(data.detail) ? data.detail.map((d) => d.msg).join(", ") : data.detail; } else if (resp.status === 502 || resp.status === 503 || resp.status === 504) { detail = `Server nicht erreichbar (${resp.status}).`; } else if (typeof data === "string" && data.trim().startsWith("<")) { detail = `Server-Fehler (${resp.status}).`; } else if (typeof data === "string" && data) { detail = data; } else { detail = `Fehler ${resp.status}`; } throw new ApiError(detail, resp.status); } return data; } /** Ortszeit als YYYY-MM-DD_HHMM für Dateinamen. */ function zeitstempel() { const jetzt = new Date(); const zwei = (n) => String(n).padStart(2, "0"); return ( `${jetzt.getFullYear()}-${zwei(jetzt.getMonth() + 1)}-${zwei(jetzt.getDate())}` + `_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}` ); } /** Dateiname aus dem Content-Disposition-Kopf, sonst der Rückfallwert. */ function dateinameAusKopf(resp, rueckfall) { const kopf = resp.headers.get("content-disposition") || ""; const treffer = /filename="([^"]+)"/.exec(kopf); return treffer ? treffer[1] : rueckfall; } /** * Datei (mit Sitzungs-Cookie) laden und im Browser als Download anbieten. * * Den Namen gibt der Server vor – sonst hiesse dieselbe Datei je nachdem, ob * man sie über die Oberfläche oder direkt über die API holt, anders. `rueckfall` * greift nur, wenn der Kopf fehlt. */ export async function downloadFile(path, rueckfall) { const resp = await fetch(`${API_BASE}${path}`, { credentials: "include" }); if (!resp.ok) { throw new ApiError(`Download fehlgeschlagen (${resp.status})`, resp.status); } const blob = await resp.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = dateinameAusKopf(resp, rueckfall); document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } export { zeitstempel }; export const api = { // Auth login: (username, password) => request("/auth/login", { method: "POST", form: { username, password } }), logout: () => request("/auth/logout", { method: "POST" }), me: (opts) => request("/auth/me", { skipAuthHandler: opts?.silent }), changePassword: (current_password, new_password) => request("/auth/change-password", { method: "POST", body: { current_password, new_password } }), version: () => request("/version"), // Zwei-Faktor. verifyTwoFactor ist der zweite Anmeldeschritt und läuft noch // über das kurzlebige Zwischen-Cookie – dort ist ein 401 normal, also nicht // vom globalen Abmelde-Griff behandeln lassen. verifyTwoFactor: (code, recovery = false) => request("/auth/2fa/verify", { method: "POST", body: { code, recovery }, skipAuthHandler: true }), twoFactorStatus: () => request("/auth/2fa/status"), twoFactorSetup: () => request("/auth/2fa/setup", { method: "POST" }), twoFactorActivate: (code) => request("/auth/2fa/activate", { method: "POST", body: { code } }), twoFactorDisable: (password) => request("/auth/2fa/disable", { method: "POST", body: { password } }), newRecoveryCodes: (password) => request("/auth/2fa/recovery-codes", { method: "POST", body: { password } }), // Passwortrichtlinie (lesen: alle, ändern: Admin) getPolicy: () => request("/policy"), setPolicy: (body) => request("/policy", { method: "PUT", body }), // Benutzer (Admin) listUsers: () => request("/users"), createUser: (body) => request("/users", { method: "POST", body }), updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }), deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }), resetUserTwoFactor: (id) => request(`/users/${id}/2fa/reset`, { method: "POST" }), // Einstellungen (je Benutzer) listSettings: () => request("/settings"), setSetting: (key, value) => request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }), // Konten listAccounts: () => request("/accounts"), getAccount: (id) => request(`/accounts/${id}`), createAccount: (body) => request("/accounts", { method: "POST", body }), updateAccount: (id, body) => request(`/accounts/${id}`, { method: "PATCH", body }), deleteAccount: (id) => request(`/accounts/${id}`, { method: "DELETE" }), setAccountOrder: (ids) => request("/accounts/order", { method: "PUT", body: { ids } }), // Schnellerfassung: Gerätemarken und Eingang listTokens: () => request("/inbox/tokens"), createToken: (body) => request("/inbox/tokens", { method: "POST", body }), deleteToken: (id) => request(`/inbox/tokens/${id}`, { method: "DELETE" }), listInbox: () => request("/inbox"), discardInboxItem: (id) => request(`/inbox/${id}`, { method: "DELETE" }), setInboxCategory: (id, categoryId) => request(`/inbox/${id}/category/${categoryId}`, { method: "POST" }), bookInboxItem: (id) => request(`/inbox/${id}/book`, { method: "POST" }), bookAllInbox: () => request("/inbox/book-all", { method: "POST" }), detectFormat: (formData) => request("/imports/detect", { method: "POST", formData }), listUploads: () => request("/imports/uploads"), deleteUpload: (id) => request(`/imports/uploads/${id}`, { method: "DELETE" }), // Postfächer für den Mail-Abruf listMailSources: () => request("/mail/sources"), createMailSource: (body) => request("/mail/sources", { method: "POST", body }), updateMailSource: (id, body) => request(`/mail/sources/${id}`, { method: "PUT", body }), deleteMailSource: (id) => request(`/mail/sources/${id}`, { method: "DELETE" }), testMailSource: (id) => request(`/mail/sources/${id}/test`, { method: "POST" }), // `tage` uebergeht den letzten Abruf – fuer Mails, die schon vor der // Einrichtung im Postfach lagen und sonst ausserhalb des Fensters blieben. fetchMailSource: (id, tage) => request(`/mail/sources/${id}/fetch${tage ? `?tage=${tage}` : ""}`, { method: "POST" }), listMailSenders: (id) => request(`/mail/sources/${id}/senders`), createMailSender: (id, body) => request(`/mail/sources/${id}/senders`, { method: "POST", body }), updateMailSender: (id, body) => request(`/mail/senders/${id}`, { method: "PUT", body }), deleteMailSender: (id) => request(`/mail/senders/${id}`, { method: "DELETE" }), // Bankanbindung über PSD2 getBankApp: () => request("/bank/app"), saveBankApp: (body) => request("/bank/app", { method: "PUT", body }), deleteBankApp: () => request("/bank/app", { method: "DELETE" }), listBanks: (country, search) => { const p = new URLSearchParams(); if (country) p.set("country", country); if (search) p.set("search", search); return request(`/bank/banks?${p.toString()}`); }, connectBank: (body) => request("/bank/connect", { method: "POST", body }), bankCallback: (state, code) => request(`/bank/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}`, { method: "POST" }), // Einen Eingangs-Vorschlag als Kontoübertrag buchen statt als Ausgabe. // `trotzdem` nur setzen, wenn der Server mit 409 gewarnt hat und der Nutzer // die Warnung gelesen und bestätigt hat. bookInboxAsTransfer: (id, toAccountId, trotzdem = false) => request(`/inbox/${id}/transfer`, { method: "POST", body: { to_account_id: toAccountId, trotzdem }, }), // --- Überträge zwischen eigenen Konten ----------------------------------- // Ändern ausschliesslich die Verknüpfungs-Schicht. Keine Buchung wird // angefasst – deshalb ist auch eine falsche Verknüpfung folgenlos. // Eine gebuchte Ausgabe oder Einnahme nachtraeglich zum Uebertrag machen. makeTransactionTransfer: (id, toAccountId, trotzdem = false) => request(`/transactions/${id}/transfer`, { method: "POST", body: { to_account_id: toAccountId, trotzdem }, }), // Regeln nachträglich auf Buchungen ohne Kategorie anwenden. `vorschau` // ändert nichts – erst zeigen, dann tun. applyRules: (vorschau = true) => request(`/categories/keywords/anwenden?vorschau=${vorschau}`, { method: "POST" }), transactionLinks: () => request("/transactions/links"), searchTransfers: () => request("/uebertraege/suchen", { method: "POST" }), connectTransfer: (a_id, b_id) => request("/uebertraege/verbinden", { method: "POST", body: { a_id, b_id } }), releaseTransfer: (linkId) => request(`/uebertraege/${linkId}`, { method: "DELETE" }), ignoreTransfer: (a_id, b_id) => request("/uebertraege/ignorieren", { method: "POST", body: { a_id, b_id } }), listBankConnections: () => request("/bank/connections"), syncBankConnection: (id) => request(`/bank/connections/${id}/sync`, { method: "POST" }), refreshBankBalances: (id) => request(`/bank/connections/${id}/salden`, { method: "POST" }), // Nur dieses eine Konto abrufen – ein Abruf bucht, und wer ein Konto holt, // will das andere nicht angefasst haben. syncBankLink: (linkId) => request(`/bank/links/${linkId}/sync`, { method: "POST" }), // Den Saldo der Bank als richtig uebernehmen – die Differenz wandert in // den Anfangsbestand, die Buchungen bleiben unangetastet. adoptBankBalance: (accountId) => request(`/accounts/${accountId}/saldo-uebernehmen`, { method: "POST" }), setBankPoll: (id, minutes) => request(`/bank/connections/${id}/takt?minutes=${minutes}`, { method: "PUT" }), deleteBankConnection: (id) => request(`/bank/connections/${id}`, { method: "DELETE" }), listBankLinks: () => request("/bank/links"), setBankLinkAccount: (linkId, accountId) => request(`/bank/links/${linkId}/account/${accountId}`, { method: "POST" }), clearBankLinkAccount: (linkId) => request(`/bank/links/${linkId}/account`, { method: "DELETE" }), // Kategorien (Gruppen + Kategorien) listCategories: () => request("/categories"), createGroup: (body) => request("/categories/groups", { method: "POST", body }), updateGroup: (id, body) => request(`/categories/groups/${id}`, { method: "PATCH", body }), deleteGroup: (id) => request(`/categories/groups/${id}`, { method: "DELETE" }), createCategory: (body) => request("/categories", { method: "POST", body }), updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), // Schlagwörter je Kategorie (manuelle Auto-Zuordnung, optional mit Betrag) + Prüfmeldungen listKeywords: (categoryId) => request(`/categories/${categoryId}/keywords`), addKeyword: (categoryId, body) => request(`/categories/${categoryId}/keywords`, { method: "POST", body }), updateKeyword: (ruleId, body) => request(`/categories/keywords/${ruleId}`, { method: "PATCH", body }), deleteKeyword: (ruleId) => request(`/categories/keywords/${ruleId}`, { method: "DELETE" }), flaggedKeywords: () => request("/categories/keywords/flagged"), confirmKeyword: (ruleId) => request(`/categories/keywords/${ruleId}/confirm`, { method: "POST" }), // Transfer-Schlagwörter (Text -> Transfer, Zielkonto über Konto-Texte) listTransferKeywords: () => request("/categories/transfer-keywords"), addTransferKeyword: (body) => request("/categories/transfer-keywords", { method: "POST", body }), // Transaktionen listTransactions: (params = {}) => { const qs = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v !== undefined && v !== null && v !== "") qs.set(k, v); } const s = qs.toString(); return request(`/transactions${s ? `?${s}` : ""}`); }, getTransaction: (id) => request(`/transactions/${id}`), createTransaction: (body) => request("/transactions", { method: "POST", body }), updateTransaction: (id, body) => request(`/transactions/${id}`, { method: "PATCH", body }), deleteTransaction: (id) => request(`/transactions/${id}`, { method: "DELETE" }), // Wechselkurse rateStatus: () => request("/rates/status"), convertRate: (amountCents, from, to, on) => { const qs = new URLSearchParams({ amount_cents: amountCents, from_currency: from, to_currency: to }); if (on) qs.set("on", on); return request(`/rates/convert?${qs.toString()}`); }, // Budget budgetCurrent: () => request("/budget/current"), budget: (label) => request(`/budget/${label}`), budgetTemplate: () => request("/budget/template"), setTemplateCategory: (catId, cents) => request(`/budget/template/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), setMonthCategory: (label, catId, cents) => request(`/budget/${label}/category/${catId}?planned_cents=${cents}`, { method: "PUT" }), resetMonth: (label) => request(`/budget/${label}`, { method: "DELETE" }), budgetTemplateFromMonth: (label) => request(`/budget/template/from-month/${label}`, { method: "POST" }), budgetMonthFromTemplate: (label) => request(`/budget/${label}/from-template`, { method: "POST" }), createIncome: (body) => request("/budget/income", { method: "POST", body }), updateIncome: (id, body) => request(`/budget/income/${id}`, { method: "PATCH", body }), deleteIncome: (id) => request(`/budget/income/${id}`, { method: "DELETE" }), // Dashboard dashboardLayout: () => request("/dashboard/layout"), saveDashboardLayout: (layout) => request("/dashboard/layout", { method: "PUT", body: layout }), resetDashboardLayout: () => request("/dashboard/layout", { method: "DELETE" }), cardBudgetDonut: (groupBy) => request(`/dashboard/budget-donut${groupBy ? `?group_by=${groupBy}` : ""}`), cardExpenseShare: (groupBy) => request(`/dashboard/expense-share${groupBy ? `?group_by=${groupBy}` : ""}`), cardBudgetProgress: (groupBy) => request(`/dashboard/budget-progress${groupBy ? `?group_by=${groupBy}` : ""}`), cardIncomeExpense: () => request("/dashboard/income-expense"), cardBalances: () => request("/dashboard/balances"), cardNetWorth: (days) => request(`/dashboard/net-worth${days ? `?days=${days}` : ""}`), cardRecent: (limit) => request(`/dashboard/recent${limit ? `?limit=${limit}` : ""}`), // CSV-Import importPresets: () => request("/imports/presets"), importTemplates: () => request("/imports/templates"), createImportTemplate: (body) => request("/imports/templates", { method: "POST", body }), updateImportTemplate: (id, body) => request(`/imports/templates/${id}`, { method: "PATCH", body }), deleteImportTemplate: (id) => request(`/imports/templates/${id}`, { method: "DELETE" }), importRaw: (formData) => request("/imports/raw", { method: "POST", formData }), importPreview: (formData) => request("/imports/preview", { method: "POST", formData }), importCommit: (body) => request("/imports/commit", { method: "POST", body }), importBatches: () => request("/imports/batches"), undoImportBatch: (id) => request(`/imports/batches/${id}`, { method: "DELETE" }), removeImportBatch: (id) => request(`/imports/batches/${id}/record`, { method: "DELETE" }), // PDF/Bild-Beleg documentPreview: (formData) => request("/documents/preview", { method: "POST", formData }), // Backup & Export // Die Dateinamen kommen aus dem Content-Disposition-Kopf (backend/app/routers/ // backup.py, _dateiname). Was hier steht, ist nur der Rückfall. downloadDbBackup: () => downloadFile("/backup/db", `certain-finance_datenbank_${zeitstempel()}.db`), exportTransactionsCsv: () => downloadFile("/export/transactions.csv", `certain-finance_buchungen_${zeitstempel()}.csv`), exportTransactionsJson: () => downloadFile("/export/transactions.json", `certain-finance_buchungen_${zeitstempel()}.json`), exportAllJson: () => downloadFile("/export/all.json", `certain-finance_datenuebersicht_${zeitstempel()}.json`), exportBackupJson: () => downloadFile("/export/backup.json", `certain-finance_vollbackup_${zeitstempel()}.json`), exportSystemBackupJson: () => downloadFile("/export/system-backup.json", `certain-finance_vollbackup-system_${zeitstempel()}.json`), restorePreview: (file) => { const fd = new FormData(); fd.append("file", file); return request("/backup/restore/preview", { method: "POST", formData: fd }); }, restoreBackup: (file, mode) => { const fd = new FormData(); fd.append("file", file); fd.append("mode", mode); return request("/backup/restore", { method: "POST", formData: fd }); }, };