diff --git a/web/src/App.jsx b/web/src/App.jsx
index c4743b0..59155ce 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -12,6 +12,7 @@ import Categories from "./pages/Categories";
import Transactions from "./pages/Transactions";
import Budget from "./pages/Budget";
import Import from "./pages/Import";
+import Eingang from "./pages/Eingang";
import Offen from "./pages/Offen";
import Users from "./pages/Users";
import Settings from "./pages/Settings";
@@ -50,6 +51,7 @@ function Sidebar() {
+
{t("nav.system")}
@@ -137,6 +139,7 @@ export default function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/web/src/api.js b/web/src/api.js
index 753cd93..0afe1c3 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -164,6 +164,15 @@ export const api = {
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" }),
+
// Kategorien (Gruppen + Kategorien)
listCategories: () => request("/categories"),
createGroup: (body) => request("/categories/groups", { method: "POST", body }),
diff --git a/web/src/locales/de.json b/web/src/locales/de.json
index 92ad666..9061bba 100644
--- a/web/src/locales/de.json
+++ b/web/src/locales/de.json
@@ -23,7 +23,8 @@
"system": "System",
"users": "Benutzer",
"settings": "Einstellungen",
- "open": "Offene Buchungen"
+ "open": "Offene Buchungen",
+ "inbox": "Eingang"
},
"login": {
"subtitle": "Bitte anmelden",
@@ -365,7 +366,9 @@
"filterTitle": "Diese Datei enthält mehrere Konten",
"filterValueOption": "{{value}} ({{count}} Zeilen)",
"removeAllUndone": "Rückgängig gemachte entfernen",
- "removeAllUndoneConfirm": "{{n}} rückgängig gemachte Einträge aus dem Verlauf entfernen? Die Buchungen sind bereits weg, es verschwindet nur der Eintrag."
+ "removeAllUndoneConfirm": "{{n}} rückgängig gemachte Einträge aus dem Verlauf entfernen? Die Buchungen sind bereits weg, es verschwindet nur der Eintrag.",
+ "dupInbox": "schon erfasst",
+ "dupInboxHint": "Diese Zahlung hast du unterwegs per Kurzbefehl erfasst – Betrag gleich, Datum in der Nähe. Sie ist bereits gebucht und wird deshalb übersprungen. Falls das doch zwei verschiedene Zahlungen sind, kannst du die Zeile von Hand anhaken."
},
"password": {
"show": "Passwort anzeigen",
@@ -454,5 +457,41 @@
"copy": "Text kopieren",
"copied": "Kopiert",
"copyFailed": "Kopieren nicht möglich – Text lässt sich jetzt aber markieren."
+ },
+ "tokens": {
+ "title": "Schnellerfassung per Kurzbefehl",
+ "intro": "Eine Gerätemarke erlaubt einem iOS-Kurzbefehl, Zahlungen an Certain zu schicken – damit du im Laden gleich erfassen kannst, wofür du bezahlt hast. Die Marke darf ausschliesslich erfassen: keine Buchungen lesen, nichts ändern, nichts exportieren.",
+ "name": "Name des Geräts",
+ "namePlaceholder": "z. B. iPhone",
+ "defaultAccount": "Standardkonto",
+ "create": "Marke anlegen",
+ "onceOnly": "Diese Marke ist nur jetzt zu sehen. Kopiere sie, bevor du weitergehst.",
+ "copy": "Kopieren",
+ "copied": "Kopiert",
+ "copyFailed": "Kopieren nicht möglich – der Text lässt sich aber markieren.",
+ "hide": "Fertig, ausblenden",
+ "guideTitle": "So baust du den Kurzbefehl",
+ "guideNoDownload": "Zum Herunterladen anbieten kann ich ihn leider nicht: Seit iOS 15 müssen Kurzbefehl-Dateien signiert sein, und signieren kann nur ein Mac mit Apple-Konto. Nachbauen dauert zwei Minuten.",
+ "step1": "„Text erfragen\" – Frage: Betrag, Eingabetyp Zahl",
+ "step2": "„Text erfragen\" – Frage: Wofür?",
+ "step3": "„Aus Liste wählen\" für das Konto (oder weglassen, dann gilt das Standardkonto)",
+ "step4": "„Inhalte von URL abrufen\" – Methode POST, Anfragetext JSON mit den Feldern amount und payee:",
+ "step5": "„Mitteilung anzeigen\" mit dem Ergebnis – dann siehst du die Bestätigung",
+ "guideHomeScreen": "Danach über Teilen → „Zum Home-Bildschirm\" ablegen, dann ist es ein Tipp.",
+ "guideAutomation": "Wenn deine Kurzbefehle die Automation „Transaktion\" anbieten, kannst du denselben Kurzbefehl daran hängen: Schritt 1 und 2 entfallen, Betrag und Händler kommen dann von dort.",
+ "revoke": "Widerrufen",
+ "revokeConfirm": "Marke „{{name}}\" widerrufen? Der Kurzbefehl auf dem Gerät funktioniert danach nicht mehr. Bereits erfasste Buchungen bleiben.",
+ "noDefault": "kein Standardkonto",
+ "lastUsed": "schon benutzt",
+ "neverUsed": "noch nie benutzt"
+ },
+ "inbox": {
+ "title": "Eingang",
+ "intro": "Zahlungen, die du unterwegs erfasst hast. Sie sind bereits gebucht – hier ordnest du ihnen in Ruhe eine Kategorie zu. Die Einträge bleiben stehen, damit der spätere Kontoauszug sie wiedererkennt und nichts doppelt verbucht wird.",
+ "empty": "Nichts erfasst. Richte unter „Mein Konto“ eine Gerätemarke ein, dann kannst du Zahlungen per Kurzbefehl vom Telefon aus erfassen.",
+ "noText": "ohne Text",
+ "viaShortcut": "per Kurzbefehl",
+ "discard": "Verwerfen",
+ "discardConfirm": "Diese Erfassung verwerfen? Die Buchung wird gelöscht. Der Eintrag bleibt als verworfen vermerkt, damit der Kontoauszug die Zahlung später trotzdem erkennt."
}
}
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index 64c5ec1..36f5244 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -23,7 +23,8 @@
"system": "System",
"users": "Users",
"settings": "Settings",
- "open": "Open bookings"
+ "open": "Open bookings",
+ "inbox": "Captured"
},
"login": {
"subtitle": "Please sign in",
@@ -365,7 +366,9 @@
"filterTitle": "This file holds several accounts",
"filterValueOption": "{{value}} ({{count}} rows)",
"removeAllUndone": "Remove undone entries",
- "removeAllUndoneConfirm": "Remove {{n}} undone entries from the history? The bookings are already gone; only the entry disappears."
+ "removeAllUndoneConfirm": "Remove {{n}} undone entries from the history? The bookings are already gone; only the entry disappears.",
+ "dupInbox": "already captured",
+ "dupInboxHint": "You recorded this payment on the go via shortcut – same amount, nearby date. It is already booked, so it is skipped. If these really are two separate payments, tick the row by hand."
},
"password": {
"show": "Show password",
@@ -454,5 +457,41 @@
"copy": "Copy text",
"copied": "Copied",
"copyFailed": "Copying failed – but the text can be selected now."
+ },
+ "tokens": {
+ "title": "Quick capture via shortcut",
+ "intro": "A device token lets an iOS shortcut send payments to Certain, so you can record what a purchase was for while you still remember. The token may only record: it cannot read bookings, change anything, or export.",
+ "name": "Device name",
+ "namePlaceholder": "e.g. iPhone",
+ "defaultAccount": "Default account",
+ "create": "Create token",
+ "onceOnly": "This token is shown only now. Copy it before you move on.",
+ "copy": "Copy",
+ "copied": "Copied",
+ "copyFailed": "Copying failed – but the text can be selected.",
+ "hide": "Done, hide",
+ "guideTitle": "How to build the shortcut",
+ "guideNoDownload": "I cannot offer it as a download: since iOS 15 shortcut files must be signed, and only a Mac with an Apple account can sign them. Rebuilding it takes two minutes.",
+ "step1": "“Ask for Input” – prompt: Amount, input type Number",
+ "step2": "“Ask for Input” – prompt: What for?",
+ "step3": "“Choose from List” for the account (or omit it to use the default account)",
+ "step4": "“Get Contents of URL” – method POST, request body JSON with the fields amount and payee:",
+ "step5": "“Show Notification” with the result, so you see the confirmation",
+ "guideHomeScreen": "Then use Share → “Add to Home Screen” so it is a single tap.",
+ "guideAutomation": "If your Shortcuts app offers the “Transaction” automation, you can attach the same shortcut to it: steps 1 and 2 fall away, amount and merchant come from there instead.",
+ "revoke": "Revoke",
+ "revokeConfirm": "Revoke token “{{name}}”? The shortcut on that device will stop working. Bookings already recorded stay.",
+ "noDefault": "no default account",
+ "lastUsed": "used before",
+ "neverUsed": "never used"
+ },
+ "inbox": {
+ "title": "Captured",
+ "intro": "Payments you recorded on the go. They are already booked – assign a category here at your leisure. The entries stay so the later statement recognises them and nothing gets booked twice.",
+ "empty": "Nothing captured. Set up a device token under “My account” to record payments from your phone via shortcut.",
+ "noText": "no text",
+ "viaShortcut": "via shortcut",
+ "discard": "Discard",
+ "discardConfirm": "Discard this capture? The booking is deleted. The entry stays marked as discarded so the statement still recognises the payment later."
}
}
diff --git a/web/src/pages/Account.jsx b/web/src/pages/Account.jsx
index 127aeef..470eb27 100644
--- a/web/src/pages/Account.jsx
+++ b/web/src/pages/Account.jsx
@@ -7,6 +7,7 @@ import { useToast } from "../toast";
import Icon from "../components/Icon";
import PasswordInput from "../components/PasswordInput";
import PasswordRules from "../components/PasswordRules";
+import Geraetemarken from "./settings/Geraetemarken";
import { DEFAULT_POLICY, meetsPolicy } from "../passwordPolicy";
/**
@@ -98,6 +99,8 @@ export default function Account() {
{tfa?.enabled ? t("account.manage2fa") : t("settings.twoFactor")}
+
+
);
diff --git a/web/src/pages/Eingang.jsx b/web/src/pages/Eingang.jsx
new file mode 100644
index 0000000..cfa2314
--- /dev/null
+++ b/web/src/pages/Eingang.jsx
@@ -0,0 +1,118 @@
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { api } from "../api";
+import CategorySelect from "../components/CategorySelect";
+import Icon from "../components/Icon";
+import IconChip from "../components/IconChip";
+import Money from "../components/Money";
+import { useToast } from "../toast";
+import { useConfirm } from "../confirm";
+import { useSettings } from "../settings";
+import { gruppenFuer } from "../kategorien";
+import { formatDate } from "../format";
+
+// Was unterwegs erfasst wurde – per Kurzbefehl vom Telefon. Die Einträge bleiben
+// hier stehen, auch nachdem sie gebucht sind: Der spätere Kontoauszug gleicht
+// gegen sie ab, damit dieselbe Zahlung nicht ein zweites Mal in den Saldo läuft.
+export default function Eingang() {
+ const { t } = useTranslation();
+ const { toast } = useToast();
+ const confirm = useConfirm();
+ const { baseCurrency } = useSettings();
+
+ const [rows, setRows] = useState(null);
+ const [accounts, setAccounts] = useState([]);
+ const [groups, setGroups] = useState([]);
+
+ const expenseGroups = useMemo(() => gruppenFuer(groups, "expense"), [groups]);
+ const incomeGroups = useMemo(() => gruppenFuer(groups, "income"), [groups]);
+ const accountById = useMemo(
+ () => Object.fromEntries(accounts.map((a) => [a.id, a])), [accounts]);
+
+ async function laden() {
+ try {
+ const [items, acc, cat] = await Promise.all([
+ api.listInbox(), api.listAccounts(), api.listCategories(),
+ ]);
+ setRows(items);
+ setAccounts(acc);
+ setGroups(cat);
+ } catch (e) {
+ toast(e.message, "error");
+ }
+ }
+ useEffect(() => { laden(); }, []);
+
+ async function zuordnen(item, categoryId) {
+ if (categoryId == null) return;
+ try {
+ await api.setInboxCategory(item.id, categoryId);
+ setRows((r) => r.map((x) => (x.id === item.id ? { ...x, category_id: categoryId } : x)));
+ } catch (e) {
+ toast(e.message, "error");
+ }
+ }
+
+ async function verwerfen(item) {
+ if (!(await confirm({
+ title: t("inbox.discard"),
+ message: t("inbox.discardConfirm"),
+ danger: true,
+ confirmLabel: t("inbox.discard"),
+ }))) return;
+ try {
+ await api.discardInboxItem(item.id);
+ laden();
+ } catch (e) {
+ toast(e.message, "error");
+ }
+ }
+
+ if (rows === null) return