feat: Schnellerfassung per Kurzbefehl (Etappe A)
Der Kontoauszug kommt erst am Monatsende; wer im Laden bezahlt hat, weiss in dem Moment noch, wofuer. iOS laesst keine App fremde Mitteilungen mitlesen, also fuehrt der Weg ueber einen Kurzbefehl, der die Zahlung an Certain schickt. Gebaut: - Geraetemarken mit eigener Abhaengigkeit statt Sonderfall in get_current_user. Eine Marke gilt NUR an /quick – auf allen anderen Wegen ist sie keine Anmeldung. Geht das Telefon verloren, ist kein Datenabfluss moeglich; Tests halten das fuer Buchungen, Konten, Kategorien, Export und Datenbanksicherung fest. - POST /quick/booking laeuft durch detect_rows, damit Schlagwort- und Betragsregeln die Kategorie schon vorschlagen. external_id verhindert, dass ein doppelt ausgeloester Kurzbefehl zweimal bucht. - inbox_items merkt sich die Zahlung ueber das Buchen hinaus. Beim spaeteren CSV-Import greift der Import-Schluessel nicht: Der Text vom Telefon ist selten wortgleich mit dem des Auszugs und die Bank bucht ein bis zwei Tage spaeter. detect_rows vergleicht deshalb zusaetzlich unscharf – exakter Betrag, Datum +/- 3 Tage, je Eintrag nur einmal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -589,3 +589,98 @@ class CategorizationRule(Base):
|
||||
UniqueConstraint("owner_id", "match_field", "pattern", "kind", "amount_cents",
|
||||
name="uq_rule_owner_field_pattern_kind_amount"),
|
||||
)
|
||||
|
||||
|
||||
class TokenScope(str, enum.Enum):
|
||||
"""Was eine Gerätemarke darf. Bewusst eng gehalten.
|
||||
|
||||
Eine Marke liegt auf dem Telefon, oft in einem Kurzbefehl, den man teilt.
|
||||
Sie darf deshalb nur erfassen, niemals lesen: Geht das Gerät verloren, ist
|
||||
kein Datenabfluss möglich.
|
||||
"""
|
||||
|
||||
quick_add = "quick_add"
|
||||
|
||||
|
||||
class ApiToken(Base):
|
||||
"""Zugangsmerkmal für Geräte, die sich nicht anmelden können (Kurzbefehle)."""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
# Nur der Hash wird abgelegt; den Klartext sieht man einmal beim Anlegen.
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
scope: Mapped[TokenScope] = mapped_column(
|
||||
_enum(TokenScope), nullable=False, default=TokenScope.quick_add
|
||||
)
|
||||
# Vorbelegtes Konto, damit der Kurzbefehl ohne Kontoauswahl auskommt.
|
||||
default_account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class InboxSource(str, enum.Enum):
|
||||
shortcut = "shortcut" # per Kurzbefehl vom Telefon erfasst
|
||||
mail = "mail" # aus einer Benachrichtigungs-Mail gelesen
|
||||
|
||||
|
||||
class InboxStatus(str, enum.Enum):
|
||||
booked = "booked" # als Buchung übernommen
|
||||
pending = "pending" # wartet auf Bestätigung
|
||||
unclear = "unclear" # Erkennung unsicher, erst ansehen
|
||||
discarded = "discarded" # keine Zahlung / verworfen
|
||||
|
||||
|
||||
class InboxItem(Base):
|
||||
"""Eine von aussen erfasste Zahlung, bevor der Kontoauszug sie bestätigt.
|
||||
|
||||
Der Kontoauszug kommt erst am Monatsende. Wer eine Zahlung gleich erfasst,
|
||||
weiss noch, wofür sie war – auf dem Auszug steht später oft nur ein
|
||||
nichtssagender Betreibername. Der Eintrag bleibt nach dem Buchen erhalten,
|
||||
damit der spätere CSV-Import ihn wiedererkennt und die Zahlung nicht ein
|
||||
zweites Mal verbucht.
|
||||
"""
|
||||
|
||||
__tablename__ = "inbox_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
owner_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
source: Mapped[InboxSource] = mapped_column(_enum(InboxSource), nullable=False)
|
||||
status: Mapped[InboxStatus] = mapped_column(
|
||||
_enum(InboxStatus), nullable=False, default=InboxStatus.pending
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
date: Mapped[date_t] = mapped_column(Date, nullable=False, index=True)
|
||||
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
|
||||
payee: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
category_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Die erzeugte Buchung. SET NULL, damit das Löschen einer Buchung den
|
||||
# Eingangs-Eintrag nicht mitreisst – sonst käme die Zahlung beim nächsten
|
||||
# Auszug unbemerkt ein zweites Mal herein.
|
||||
transaction_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("transactions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# Kennung der Quelle (Message-ID der Mail, Kennung des Kurzbefehls):
|
||||
# schützt davor, dieselbe Meldung zweimal zu erfassen.
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_id", "source", "external_id", name="uq_inbox_owner_source_external"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user