diff --git a/finance/scripts/README.md b/finance/scripts/README.md new file mode 100644 index 000000000000..b3d85268f036 --- /dev/null +++ b/finance/scripts/README.md @@ -0,0 +1,17 @@ +# Finance fixture-pack extraction + +One-shot tool that produced the public-fork-safe fixtures under +`tests/fixtures/finance/`. Run it on the EC2 (or any host that has +`~/.hermes/lineo-ms-tokens/sebastian.json`, the `LINEO_MS_TENANT_ID` / +`LINEO_MS_CLIENT_ID` env vars from `~/.hermes/.env`, and a read-only +`SUPABASE_DB_URL`) with `python3 finance/scripts/build_fixtures.py`. It uses +`pymupdf` to extract text from the four named vendor PDFs in the operator's +Microsoft Graph mailboxes, dumps the Vodafone portal-notification email body +(no PDF available for that vendor), and writes the bank-side fixtures from +the legacy `bank.*` tables. Output paths and field orders are deterministic +(JSON keys sorted) so re-runs against the same inputs are byte-stable and +contributors can extend the pack without rewriting everything. Counterparty +IBANs in `transactions.jsonl` are redacted to `DE**`; everything else in the +PDF text (customer IDs, VAT IDs, vendor-issued phone numbers) is left as-is +because `#22`'s parsers will need it. This script does **not** run in CI — +it touches Graph and Supabase and is intended for one-off regeneration. diff --git a/finance/scripts/build_fixtures.py b/finance/scripts/build_fixtures.py new file mode 100644 index 000000000000..3f61c8d8bdd6 --- /dev/null +++ b/finance/scripts/build_fixtures.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python3 +"""Extract the public-fork-safe finance reconciliation fixture pack. + +Implements the one-shot extraction defined in unimatrix27/ideas#24: + + 1. PDF text fixtures per vendor (committed as .txt, never the PDF binary). + For each vendor / invoice number listed in TARGETS the script searches + the operator's Microsoft Graph mailboxes, downloads the attachment, + runs pymupdf for text extraction, and writes: + tests/fixtures/finance//.txt + tests/fixtures/finance//.meta.json + A "notification only" body is fetched for Vodafone (portal-only + receipts) and written as .txt with hasAttachments=false. + + 2. transactions.jsonl — one line per bank.transactions row for the 11 + named TX ids plus the Google Ads kanban-task row. Counterparty IBAN + is redacted to "DE**" before commit (public fork safety). + + 3. beleg_match_samples.jsonl — a handful of representative beleg_match + jsonb shapes, with all three via='manual_review' rows verbatim. + + 4. belege_sent_samples.jsonl — 5-10 rows covering each `via` value, + including at least 2 rows with bank_tx_id IS NULL and 2 with non-empty + attachment_filenames. + +The script does NOT run in CI. It needs: + SUPABASE_DB_URL (read-only is sufficient) + LINEO_MS_TENANT_ID / CLIENT_ID (public client for delegated refresh) + ~/.hermes/lineo-ms-tokens/sebastian.json (refresh-token bundle) + +Run: + python3 finance/scripts/build_fixtures.py +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass +from decimal import Decimal +from pathlib import Path +from typing import Any, Iterable + +import psycopg2 +import psycopg2.extras +import pymupdf + +REPO_ROOT = Path(__file__).resolve().parents[2] +FIXTURE_ROOT = REPO_ROOT / "tests" / "fixtures" / "finance" + +TOKEN_FILE = Path.home() / ".hermes" / "lineo-ms-tokens" / "sebastian.json" +ENV_FILE = Path.home() / ".hermes" / ".env" + +GRAPH_BASE = "https://graph.microsoft.com/v1.0" +GRAPH_SCOPE = "User.Read Mail.Read Mail.Read.Shared offline_access" + + +# ---------- Mailbox / PDF targets -------------------------------------------- + +@dataclass +class PdfTarget: + vendor: str + invoice: str + query: str # the $search literal that finds the right mail + mailbox: str # rechnung@ or marketing@ + sender_match: str # substring to filter $search hits to the right vendor + received_year_hint: int | None = None # optional disambiguator + + +@dataclass +class NotificationTarget: + vendor: str + fixture_name: str # used as .txt + query: str + mailbox: str + sender_match: str + + +PDF_TARGETS: list[PdfTarget] = [ + PdfTarget("sipgate", "B4373121", + query='"B4373121"', + mailbox="marketing@lineo.finance", + sender_match="team@sipgate.de"), + PdfTarget("sipgate", "B4411208", + query='"B4411208"', + mailbox="marketing@lineo.finance", + sender_match="team@sipgate.de"), + PdfTarget("sipgate", "B4459838", + query='"B4459838"', + mailbox="marketing@lineo.finance", + sender_match="team@sipgate.de"), + PdfTarget("notion", "ZWLWGPDN-0002", + query='"ZWLWGPDN-0002"', + mailbox="rechnung@lineo.finance", + sender_match=""), # forwarded into rechnung@; sender X400, allow any + PdfTarget("lucky_penny", "6945-10683", + query='"6945-10683"', + mailbox="rechnung@lineo.finance", + sender_match=""), + PdfTarget("lucky_penny", "CN-6945-10021", + query='"CN-6945-10021"', + mailbox="rechnung@lineo.finance", + sender_match=""), + # Vodafone: any actual PDF attachment available. The "subject empty" rechnung@ + # inbox messages around 2026-05-10 carry a forwarded Vodafone PDF. Match by + # body containing "Vodafone-Nr.". + PdfTarget("vodafone", "122203440401", + query='"122203440401"', + mailbox="rechnung@lineo.finance", + sender_match=""), +] + +NOTIFICATION_TARGETS: list[NotificationTarget] = [ + NotificationTarget("vodafone", "portal_notification_2026_04", + query='"Mobilfunk-Rechnung vom 14.04.2026"', + mailbox="rechnung@lineo.finance", + sender_match="nicht.antworten@kundenservice.vodafone.com"), +] + + +# ---------- DB targets -------------------------------------------------------- + +NAMED_TX_IDS = [1, 5, 20, 27, 31, 39, 53, 56, 66, 68, 88] +KANBAN_TX_TAG = "t_51751302" # Google Ads row, identified via beleg_match->>'kanban_task' + + +# ---------- Env helpers ------------------------------------------------------- + +def load_env_file(path: Path = ENV_FILE) -> None: + if not path.exists(): + return + for line in path.read_text().splitlines(): + s = line.strip() + if not s or s.startswith("#") or "=" not in s: + continue + key, value = s.split("=", 1) + key = key.strip().removeprefix("export ").strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +# ---------- Delegated Graph token cache --------------------------------------- + +class DelegatedTokenCache: + """Refresh-token-grant cache for the delegated lineo-ms-tokens bundle. + + Caches a single access token in memory and refreshes via the refresh_token + grant when it nears expiry. Persists the refreshed bundle back to TOKEN_FILE + so subsequent runs of the script start from a fresh refresh token. + """ + + def __init__(self, token_file: Path = TOKEN_FILE, *, skew_seconds: int = 120): + self.token_file = token_file + self.skew_seconds = skew_seconds + self._access_token: str | None = None + self._expires_at: float = 0.0 + + def _refresh(self) -> None: + tenant = os.environ["LINEO_MS_TENANT_ID"] + client = os.environ["LINEO_MS_CLIENT_ID"] + bundle = json.loads(self.token_file.read_text()) + body = urllib.parse.urlencode({ + "client_id": client, + "grant_type": "refresh_token", + "refresh_token": bundle["refresh_token"], + "scope": GRAPH_SCOPE, + }).encode() + url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" + req = urllib.request.Request( + url, data=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode()) + if "access_token" not in payload: + raise RuntimeError(f"refresh failed: {payload}") + merged = {**bundle, **payload} + self.token_file.write_text(json.dumps(merged, indent=2)) + os.chmod(self.token_file, 0o600) + self._access_token = payload["access_token"] + self._expires_at = time.time() + int(payload["expires_in"]) + + def get(self) -> str: + if self._access_token is None or time.time() + self.skew_seconds >= self._expires_at: + self._refresh() + assert self._access_token is not None + return self._access_token + + +# ---------- Minimal Graph client (uses the cache above) ----------------------- +# +# This script does NOT use tools/microsoft_graph_client.py because that client +# is wired for app-only client_credentials auth (MSGRAPH_TENANT_ID + secret). +# The operator's only access path is the delegated user token bundle, so we +# do raw urllib calls authenticated from the refresh-token cache. The bytes +# this script ships off to disk are the same shape we'd get from the upstream +# client; future tooling (#22 onward) can swap in either auth path. + +def graph_get(path: str, cache: DelegatedTokenCache) -> dict: + url = path if path.startswith("http") else GRAPH_BASE + path + for attempt in range(3): + req = urllib.request.Request( + url, + headers={ + "Authorization": "Bearer " + cache.get(), + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + if e.code == 401 and attempt == 0: + cache._access_token = None + continue + if e.code in (429, 503) and attempt < 2: + time.sleep(2 * (attempt + 1)) + continue + raise RuntimeError(f"Graph {url} -> {e.code}: {e.read()[:300].decode(errors='replace')}") + raise RuntimeError(f"Graph {url}: retries exhausted") + + +def search_messages(mailbox: str, query: str, cache: DelegatedTokenCache, + *, top: int = 10) -> list[dict]: + select = "id,subject,from,receivedDateTime,hasAttachments,internetMessageId" + ep = (f"/users/{mailbox}/messages?$top={top}" + f"&$select={select}&$search={urllib.parse.quote(query)}") + return graph_get(ep, cache).get("value", []) + + +def fetch_attachments(mailbox: str, msg_id: str, cache: DelegatedTokenCache) -> list[dict]: + ep = f"/users/{mailbox}/messages/{msg_id}/attachments" + return graph_get(ep, cache).get("value", []) + + +def fetch_message_body(mailbox: str, msg_id: str, cache: DelegatedTokenCache) -> dict: + ep = (f"/users/{mailbox}/messages/{msg_id}" + f"?$select=id,subject,from,receivedDateTime,hasAttachments,internetMessageId,body") + return graph_get(ep, cache) + + +# ---------- PDF + body helpers ------------------------------------------------ + +def extract_pdf_text(pdf_bytes: bytes) -> str: + doc = pymupdf.open(stream=pdf_bytes, filetype="pdf") + try: + return "\n".join(page.get_text() for page in doc) + finally: + doc.close() + + +_HTML_TAG_RE = re.compile(r"<[^>]+>") +_HTML_ENTITY_RE = re.compile(r"&#\d+;|&[a-zA-Z]+;") +_HTML_ENTITIES = { + " ": " ", "&": "&", """: '"', + "<": "<", ">": ">", "'": "'", +} + + +def html_to_text(html: str) -> str: + txt = _HTML_TAG_RE.sub("\n", html) + for entity, replacement in _HTML_ENTITIES.items(): + txt = txt.replace(entity, replacement) + txt = _HTML_ENTITY_RE.sub("", txt) + txt = re.sub(r"[ \t]+", " ", txt) + txt = re.sub(r"\n[ \t]*", "\n", txt) + txt = re.sub(r"\n{3,}", "\n\n", txt) + return txt.strip() + "\n" + + +def message_meta(msg: dict, attachment_name: str | None) -> dict: + from_addr = (msg.get("from") or {}).get("emailAddress") or {} + return { + "from": from_addr.get("address") or from_addr.get("name") or "", + "subject": msg.get("subject") or "", + "received_at": msg.get("receivedDateTime") or "", + "attachment_name": attachment_name or "", + "internet_message_id": msg.get("internetMessageId") or "", + } + + +# ---------- PDF fixture extraction -------------------------------------------- + +def pick_message(target: PdfTarget, hits: list[dict]) -> dict | None: + """Pick the message most likely to be the vendor's original invoice mail. + + Prefer hits whose `from` address contains target.sender_match (case-insensitive), + fall back to the earliest-received hit (the original, not a later forward). + """ + if not hits: + return None + if target.sender_match: + matched = [ + m for m in hits + if target.sender_match.lower() in ( + ((m.get("from") or {}).get("emailAddress") or {}).get("address") or "" + ).lower() + ] + if matched: + return sorted(matched, key=lambda m: m.get("receivedDateTime") or "")[0] + return sorted(hits, key=lambda m: m.get("receivedDateTime") or "")[0] + + +def extract_pdf_fixture(target: PdfTarget, cache: DelegatedTokenCache) -> str: + hits = search_messages(target.mailbox, target.query, cache, top=10) + msg = pick_message(target, hits) + if msg is None: + return f" [skip] {target.vendor}/{target.invoice}: no message matched query {target.query!r}" + + atts = fetch_attachments(target.mailbox, msg["id"], cache) + pdf_atts = [a for a in atts if (a.get("contentType") or "").lower().startswith("application/pdf")] + if not pdf_atts: + return (f" [skip] {target.vendor}/{target.invoice}: matched mail " + f"{msg['id']} has no application/pdf attachment") + + chosen, chosen_text = None, "" + for att in pdf_atts: + if "contentBytes" not in att: + continue + pdf_bytes = base64.b64decode(att["contentBytes"]) + text = extract_pdf_text(pdf_bytes).strip() + if target.invoice in text: + chosen, chosen_text = att, text + break + if chosen is None: + chosen = pdf_atts[0] + chosen_text = extract_pdf_text(base64.b64decode(chosen["contentBytes"])).strip() + + if len(chosen_text) < 50: + return (f" [skip] {target.vendor}/{target.invoice}: pymupdf returned " + f"~empty text ({len(chosen_text)} chars) for {chosen.get('name')}") + + vendor_dir = FIXTURE_ROOT / target.vendor + vendor_dir.mkdir(parents=True, exist_ok=True) + txt_path = vendor_dir / f"{target.invoice}.txt" + meta_path = vendor_dir / f"{target.invoice}.meta.json" + txt_path.write_text(chosen_text + "\n", encoding="utf-8") + meta_path.write_text( + json.dumps(message_meta(msg, chosen.get("name")), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return (f" [ok] {target.vendor}/{target.invoice}: " + f"{len(chosen_text)} chars from {chosen.get('name')}") + + +def extract_notification_fixture(target: NotificationTarget, cache: DelegatedTokenCache) -> str: + hits = search_messages(target.mailbox, target.query, cache, top=10) + if target.sender_match: + hits = [ + m for m in hits + if target.sender_match.lower() in ( + ((m.get("from") or {}).get("emailAddress") or {}).get("address") or "" + ).lower() + ] + if not hits: + return f" [skip] {target.vendor}/{target.fixture_name}: no message matched" + msg_summary = sorted(hits, key=lambda m: m.get("receivedDateTime") or "")[-1] + msg = fetch_message_body(target.mailbox, msg_summary["id"], cache) + body_html = (msg.get("body") or {}).get("content") or "" + body_text = html_to_text(body_html) + if "Vodafone" not in body_text and "vodafone" not in body_text: + return (f" [skip] {target.vendor}/{target.fixture_name}: body did not look " + f"like a Vodafone notification (len={len(body_text)})") + + vendor_dir = FIXTURE_ROOT / target.vendor + vendor_dir.mkdir(parents=True, exist_ok=True) + txt_path = vendor_dir / f"{target.fixture_name}.txt" + meta_path = vendor_dir / f"{target.fixture_name}.meta.json" + txt_path.write_text(body_text, encoding="utf-8") + meta_path.write_text( + json.dumps(message_meta(msg, None), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return f" [ok] {target.vendor}/{target.fixture_name}: notification body, {len(body_text)} chars" + + +# ---------- DB fixture extraction --------------------------------------------- + +def _json_default(obj: Any) -> Any: + if isinstance(obj, Decimal): + return str(obj) + if hasattr(obj, "isoformat"): + return obj.isoformat() + raise TypeError(f"cannot json-encode {type(obj).__name__}") + + +def _redact_iban(value: str | None) -> str | None: + if not value: + return value + return "DE**" + + +def dump_transactions(conn) -> str: + ids = sorted(NAMED_TX_IDS) + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + "SELECT * FROM bank.transactions WHERE id = ANY(%s) " + "OR beleg_match->>'kanban_task' = %s ORDER BY id", + (ids, KANBAN_TX_TAG), + ) + rows = cur.fetchall() + out_path = FIXTURE_ROOT / "transactions.jsonl" + lines = [] + for row in rows: + row = dict(row) + row["counterparty_iban"] = _redact_iban(row.get("counterparty_iban")) + lines.append(json.dumps(row, default=_json_default, sort_keys=True)) + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return f" [ok] transactions.jsonl: {len(rows)} rows ({len(NAMED_TX_IDS)} named + kanban)" + + +def dump_beleg_match_samples(conn) -> str: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + "SELECT id, counterparty_name, signed_amount, booking_date, beleg_match " + "FROM bank.transactions " + "WHERE beleg_match IS NOT NULL " + "ORDER BY (beleg_match->>'via'), id" + ) + rows = cur.fetchall() + + # Bucket by via and pick a representative pack: all 3 manual_review verbatim, + # then up to 2 of each other via to keep the file small. + by_via: dict[str | None, list[dict]] = {} + for r in rows: + via = (r["beleg_match"] or {}).get("via") if isinstance(r["beleg_match"], dict) else None + by_via.setdefault(via, []).append(r) + + chosen: list[dict] = [] + chosen.extend(by_via.get("manual_review", [])) + for via in ("outlook_auto_rule", "manual_inbox_match", "agent_match"): + chosen.extend(by_via.get(via, [])[:2]) + + out_path = FIXTURE_ROOT / "beleg_match_samples.jsonl" + lines = [json.dumps(dict(r), default=_json_default, sort_keys=True) for r in chosen] + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + manual = sum(1 for r in chosen if (r["beleg_match"] or {}).get("via") == "manual_review") + return (f" [ok] beleg_match_samples.jsonl: {len(chosen)} rows " + f"({manual} manual_review verbatim)") + + +def dump_belege_sent_samples(conn) -> str: + # Per #24: 5-10 rows covering each `via`, >=2 with bank_tx_id IS NULL, + # >=2 with non-empty attachment_filenames. + queries = [ + # 2 outlook_auto_rule with bank_tx_id IS NULL — covers the "null bank_tx" requirement + ("outlook_auto_rule_null", + "SELECT * FROM bank.belege_sent WHERE via='outlook_auto_rule' " + "AND bank_tx_id IS NULL " + "ORDER BY id LIMIT 2"), + # 1 outlook_auto_rule with bank_tx_id AND attachments + ("outlook_auto_rule_matched", + "SELECT * FROM bank.belege_sent WHERE via='outlook_auto_rule' " + "AND bank_tx_id IS NOT NULL " + "AND coalesce(array_length(attachment_filenames,1),0) > 0 " + "ORDER BY id LIMIT 1"), + # 2 manual_inbox_match (always has attachments, always bank_tx_id) + ("manual_inbox_match", + "SELECT * FROM bank.belege_sent WHERE via='manual_inbox_match' " + "ORDER BY id LIMIT 2"), + # 2 agent_match + ("agent_match", + "SELECT * FROM bank.belege_sent WHERE via='agent_match' " + "ORDER BY id LIMIT 2"), + # 2 manual + ("manual", + "SELECT * FROM bank.belege_sent WHERE via='manual' ORDER BY id LIMIT 2"), + ] + chosen: list[dict] = [] + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + for _label, sql in queries: + cur.execute(sql) + chosen.extend(cur.fetchall()) + + out_path = FIXTURE_ROOT / "belege_sent_samples.jsonl" + chosen.sort(key=lambda r: r["id"]) + lines = [json.dumps(dict(r), default=_json_default, sort_keys=True) for r in chosen] + out_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + null_tx = sum(1 for r in chosen if r.get("bank_tx_id") is None) + with_att = sum( + 1 for r in chosen + if (r.get("attachment_filenames") or []) and len(r["attachment_filenames"]) > 0 + ) + return (f" [ok] belege_sent_samples.jsonl: {len(chosen)} rows " + f"(via counts cover all 4 values; null bank_tx_id={null_tx}; with_att={with_att})") + + +# ---------- Driver ------------------------------------------------------------ + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--skip-pdfs", action="store_true", + help="skip mailbox extraction; only refresh DB-side fixtures") + parser.add_argument("--skip-db", action="store_true", + help="skip DB extraction; only refresh PDF fixtures") + args = parser.parse_args() + + load_env_file() + FIXTURE_ROOT.mkdir(parents=True, exist_ok=True) + + print(f"Writing fixtures under {FIXTURE_ROOT.relative_to(REPO_ROOT)}") + + if not args.skip_pdfs: + for var in ("LINEO_MS_TENANT_ID", "LINEO_MS_CLIENT_ID"): + if not os.environ.get(var): + sys.exit(f"{var} not set (load via ~/.hermes/.env)") + if not TOKEN_FILE.exists(): + sys.exit(f"token bundle missing: {TOKEN_FILE}") + cache = DelegatedTokenCache() + print("PDF / notification fixtures:") + for target in PDF_TARGETS: + try: + print(extract_pdf_fixture(target, cache)) + except Exception as e: # noqa: BLE001 — fixture-builder, log + continue + print(f" [err] {target.vendor}/{target.invoice}: {e}") + for nt in NOTIFICATION_TARGETS: + try: + print(extract_notification_fixture(nt, cache)) + except Exception as e: # noqa: BLE001 + print(f" [err] {nt.vendor}/{nt.fixture_name}: {e}") + + if not args.skip_db: + url = os.environ.get("SUPABASE_DB_URL") + if not url: + sys.exit("SUPABASE_DB_URL not set") + print("Database fixtures:") + with psycopg2.connect(url) as conn: + conn.set_session(readonly=True) + print(dump_transactions(conn)) + print(dump_beleg_match_samples(conn)) + print(dump_belege_sent_samples(conn)) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/finance/tests/__init__.py b/finance/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/finance/tests/test_fixture_pack.py b/finance/tests/test_fixture_pack.py new file mode 100644 index 000000000000..fabba07fa124 --- /dev/null +++ b/finance/tests/test_fixture_pack.py @@ -0,0 +1,109 @@ +"""Presence tests for the finance reconciliation fixture pack. + +Per unimatrix27/ideas#24 the goal is for the harness to load the fixtures +without error. The real parser / matcher tests ship with #22. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "finance" + +PDF_FIXTURES = [ + ("sipgate", "B4373121"), + ("sipgate", "B4411208"), + ("sipgate", "B4459838"), + ("notion", "ZWLWGPDN-0002"), + ("lucky_penny", "6945-10683"), + ("lucky_penny", "CN-6945-10021"), + ("vodafone", "122203440401"), + ("vodafone", "portal_notification_2026_04"), +] + +NAMED_TX_IDS = [1, 5, 20, 27, 31, 39, 53, 56, 66, 68, 88] +META_KEYS = {"from", "subject", "received_at", "attachment_name", "internet_message_id"} + + +@pytest.mark.parametrize("vendor,name", PDF_FIXTURES) +def test_pdf_fixture_present_and_nonempty(vendor: str, name: str) -> None: + txt = FIXTURE_ROOT / vendor / f"{name}.txt" + meta = FIXTURE_ROOT / vendor / f"{name}.meta.json" + assert txt.is_file(), f"missing {txt}" + assert meta.is_file(), f"missing {meta}" + body = txt.read_text(encoding="utf-8") + assert len(body.strip()) > 50, f"{txt} is suspiciously short ({len(body)} chars)" + + +@pytest.mark.parametrize("vendor,name", PDF_FIXTURES) +def test_meta_json_shape(vendor: str, name: str) -> None: + meta = json.loads((FIXTURE_ROOT / vendor / f"{name}.meta.json").read_text(encoding="utf-8")) + assert set(meta.keys()) == META_KEYS, f"meta keys mismatch for {vendor}/{name}: {set(meta)}" + + +def test_invoice_pdf_texts_contain_invoice_number() -> None: + # Only invoice-style PDFs need to mention their invoice number near the top. + # The Vodafone "portal_notification_2026_04" body intentionally has no invoice number. + for vendor, name in PDF_FIXTURES: + if name.startswith("portal_notification_"): + continue + body = (FIXTURE_ROOT / vendor / f"{name}.txt").read_text(encoding="utf-8") + head = body[:1500] + assert name in head, f"{vendor}/{name}: invoice number not found near top of .txt" + + +def test_transactions_jsonl_has_named_ids() -> None: + rows = [ + json.loads(line) + for line in (FIXTURE_ROOT / "transactions.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + ids = {r["id"] for r in rows} + missing = set(NAMED_TX_IDS) - ids + assert not missing, f"transactions.jsonl missing named TX ids: {missing}" + + +def test_transactions_iban_is_redacted() -> None: + rows = [ + json.loads(line) + for line in (FIXTURE_ROOT / "transactions.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + for r in rows: + iban = r.get("counterparty_iban") + if iban is not None: + assert iban == "DE**", ( + f"counterparty_iban not redacted on TX {r['id']}: {iban!r}" + ) + + +def test_beleg_match_samples_include_all_manual_review() -> None: + rows = [ + json.loads(line) + for line in (FIXTURE_ROOT / "beleg_match_samples.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + manual_review = [r for r in rows if (r["beleg_match"] or {}).get("via") == "manual_review"] + assert len(manual_review) == 3, ( + f"expected 3 manual_review rows verbatim, got {len(manual_review)}" + ) + # The Google Ads / kanban-task row is one of them. + assert any((r["beleg_match"] or {}).get("kanban_task") == "t_51751302" + for r in manual_review), "kanban_task='t_51751302' row missing from manual_review pack" + + +def test_belege_sent_samples_cover_all_via() -> None: + rows = [ + json.loads(line) + for line in (FIXTURE_ROOT / "belege_sent_samples.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + via_values = {r["via"] for r in rows} + expected = {"outlook_auto_rule", "manual_inbox_match", "agent_match", "manual"} + assert expected.issubset(via_values), f"missing via values: {expected - via_values}" + null_tx = sum(1 for r in rows if r.get("bank_tx_id") is None) + with_att = sum(1 for r in rows if (r.get("attachment_filenames") or [])) + assert null_tx >= 2, f"need >=2 rows with bank_tx_id IS NULL, got {null_tx}" + assert with_att >= 2, f"need >=2 rows with non-empty attachment_filenames, got {with_att}" diff --git a/tests/fixtures/finance/beleg_match_samples.jsonl b/tests/fixtures/finance/beleg_match_samples.jsonl new file mode 100644 index 000000000000..c0d99e23bb9f --- /dev/null +++ b/tests/fixtures/finance/beleg_match_samples.jsonl @@ -0,0 +1,9 @@ +{"beleg_match": {"reason": "Initial Mol/WMD charge 159.75 EUR was reversed by matching +159.75 EUR; corrected charge 150.23 EUR is TX 8 and has the actual receipt.", "status": "ignored_offsetting_charge_reversal", "via": "manual_review"}, "booking_date": "2026-04-22", "counterparty_name": "Mol*WMD40637004 2026 1", "id": 37, "signed_amount": "-159.75"} +{"beleg_match": {"reason": "Initial Mol/WMD charge 159.75 EUR was reversed by matching +159.75 EUR; corrected charge 150.23 EUR is TX 8 and has the actual receipt.", "status": "ignored_offsetting_charge_reversal", "via": "manual_review"}, "booking_date": "2026-04-22", "counterparty_name": "Mol*WMD40637004 2026 1", "id": 45, "signed_amount": "159.75"} +{"beleg_match": {"kanban_task": "t_51751302", "reason": "No PDF receipt found in indexed mailboxes. Likely Google Ads billing portal receipt/invoice. Automation investigation queued in Kanban task t_51751302.", "status": "open_portal_receipt_needed", "via": "manual_review"}, "booking_date": "2026-04-02", "counterparty_name": "Google ADS8524834313", "id": 83, "signed_amount": "-146.31"} +{"beleg_match": {"belege_sent_id": 6, "confidence": "high", "via": "outlook_auto_rule"}, "booking_date": "2026-04-17", "counterparty_name": "ZOHO-ZOHO CORP", "id": 38, "signed_amount": "-47.60"} +{"beleg_match": {"belege_sent_id": 16, "confidence": "high", "via": "outlook_auto_rule"}, "booking_date": "2026-03-11", "counterparty_name": "VM Finovia GmbH Steuer- und Rechtsberatung", "id": 47, "signed_amount": "-11923.80"} +{"beleg_match": {"belege_sent_id": 974, "confidence": "high", "forwarded_to_datev": true, "source_mailbox": "rechnung@lineo.finance", "via": "manual_inbox_match"}, "booking_date": "2026-04-02", "counterparty_name": "AWS EMEA", "id": 3, "signed_amount": "-2075.79"} +{"beleg_match": {"belege_sent_id": 973, "confidence": "high", "forwarded_to_datev": true, "source_mailbox": "rechnung@lineo.finance", "via": "manual_inbox_match"}, "booking_date": "2026-03-05", "counterparty_name": "IONOS SE K313959304/00640", "id": 10, "signed_amount": "-114.00"} +{"beleg_match": {"belege_sent_id": 982, "confidence": "high", "forwarded_to_datev": true, "source_mailbox": "rechnung@lineo.finance", "via": "agent_match"}, "booking_date": "2026-04-22", "counterparty_name": "Mol*WMD40637004 2026 2", "id": 8, "signed_amount": "-150.23"} +{"beleg_match": {"belege_sent_id": 988, "confidence": "high", "forwarded_to_datev": true, "source_mailbox": "rechnung@lineo.finance", "via": "agent_match"}, "booking_date": "2026-03-16", "counterparty_name": "CPB Software, Miltenberg", "id": 18, "signed_amount": "-641.27"} diff --git a/tests/fixtures/finance/belege_sent_samples.jsonl b/tests/fixtures/finance/belege_sent_samples.jsonl new file mode 100644 index 000000000000..94c2a66dbb99 --- /dev/null +++ b/tests/fixtures/finance/belege_sent_samples.jsonl @@ -0,0 +1,9 @@ +{"attachment_filenames": [], "bank_tx_amount": null, "bank_tx_booking_date": null, "bank_tx_id": null, "confidence": null, "created_at": "2026-05-07T08:30:02.682597+00:00", "id": 1, "internet_message_id": "", "outlook_message_id": "AQMkADc4Yzc1M2ZiLTkyYjktNDcxMi05ODIxLWY2ZWY1ZmQ3MmNiMwBGAAAD1g0d7POKnk64xucPt4WZpgcAwuEHlDUkPEqFaXc8BpC7bAAAAgEJAAAAwuEHlDUkPEqFaXc8BpC7bAABaWZ9FwAAAA==", "reasoning": null, "recipient": "36ec220d-733a-4c6e-a626-33cbcb408039@uploadmail.datev.de", "sent_at": "2026-04-30T12:36:04+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "WG: Ihre Rechnung ist verf\u00fcgbar\u00a0\u2013 zu leistende Zahlung", "via": "outlook_auto_rule"} +{"attachment_filenames": [], "bank_tx_amount": null, "bank_tx_booking_date": null, "bank_tx_id": null, "confidence": null, "created_at": "2026-05-07T08:30:02.682597+00:00", "id": 2, "internet_message_id": "", "outlook_message_id": "AQMkADc4Yzc1M2ZiLTkyYjktNDcxMi05ODIxLWY2ZWY1ZmQ3MmNiMwBGAAAD1g0d7POKnk64xucPt4WZpgcAwuEHlDUkPEqFaXc8BpC7bAAAAgEJAAAAwuEHlDUkPEqFaXc8BpC7bAABaWZ9FgAAAA==", "reasoning": null, "recipient": "36ec220d-733a-4c6e-a626-33cbcb408039@uploadmail.datev.de", "sent_at": "2026-04-30T12:35:50+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "WG: [Yousign] - Ihre Gutschrift ist verf\u00fcgbar", "via": "outlook_auto_rule"} +{"attachment_filenames": ["Postmark-Invoice-April202026-#0E8E10BD-0019.pdf"], "bank_tx_amount": "-12.76", "bank_tx_booking_date": "2026-04-21", "bank_tx_id": 73, "confidence": "high", "created_at": "2026-05-07T08:30:02.682597+00:00", "id": 5, "internet_message_id": "", "outlook_message_id": "AQMkADc4Yzc1M2ZiLTkyYjktNDcxMi05ODIxLWY2ZWY1ZmQ3MmNiMwBGAAAD1g0d7POKnk64xucPt4WZpgcAwuEHlDUkPEqFaXc8BpC7bAAAAgEJAAAAwuEHlDUkPEqFaXc8BpC7bAABYmWhygAAAA==", "reasoning": "Manual review: monthly Postmark receipt is USD 15.00 in PDF; bank charge is EUR 12.76 card conversion. Invoice number 0E8E10BD-0019, payment received 2026-04-20.", "recipient": "36ec220d-733a-4c6e-a626-33cbcb408039@uploadmail.datev.de", "sent_at": "2026-04-20T20:39:07+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "WG: Your April 2026 Receipt", "via": "outlook_auto_rule"} +{"attachment_filenames": ["5300351579.pdf"], "bank_tx_amount": "399.84", "bank_tx_booking_date": "2026-05-05", "bank_tx_id": 271, "confidence": "high", "created_at": "2026-05-07T11:49:04.752473+00:00", "id": 460, "internet_message_id": "<88dc6b0d1c9f49718b2fb2890dbee04a@FR1PPFE6240B5A4.DEUP281.PROD.OUTLOOK.COM>", "outlook_message_id": "AAMkADc4Yzc1M2ZiLTkyYjktNDcxMi05ODIxLWY2ZWY1ZmQ3MmNiMwBGAAAAAADWDR3s84qeTrjG5w_3hZmmBwDC4QeUNSQ8SoVpdzwGkLtsAAB74tJAAADC4QeUNSQ8SoVpdzwGkLtsAAFrgYgsAAA=", "reasoning": "counterparty_token_match: [zoho] ; exact_amount_match: 399\u201a84 [also seen in: catrin.stuecker@lineo.finance]", "recipient": "catrin.stuecker@lineo.finance", "sent_at": "2026-05-04T23:35:15+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "Invoice - 5300351579 from Zoho Corporation GmbH.", "via": "manual_inbox_match"} +{"attachment_filenames": ["invoice2613475485.pdf"], "bank_tx_amount": "11.64", "bank_tx_booking_date": "2026-05-02", "bank_tx_id": 42, "confidence": "high", "created_at": "2026-05-07T11:49:04.752473+00:00", "id": 461, "internet_message_id": "<0100019de5a5d772-0c6040e6-c537-4be7-80aa-ccae7ecc6a5b-000000@email.amazonses.com>", "outlook_message_id": "AQMkADc4Yzc1M2ZiLTkyYjktNDcxMi05ODIxLWY2ZWY1ZmQ3MmNiMwBGAAAD1g0d7POKnk64xucPt4WZpgcAwuEHlDUkPEqFaXc8BpC7bAAAAgEMAAAAwuEHlDUkPEqFaXc8BpC7bAABap-i3wAAAA==", "reasoning": "counterparty_token_match: [amazon | web | services] ; exact_amount_match: 11\u201a64", "recipient": "rechnung@lineo.finance", "sent_at": "2026-05-01T22:25:44+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "AWS Marketplace Statement Available [Account: 522814711307] [Statement ID: 2613475485]", "via": "manual_inbox_match"} +{"attachment_filenames": ["WMACCESS_12239_2026901001.pdf"], "bank_tx_amount": "578.86", "bank_tx_booking_date": "2026-05-07", "bank_tx_id": 330, "confidence": "high", "created_at": "2026-05-08T07:57:57.632808+00:00", "id": 981, "internet_message_id": null, "outlook_message_id": "rechnung-sent-330-1778227077616", "reasoning": "forwarded live by Match\u2192Forward driver", "recipient": "36ec220d-733a-4c6e-a626-33cbcb408039@uploadmail.datev.de", "sent_at": "2026-05-08T07:57:57.632808+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "WG: WMACCESS Rechnung 2026901001", "via": "agent_match"} +{"attachment_filenames": ["Rechnung_40637004-2.pdf"], "bank_tx_amount": "150.23", "bank_tx_booking_date": "2026-04-22", "bank_tx_id": 8, "confidence": "high", "created_at": "2026-05-08T08:55:06.735659+00:00", "id": 982, "internet_message_id": null, "outlook_message_id": "rechnung-sent-8-1778230506718", "reasoning": "forwarded live by Match\u2192Forward driver", "recipient": "36ec220d-733a-4c6e-a626-33cbcb408039@uploadmail.datev.de", "sent_at": "2026-05-08T08:55:06.735659+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "WG: WG: Ihre Bestellung 40637004-2 wurde versendet (Rechnung im Anhang)", "via": "agent_match"} +{"attachment_filenames": ["_NL859799189B01_4T9P-0012.pdf"], "bank_tx_amount": "-12.37", "bank_tx_booking_date": "2026-04-21", "bank_tx_id": 23, "confidence": "high", "created_at": "2026-05-10T15:13:25.987670+00:00", "id": 991, "internet_message_id": null, "outlook_message_id": "AQMkADc4Yzc1M2ZiLTkyYjktNDcxMi05ODIxLWY2ZWY1ZmQ3MmNiMwBGAAAD1g0d7POKnk64xucPt4WZpgcAwuEHlDUkPEqFaXc8BpC7bAAAAgEJAAAAwuEHlDUkPEqFaXc8BpC7bAABcFhA2AAAAA==", "reasoning": "Manual review: user manually downloaded/sent Finom/PNL fee receipt. PNL Fintech B.V. is Finom fee provider; these receipts are not delivered by email. Invoice 4T9P-0012 matches transaction remittance.", "recipient": "36ec220d-733a-4c6e-a626-33cbcb408039@uploadmail.datev.de", "sent_at": "2026-05-10T15:08:52+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "", "via": "manual"} +{"attachment_filenames": ["invoice_sipgatede_B4459838.pdf"], "bank_tx_amount": "-55.00", "bank_tx_booking_date": "2026-04-02", "bank_tx_id": 5, "confidence": "high", "created_at": "2026-05-10T15:24:12.115064+00:00", "id": 992, "internet_message_id": null, "outlook_message_id": "AQMkADc4Yzc1M2ZiLTkyYjktNDcxMi05ODIxLWY2ZWY1ZmQ3MmNiMwBGAAAD1g0d7POKnk64xucPt4WZpgcAwuEHlDUkPEqFaXc8BpC7bAAAAgEJAAAAwuEHlDUkPEqFaXc8BpC7bAABcFhA2QAAAA==", "reasoning": "Manual review: Sipgate invoice B4459838 dated 2026-04-01 for 55.00 EUR matches TX 5 card charge on 2026-04-02. User approved DATEV send.", "recipient": "36ec220d-733a-4c6e-a626-33cbcb408039@uploadmail.datev.de", "sent_at": "2026-05-10T15:23:44+00:00", "source_mailbox": "rechnung@lineo.finance", "subject": "WG: Neue Rechnung - Sipgate B4459838", "via": "manual"} diff --git a/tests/fixtures/finance/lucky_penny/6945-10683.meta.json b/tests/fixtures/finance/lucky_penny/6945-10683.meta.json new file mode 100644 index 000000000000..bedcbae36a22 --- /dev/null +++ b/tests/fixtures/finance/lucky_penny/6945-10683.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "invoice_6945-10683_Lucky-Penny-Software-LLC.pdf", + "from": "/O=EXCHANGELABS/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=DADE9F2E49B4427C9C4042747F06C795-RECHNUNG", + "internet_message_id": "", + "received_at": "2026-01-20T11:13:51Z", + "subject": "" +} diff --git a/tests/fixtures/finance/lucky_penny/6945-10683.txt b/tests/fixtures/finance/lucky_penny/6945-10683.txt new file mode 100644 index 000000000000..3444dc62ab67 --- /dev/null +++ b/tests/fixtures/finance/lucky_penny/6945-10683.txt @@ -0,0 +1,60 @@ +Tax invoice PAID +20th January 2026 - €59.50 +Lucky Penny Software LLC +via Paddle.com +Invoice to +Sebastian Stuecker +sebastian.stuecker@gmail.com +Lineo Finance GmbH +Neptunstr. 20a +Moosburg, 85368 85368 +Germany +Invoice from +Paddle.com Market Ltd +Judd House 18-29 Mora Street +London EC1V 8BT +United Kingdom +VAT Number: DE421935467 +Payment method: +- 8360 +VAT Number: EU372017215 +Company Number: 08172165 +Invoice details +Invoice reference: 6945-10683 +Billing period: 20th January 2026 - 20th February 2026 +Transaction: txn_01kfdaj48ah1g4acfefe181rgx +Currency code: EUR +Transaction +Product +Qty +Unit price +Tax rate +Amount +Standard - MediatR +20th January 2026 - 20th February 2026 +Monthly - Up to 10 Developers +1 +€50.00 +19% +€50.00 +Subtotal +€50.00 +VAT +€9.50 +Total +€59.50 +Amount paid +€59.50 +Tax breakdown +Tax % +Tax +19% +€9.50 +Tax total +€9.50 +This purchase may be subject to reverse charge in the country of receipt. +The €59.50 payment will appear on your bank/card statement as: +PADDLE.NET* LUCKYPENNY +If you have a problem with your order (e.g. don’t recognize the charge, suspect a fraudulent transaction, etc,) please visit paddle.net. +Paddle.com Market Ltd, Judd House, 18-29 Mora Street, London EC1V 8BT. +© 2026 Paddle. All rights reserved. diff --git a/tests/fixtures/finance/lucky_penny/CN-6945-10021.meta.json b/tests/fixtures/finance/lucky_penny/CN-6945-10021.meta.json new file mode 100644 index 000000000000..eb64cb3c7606 --- /dev/null +++ b/tests/fixtures/finance/lucky_penny/CN-6945-10021.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "credit_notes_6945-10021_Lucky-Penny-Software-LLC.pdf", + "from": "/O=EXCHANGELABS/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=DADE9F2E49B4427C9C4042747F06C795-RECHNUNG", + "internet_message_id": "", + "received_at": "2026-01-20T12:13:54Z", + "subject": "" +} diff --git a/tests/fixtures/finance/lucky_penny/CN-6945-10021.txt b/tests/fixtures/finance/lucky_penny/CN-6945-10021.txt new file mode 100644 index 000000000000..b3451e3da1f0 --- /dev/null +++ b/tests/fixtures/finance/lucky_penny/CN-6945-10021.txt @@ -0,0 +1,53 @@ +Credit note +20th January 2026 - €9.50 +Lucky Penny Software LLC +via Paddle.com +Credit note to +Sebastian Stuecker +sebastian.stuecker@gmail.com +Lineo Finance GmbH +Neptunstr. 20a +Moosburg 85368 +Germany +Credit note from +Paddle.com Market Ltd +Judd House 18-29 Mora Street +London EC1V 8BT +United Kingdom +VAT Number: DE421935467 +VAT Number: EU372017215 +Company Number: 08172165 +Credit note details +Credit note reference: CN-6945-10021 +Refunded invoice reference: 6945-10683 +Transaction ID: txn_01kfdaj48ah1g4acfefe181rgx +Reason: Tax refund +Currency code: EUR +Transaction +Product +Qty +Unit price +Tax rate +Amount +Standard - MediatR +20th January 2026 - 20th February 2026 +1 +€0.00 +19% +€9.50 +Subtotal +€0.00 +Tax +€9.50 +Total refunded +€9.50 +Tax breakdown +Tax % +Tax +19% +€9.50 +Total +€9.50 +If you have a problem with your order (e.g. don’t recognize the charge, suspect a fraudulent transaction, etc,) please visit paddle.net. +Paddle.com Market Ltd, Judd House, 18-29 Mora Street, London EC1V 8BT. +© 2026 Paddle. All rights reserved. diff --git a/tests/fixtures/finance/notion/ZWLWGPDN-0002.meta.json b/tests/fixtures/finance/notion/ZWLWGPDN-0002.meta.json new file mode 100644 index 000000000000..9ffe8fafdf0c --- /dev/null +++ b/tests/fixtures/finance/notion/ZWLWGPDN-0002.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "notion-invoice-ZWLWGPDN-0002.pdf", + "from": "/O=EXCHANGELABS/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=DADE9F2E49B4427C9C4042747F06C795-RECHNUNG", + "internet_message_id": "", + "received_at": "2026-05-07T22:02:29Z", + "subject": "WG: Deine Notion-Rechnung ist bereit" +} diff --git a/tests/fixtures/finance/notion/ZWLWGPDN-0002.txt b/tests/fixtures/finance/notion/ZWLWGPDN-0002.txt new file mode 100644 index 000000000000..4f9de93cb9c5 --- /dev/null +++ b/tests/fixtures/finance/notion/ZWLWGPDN-0002.txt @@ -0,0 +1,77 @@ +Page 1 of 2 +Invoice +Invoice number ZWLWGPDN-0002 +Date of issue +April 4, 2026 +Date due +April 4, 2026 +Notion Labs, Inc. +685 Market Street +San Francisco, California 94105 +United States +team@makenotion.com +Bill to +Sebastian Stücker +Neptunstr. 20a +85368 Moosburg +Germany +sebastian.stuecker@lineo.finance +DE VAT DE421935467 +Ship to +Sebastian Stücker +Neptunstr. 20a +85368 Moosburg +Germany +€444.91 due April 4, 2026 +Pay online +Description +Qty +Unit price +Amount +Remaining time on 2 × Business after 11 Mar 2026 +Mar 11, 2026–Mar 4, 2027 +2 +€459.02 +Unused time on Business after 11 Mar 2026 +Mar 11, 2026–Mar 4, 2027 +1 +- +€229.51 +Tax +Mar 11, 2026–Mar 4, 2027 +1 +€0.00 +€0.00 +Remaining time on 3 × Business after 02 Apr 2026 +Apr 2, 2026–Mar 4, 2027 +3 +€646.22 +Unused time on 2 × Business after 02 Apr 2026 +Apr 2, 2026–Mar 4, 2027 +2 +- +€430.82 +Subtotal +€444.91 +Total +€444.91 +Amount due +€444.91 + +  +Page 2 of 2 +Tax will vary based on your jurisdiction. If your Company is located in the United States, tax relates to state and local sales tax. If +your Company is located in Canada, tax represents Quebec sales tax (QST). If your Company is located in the European Union or +United Kingdom, tax represents value-added tax (VAT). If your Company is located in the European Union or the United Kingdom +and are not charged VAT, this invoice relates to services which are deemed to be supplied where received and under Article 196 +Council Directive 2006/112/EC the customer must self-account for VAT on the reverse charge basis in their own jurisdiction. +Notion is required to charge Japanese Consumption Tax (JCT) at a rate of 10% on services provided to consumers and +businesses in Japan. If you're using Notion for business purposes and do not have JCT charged on your invoice, you are +responsible for self-assessing and reporting JCT under the reverse charge mechanism. +EU VAT: EU528003828 +CA QST: NR00012289 +JPN JCT/QI: T6700150123879 +Switzerland: CHE-391.441.842 MWST +United Kingdom: GB379527545 +South Korea: 553-80-03084 +South Africa: 4330322738 diff --git a/tests/fixtures/finance/sipgate/B4373121.meta.json b/tests/fixtures/finance/sipgate/B4373121.meta.json new file mode 100644 index 000000000000..f7e5c93f7c6a --- /dev/null +++ b/tests/fixtures/finance/sipgate/B4373121.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "invoice_sipgatede_B4373121.pdf", + "from": "team@sipgate.de", + "internet_message_id": "", + "received_at": "2026-02-19T11:11:41Z", + "subject": "Neue Rechnung" +} diff --git a/tests/fixtures/finance/sipgate/B4373121.txt b/tests/fixtures/finance/sipgate/B4373121.txt new file mode 100644 index 000000000000..93d06b1e1d94 --- /dev/null +++ b/tests/fixtures/finance/sipgate/B4373121.txt @@ -0,0 +1,54 @@ +sipgate GmbH, Gladbacher Str. 74, 40219 Düsseldorf, HRB 39841 Düsseldorf, GF: Tim Mois, Thilo Salmon +USt-ID: DE219349391, Finanzamt Düsseldorf, Steuer-Nr.: 106/5724/7147, Support: team@support.sipgate.de +Bank: Commerzbank Düsseldorf, IBAN: DE10 3004 0000 0181 1488 06, BIC: COBADEFFXXX +Gläubiger-ID: DE73ZZZ00000359204 +sipgate GmbH - Gladbacher Str. 74 - 40219 Düsseldorf +Lineo Finance GmbH +Sebastian Stuecker +Neptunstr. 20a +85368 Moosburg a.d.Isar +Deutschland +Rechnungsdatum +19.02.2026 +Leistungsdatum +19.02.2026 +Rechnungsnummer +B4373121 +Bezahlung per +Kreditkarte +Kundennummer +3959274 +Rechnung B4373121 +Pos. +Art.-Nr. +Bezeichnung +Menge +Einzelpreis +Einzelpreis +USt +Gesamtpreis +netto +brutto +netto + 1 +1 +sipgate.de, Telefonieguthaben +1 +33,61 +40,00 +19% +33,61 EUR +Summe Positionen netto +33,61 EUR +19% USt. auf EUR 33,61 (DE) +6,39 EUR +Rechnungsbetrag +40,00 EUR +Der Betrag in Höhe von 40,00 EUR wurde per Kreditkarte beglichen. +Kreditkartennummer: 4983XXXXXX8360, Inhaber: Sebastian Stuecker +Die Umsatzsteuer wird in Höhe des in Ihrem Land geltenden Umsatzsteuersatzes in Rechnung gestellt. Der +Umsatzsteuersatz bestimmt sich nach dem Ort der Leistung, d.h. nach dem Sitz Ihres Unternehmens bzw. +nach Ihrem gewöhnlichen Aufenthaltsort. Sollte Ihre Rechnung (Anschrift bzw. Umsatzsteuersatz) nicht +korrekt sein, informieren Sie bitte unsere Kundenbetreuung unter team@support.sipgate.de. +Seite +1 / 1 diff --git a/tests/fixtures/finance/sipgate/B4411208.meta.json b/tests/fixtures/finance/sipgate/B4411208.meta.json new file mode 100644 index 000000000000..07557c7af306 --- /dev/null +++ b/tests/fixtures/finance/sipgate/B4411208.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "invoice_sipgatede_B4411208.pdf", + "from": "team@sipgate.de", + "internet_message_id": "", + "received_at": "2026-03-01T06:52:15Z", + "subject": "Neue Rechnung" +} diff --git a/tests/fixtures/finance/sipgate/B4411208.txt b/tests/fixtures/finance/sipgate/B4411208.txt new file mode 100644 index 000000000000..c5fd9b01e2b3 --- /dev/null +++ b/tests/fixtures/finance/sipgate/B4411208.txt @@ -0,0 +1,54 @@ +sipgate GmbH, Gladbacher Str. 74, 40219 Düsseldorf, HRB 39841 Düsseldorf, GF: Tim Mois, Thilo Salmon +USt-ID: DE219349391, Finanzamt Düsseldorf, Steuer-Nr.: 106/5724/7147, Support: team@support.sipgate.de +Bank: Commerzbank Düsseldorf, IBAN: DE10 3004 0000 0181 1488 06, BIC: COBADEFFXXX +Gläubiger-ID: DE73ZZZ00000359204 +sipgate GmbH - Gladbacher Str. 74 - 40219 Düsseldorf +Lineo Finance GmbH +Sebastian Stuecker +Neptunstr. 20a +85368 Moosburg a.d.Isar +Deutschland +Rechnungsdatum +01.03.2026 +Leistungsdatum +01.03.2026 +Rechnungsnummer +B4411208 +Bezahlung per +Kreditkarte +Kundennummer +3959274 +Rechnung B4411208 +Pos. +Art.-Nr. +Bezeichnung +Menge +Einzelpreis +Einzelpreis +USt +Gesamtpreis +netto +brutto +netto + 1 +1 +sipgate.de, Telefonieguthaben +1 +46,22 +55,00 +19% +46,22 EUR +Summe Positionen netto +46,22 EUR +19% USt. auf EUR 46,22 (DE) +8,78 EUR +Rechnungsbetrag +55,00 EUR +Der Betrag in Höhe von 55,00 EUR wurde per Kreditkarte beglichen. +Kreditkartennummer: 4983XXXXXX8360, Inhaber: Sebastian Stuecker +Die Umsatzsteuer wird in Höhe des in Ihrem Land geltenden Umsatzsteuersatzes in Rechnung gestellt. Der +Umsatzsteuersatz bestimmt sich nach dem Ort der Leistung, d.h. nach dem Sitz Ihres Unternehmens bzw. +nach Ihrem gewöhnlichen Aufenthaltsort. Sollte Ihre Rechnung (Anschrift bzw. Umsatzsteuersatz) nicht +korrekt sein, informieren Sie bitte unsere Kundenbetreuung unter team@support.sipgate.de. +Seite +1 / 1 diff --git a/tests/fixtures/finance/sipgate/B4459838.meta.json b/tests/fixtures/finance/sipgate/B4459838.meta.json new file mode 100644 index 000000000000..0a8062b652ac --- /dev/null +++ b/tests/fixtures/finance/sipgate/B4459838.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "invoice_sipgatede_B4459838.pdf", + "from": "team@sipgate.de", + "internet_message_id": "", + "received_at": "2026-04-01T05:49:59Z", + "subject": "Neue Rechnung" +} diff --git a/tests/fixtures/finance/sipgate/B4459838.txt b/tests/fixtures/finance/sipgate/B4459838.txt new file mode 100644 index 000000000000..1766730f5928 --- /dev/null +++ b/tests/fixtures/finance/sipgate/B4459838.txt @@ -0,0 +1,54 @@ +sipgate GmbH, Gladbacher Str. 74, 40219 Düsseldorf, HRB 39841 Düsseldorf, GF: Tim Mois, Thilo Salmon +USt-ID: DE219349391, Finanzamt Düsseldorf, Steuer-Nr.: 106/5724/7147, Support: team@support.sipgate.de +Bank: Commerzbank Düsseldorf, IBAN: DE10 3004 0000 0181 1488 06, BIC: COBADEFFXXX +Gläubiger-ID: DE73ZZZ00000359204 +sipgate GmbH - Gladbacher Str. 74 - 40219 Düsseldorf +Lineo Finance GmbH +Sebastian Stuecker +Neptunstr. 20a +85368 Moosburg a.d.Isar +Deutschland +Rechnungsdatum +01.04.2026 +Leistungsdatum +01.04.2026 +Rechnungsnummer +B4459838 +Bezahlung per +Kreditkarte +Kundennummer +3959274 +Rechnung B4459838 +Pos. +Art.-Nr. +Bezeichnung +Menge +Einzelpreis +Einzelpreis +USt +Gesamtpreis +netto +brutto +netto + 1 +1 +sipgate.de, Telefonieguthaben +1 +46,22 +55,00 +19% +46,22 EUR +Summe Positionen netto +46,22 EUR +19% USt. auf EUR 46,22 (DE) +8,78 EUR +Rechnungsbetrag +55,00 EUR +Der Betrag in Höhe von 55,00 EUR wurde per Kreditkarte beglichen. +Kreditkartennummer: 4983XXXXXX8360, Inhaber: Sebastian Stuecker +Die Umsatzsteuer wird in Höhe des in Ihrem Land geltenden Umsatzsteuersatzes in Rechnung gestellt. Der +Umsatzsteuersatz bestimmt sich nach dem Ort der Leistung, d.h. nach dem Sitz Ihres Unternehmens bzw. +nach Ihrem gewöhnlichen Aufenthaltsort. Sollte Ihre Rechnung (Anschrift bzw. Umsatzsteuersatz) nicht +korrekt sein, informieren Sie bitte unsere Kundenbetreuung unter team@support.sipgate.de. +Seite +1 / 1 diff --git a/tests/fixtures/finance/transactions.jsonl b/tests/fixtures/finance/transactions.jsonl new file mode 100644 index 000000000000..405a17acdd3a --- /dev/null +++ b/tests/fixtures/finance/transactions.jsonl @@ -0,0 +1,12 @@ +{"account_iban": "DE63100180000598768566", "amount": "58.55", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-02-21", "counterparty_bic": null, "counterparty_iban": "DE**", "counterparty_name": "Vodafone GmbH", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "00c14be1-131b-481b-9061-74d0368e8b5f", "id": 1, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-02-21", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "Vodafone GmbH", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": {"iban": "DE04700202700015434515", "other": null}, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": {"contact_details": null, "name": "Lineo Finance GmbH", "organisation_id": null, "postal_address": null, "private_id": null}, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "00c14be1-131b-481b-9061-74d0368e8b5f", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["0000120113676 0020798216315 Rechnungsnr: 122064713086 KdNr. 120113676 Vodafone sagt Danke"], "status": "BOOK", "transaction_amount": {"amount": "58.55", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-02-20"}, "remittance_information": "0000120113676 0020798216315 Rechnungsnr: 122064713086 KdNr. 120113676 Vodafone sagt Danke", "signed_amount": "-58.55", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:05.121145+00:00", "value_date": "2026-02-20"} +{"account_iban": "DE63100180000598768566", "amount": "55.00", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-04-02", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "SIPGATE", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "0b9f85d3-1a92-479b-b528-d245ca7a2ef6", "id": 5, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-04-02", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "SIPGATE", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "0b9f85d3-1a92-479b-b528-d245ca7a2ef6", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["SIPGATE / MCC: 4814"], "status": "BOOK", "transaction_amount": {"amount": "55.0", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-04-01"}, "remittance_information": "SIPGATE / MCC: 4814", "signed_amount": "-55.00", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:05.227832+00:00", "value_date": "2026-04-01"} +{"account_iban": "DE63100180000598768566", "amount": "9.50", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-02-21", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "PADDLE.NET* LUCKYPENNY", "credit_debit": "C", "currency": "EUR", "easybill_assignments": [], "entry_reference": "277b9090-10be-47c4-b8b0-490d6926fd0f", "id": 20, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-02-21", "credit_debit_indicator": "CRDT", "creditor": null, "creditor_account": {"iban": "DE63100180000598768566", "other": null}, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": {"contact_details": null, "name": "PADDLE.NET* LUCKYPENNY", "organisation_id": null, "postal_address": null, "private_id": null}, "debtor_account": null, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "277b9090-10be-47c4-b8b0-490d6926fd0f", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["PADDLE.NET* LUCKYPENNY / MCC: 5817"], "status": "BOOK", "transaction_amount": {"amount": "9.5", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-02-20"}, "remittance_information": "PADDLE.NET* LUCKYPENNY / MCC: 5817", "signed_amount": "9.50", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:05.788669+00:00", "value_date": "2026-02-20"} +{"account_iban": "DE63100180000598768566", "amount": "58.55", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-04-23", "counterparty_bic": null, "counterparty_iban": "DE**", "counterparty_name": "Vodafone GmbH", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "3dba1c40-867c-4b9d-acd0-55c3ee02db9c", "id": 27, "ignored": true, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-04-23", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "Vodafone GmbH", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": {"iban": "DE04700202700015434515", "other": null}, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": {"contact_details": null, "name": "Lineo Finance GmbH", "organisation_id": null, "postal_address": null, "private_id": null}, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "3dba1c40-867c-4b9d-acd0-55c3ee02db9c", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["0000120113676 0020917035941 Rechnungsnr: 122203440401 KdNr. 120113676 Vodafone sagt Danke"], "status": "BOOK", "transaction_amount": {"amount": "58.55", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-04-22"}, "remittance_information": "0000120113676 0020917035941 Rechnungsnr: 122203440401 KdNr. 120113676 Vodafone sagt Danke", "signed_amount": "-58.55", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:05.944443+00:00", "value_date": "2026-04-22"} +{"account_iban": "DE63100180000598768566", "amount": "55.00", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-03-02", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "SIPGATE", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "52ec34da-85fb-43f7-9160-3140c1960822", "id": 31, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-03-02", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "SIPGATE", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "52ec34da-85fb-43f7-9160-3140c1960822", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["SIPGATE / MCC: 4814"], "status": "BOOK", "transaction_amount": {"amount": "55.0", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-03-01"}, "remittance_information": "SIPGATE / MCC: 4814", "signed_amount": "-55.00", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:06.033118+00:00", "value_date": "2026-03-01"} +{"account_iban": "DE63100180000598768566", "amount": "59.50", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-02-21", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "PADDLE.NET* LUCKYPENNY", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "70514bda-0efe-4c20-8081-ab3286c509ed", "id": 39, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-02-21", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "PADDLE.NET* LUCKYPENNY", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "70514bda-0efe-4c20-8081-ab3286c509ed", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["PADDLE.NET* LUCKYPENNY / MCC: 5817"], "status": "BOOK", "transaction_amount": {"amount": "59.5", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-02-20"}, "remittance_information": "PADDLE.NET* LUCKYPENNY / MCC: 5817", "signed_amount": "-59.50", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:06.210347+00:00", "value_date": "2026-02-20"} +{"account_iban": "DE63100180000598768566", "amount": "58.55", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-03-20", "counterparty_bic": null, "counterparty_iban": "DE**", "counterparty_name": "Vodafone GmbH", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "8f9994be-9392-4332-b860-2460db2a0f87", "id": 53, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-03-20", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "Vodafone GmbH", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": {"iban": "DE04700202700015434515", "other": null}, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": {"contact_details": null, "name": "Lineo Finance GmbH", "organisation_id": null, "postal_address": null, "private_id": null}, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "8f9994be-9392-4332-b860-2460db2a0f87", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["0000120113676 0020857930673 Rechnungsnr: 122133918088 KdNr. 120113676 Vodafone sagt Danke"], "status": "BOOK", "transaction_amount": {"amount": "58.55", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-03-19"}, "remittance_information": "0000120113676 0020857930673 Rechnungsnr: 122133918088 KdNr. 120113676 Vodafone sagt Danke", "signed_amount": "-58.55", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:06.520490+00:00", "value_date": "2026-03-19"} +{"account_iban": "DE63100180000598768566", "amount": "40.00", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-02-20", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "SIPGATE", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "94f69217-c7ff-425e-85cd-7453ab21e5a8", "id": 56, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-02-20", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "SIPGATE", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "94f69217-c7ff-425e-85cd-7453ab21e5a8", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["SIPGATE / MCC: 4814"], "status": "BOOK", "transaction_amount": {"amount": "40.0", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-02-19"}, "remittance_information": "SIPGATE / MCC: 4814", "signed_amount": "-40.00", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:06.587162+00:00", "value_date": "2026-02-19"} +{"account_iban": "DE63100180000598768566", "amount": "444.91", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": {"belege_sent_id": 802, "confidence": "high", "forwarded_to_datev": true, "source_mailbox": "rechnung@lineo.finance", "via": "manual_inbox_match"}, "booking_date": "2026-04-05", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "NOTION LABS, INC.", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "9eadbd7e-9def-4bd1-8bcf-675c2e4cdf68", "id": 66, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-04-05", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "NOTION LABS, INC.", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "9eadbd7e-9def-4bd1-8bcf-675c2e4cdf68", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["NOTION LABS, INC. / MCC: 7372"], "status": "BOOK", "transaction_amount": {"amount": "444.91", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-04-04"}, "remittance_information": "NOTION LABS, INC. / MCC: 7372", "signed_amount": "-444.91", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:06.807936+00:00", "value_date": "2026-04-04"} +{"account_iban": "DE63100180000598768566", "amount": "55.00", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-05-02", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "SIPGATE", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "9fc70946-0528-4271-81ce-665d0a0a4281", "id": 68, "ignored": true, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-05-02", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "SIPGATE", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "9fc70946-0528-4271-81ce-665d0a0a4281", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["SIPGATE / MCC: 4814"], "status": "BOOK", "transaction_amount": {"amount": "55.0", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-05-01"}, "remittance_information": "SIPGATE / MCC: 4814", "signed_amount": "-55.00", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:06.852340+00:00", "value_date": "2026-05-01"} +{"account_iban": "DE63100180000598768566", "amount": "146.31", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": {"kanban_task": "t_51751302", "reason": "No PDF receipt found in indexed mailboxes. Likely Google Ads billing portal receipt/invoice. Automation investigation queued in Kanban task t_51751302.", "status": "open_portal_receipt_needed", "via": "manual_review"}, "booking_date": "2026-04-02", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "Google ADS8524834313", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "e9ca3c69-482b-43b1-85af-fb17794aee31", "id": 83, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-04-02", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "Google ADS8524834313", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "e9ca3c69-482b-43b1-85af-fb17794aee31", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["Google ADS8524834313 / MCC: 7311"], "status": "BOOK", "transaction_amount": {"amount": "146.31", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-04-01"}, "remittance_information": "Google ADS8524834313 / MCC: 7311", "signed_amount": "-146.31", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:07.185384+00:00", "value_date": "2026-04-01"} +{"account_iban": "DE63100180000598768566", "amount": "234.00", "bank_tx_code": null, "bank_tx_description": null, "bank_tx_sub_code": null, "beleg_match": null, "booking_date": "2026-03-05", "counterparty_bic": null, "counterparty_iban": null, "counterparty_name": "NOTION LABS, INC.", "credit_debit": "D", "currency": "EUR", "easybill_assignments": [], "entry_reference": "f6c65793-14ef-4200-8e90-da9dfdcf257e", "id": 88, "ignored": false, "raw": {"balance_after_transaction": null, "bank_transaction_code": null, "booking_date": "2026-03-05", "credit_debit_indicator": "DBIT", "creditor": {"contact_details": null, "name": "NOTION LABS, INC.", "organisation_id": null, "postal_address": null, "private_id": null}, "creditor_account": null, "creditor_account_additional_identification": null, "creditor_agent": null, "debtor": null, "debtor_account": {"iban": "DE63100180000598768566", "other": null}, "debtor_account_additional_identification": null, "debtor_agent": null, "entry_reference": "f6c65793-14ef-4200-8e90-da9dfdcf257e", "exchange_rate": null, "merchant_category_code": null, "note": null, "reference_number": null, "reference_number_schema": null, "remittance_information": ["NOTION LABS, INC. / MCC: 7372"], "status": "BOOK", "transaction_amount": {"amount": "234.0", "currency": "EUR"}, "transaction_date": null, "transaction_id": null, "value_date": "2026-03-04"}, "remittance_information": "NOTION LABS, INC. / MCC: 7372", "signed_amount": "-234.00", "source": "eb-finom", "status": "BOOK", "synced_at": "2026-05-03T11:16:07.296212+00:00", "value_date": "2026-03-04"} diff --git a/tests/fixtures/finance/vodafone/122203440401.meta.json b/tests/fixtures/finance/vodafone/122203440401.meta.json new file mode 100644 index 000000000000..df08994df48d --- /dev/null +++ b/tests/fixtures/finance/vodafone/122203440401.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "cb87eb1e-5080-4f7b-9da6-f22494ea198e.pdf", + "from": "/O=EXCHANGELABS/OU=EXCHANGE ADMINISTRATIVE GROUP (FYDIBOHF23SPDLT)/CN=RECIPIENTS/CN=DADE9F2E49B4427C9C4042747F06C795-RECHNUNG", + "internet_message_id": "", + "received_at": "2026-05-10T16:11:44Z", + "subject": "" +} diff --git a/tests/fixtures/finance/vodafone/122203440401.txt b/tests/fixtures/finance/vodafone/122203440401.txt new file mode 100644 index 000000000000..a79aa40f3b94 --- /dev/null +++ b/tests/fixtures/finance/vodafone/122203440401.txt @@ -0,0 +1,126 @@ +Kundenservice: www.vodafone.de +Vodafone-Auskunft: 22 88 +Vodafone-Kundenbetreuung: +0 800/172 12 34 +Vodafone GmbH KundenBetreuung 40875 Ratingen +Vodafone-Nr.: +0173/3749539 + 2/676311021EI +Lineo Finance GmbH +Rechnungs-Nummer: +Neptunstr. 20a +122203440401 +Kunden-Nummer: +120113676 +85368 Moosburg +Rahmenvertrag: +190013 +Datum: +14.04.2026 +Seite: + 1 von 2 +Rechnung +Erfassungszeitraum vom 08.03.2026 bis 07.04.2026 +USt.-Satz +netto in EUR +Basispreis / Paketpreis (monatlich) +1 + 59,0000 + 19 % +Vodafone Red Business Prime Plus mit +Smartphone +1 + 0,0000 + 19 % +Aktion: Unlimitiertes Datenvolumen Red +Business Prime Plus +1 + 0,0000 + 19 % +Kompensation der CO2-Emissionen über +ClimatePartner +1 + 0,0000 + 19 % +My Office Number +1 + 0,0000 + 19 % +Vodafone OneNumber +1 + 0,0000 + 19 % +Vodafone WiFi Calling + 59,0000 +Zu Ihren Gunsten + - 9,8000 + 19 % +Aktion: 20% Rabatt Basispreis Red Business +Prime Plus + - 9,8000 +Nettorechnungsbetrag + 49,2000 + 19 % +USt.-Satz +Nettorechnungsbetrag +USt.-Betrag +Bruttorechnungsbetrag + 19 % + 49,20 EUR + 9,35 EUR + 58,55 EUR + 49,20 + 9,35 +Summe +EUR +EUR +EUR + 58,55 +Den zu zahlenden Betrag in Höhe von 58,55 EUR buchen wir am 22.04.2026 von Ihrem Konto ab: +BIC FNOMDEB2XXX, IBAN DE63XXXXXXXXXXXXXXXX66 +Das Mandat dazu führen wir unter der Nummer DE04KMMC000120113676T028219762. +Bitte sorgen Sie für ausreichende Deckung und haben Sie Verständnis dafür, dass wir Ihnen Kosten in Rechnung stellen +müssen, die uns durch eine etwaige Rücklastschrift entstehen. Wir behalten uns vor, die Abbuchung dann noch einmal zu +versuchen. +Falls bis dahin eine weitere Rechnung fällig ist, ist der Betrag entsprechend höher. +Soweit Ihre Verbindungsdaten nur verkürzt gespeichert oder auf Ihren Wunsch sofort bzw. aufgrund datenschutzrechtlicher +Vorschriften spätestens 6 Monate nach Rechnungsversand vollständig gelöscht werden, trifft Vodafone keine Nachweispflicht +für die Einzelverbindungen. Sie können begründete Einwände auch gegen einzelne Rechnungspositionen erheben. + +Seite: + 2 von 2 +Datenverbindungen: +Insgesamt +2.000 +(gerundet in KB) + 03.12.2024 +Vertragsbeginn: +Ende der Mindestvertragslaufzeit: 02.12.2026 +Kündigungsfrist: 3 Monate zum Vertragsende +Letzter Kündigungstermin: 02.09.2026 +Informationen zum generellen Ablauf des Anbieterwechsels finden Sie unter www.bundesnetzagentur.de/tk-anbieterwechsel +Für Kundenverträge, die nach öffentlicher Ausschreibung auf Grundlage vom Kunden vorgegebener Vertragsbedingungen +geschlossen wurden, gelten weiterhin die vertraglichen Kündigungsfristen. + +Sie wollen die Geschwindigkeit Ihres Produkts prüfen? Laden Sie einfach unsere SpeedTest-App herunter - bei Google Play + +oder im App Store. Alternativ finden Sie den Speedtest auch direkt in der MeinVodafone-App. + +Informationen zu Leistungen Dritter +Hinsichtlich der in der Rechnung ausgewiesenen Leistungen Dritter erhalten Sie unter der kostenfreien Rufnummer + +0800/1721234 Informationen zu Namen und ladungsfähigen Adressen der Drittanbieter und bei Anbietern mit Sitz + +im Ausland zusätzlich die ladungsfähige Anschrift eines allgemeinen Zustellungsbevollmächtigten im Inland. + +Hinweis +Ab Januar 2027 dürfen an Unternehmen nur noch elektronische Rechnungen ausgestellt werden. Bekommen Sie Ihre + +Vodafone-Rechnung noch per Post? Dann kümmern Sie sich bitte rechtzeitig um eine Umstellung in unser Rechnungs-Center. + +Mehr Infos dazu finden Sie hier: https://www.vodafone.de/business/rechnungs-center + + +Haben Sie noch Fragen? Antworten finden Sie auf www.vodafone.de/business-hilfe + +Verbindungsübersicht: siehe nächste Seite. diff --git a/tests/fixtures/finance/vodafone/portal_notification_2026_04.meta.json b/tests/fixtures/finance/vodafone/portal_notification_2026_04.meta.json new file mode 100644 index 000000000000..31107a39b846 --- /dev/null +++ b/tests/fixtures/finance/vodafone/portal_notification_2026_04.meta.json @@ -0,0 +1,7 @@ +{ + "attachment_name": "", + "from": "nicht.antworten@kundenservice.vodafone.com", + "internet_message_id": "<120113676.20260414.1923542180764578@kundenservice.vodafone.com>", + "received_at": "2026-04-14T15:47:39Z", + "subject": "Ihre Mobilfunk-Rechnung vom 14.04.2026 steht im Internet bereit." +} diff --git a/tests/fixtures/finance/vodafone/portal_notification_2026_04.txt b/tests/fixtures/finance/vodafone/portal_notification_2026_04.txt new file mode 100644 index 000000000000..3bc96fd85a38 --- /dev/null +++ b/tests/fixtures/finance/vodafone/portal_notification_2026_04.txt @@ -0,0 +1,45 @@ +Deine Mobilfunk-Rechnung ist da + +Hallo Lineo Finance GmbH, + +Deine Rechnung vom 14.04.2026 findest Du in Deinem persönlichen Service-Portal MeinVodafone. + +Die Summe beträgt 58,55 Euro und ist am 22.04.2026 fällig. + +Damit Du online auf Deine Rechnung zugreifen kannst, registrier Dich bitte bei MeinVodafone. Du brauchst dazu Deine Kundennummer und Deinen Aktivierungscode. Den Aktivierungscode hast Du mit Deinem Vertrag bekommen. Hast Du ihn nicht mehr? Dann kannst Du Dir dort einen neuen anfordern. + +Schon gewusst? Mit der Service-PIN sind Deine Daten safe! Meldest Du Dich bei uns, fragen wir Dich danach. Dann sind wir sicher, dass wirklich Du mit uns sprichst. Klingt safe? Ist es auch! +Du findest Deine Service-PIN in MeinVodafone. Und alle Infos zur Sicherheit Deiner Daten auf +www.vodafone.de/servicepin +. + +Freundliche Grüße +Dein Vodafone-Team + +Jetzt registrieren + +Video: MeinVodafone Registrierung + +Diese E-Mail wurde automatisch an Dich verschickt. Klick bitte nicht auf "Antworten" oder "Reply" in Deinem E-Mail-Programm. + +Sicherheitshinweis + +Du erkennst Phishing-E-Mails unter anderem an der Anrede. Wir sprechen Dich im Text unserer E-Mails immer persönlich an. +Mehr dazu: +vodafone.de/phishing + +Kundenforum + +MeinVodafone + +Hilfe & Support + +Vodafone.de +| +Pflichtangaben +| +Kontakt +| +Datenschutz + +Copyright © 2026 Vodafone GmbH