quints 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
quints/importing.py ADDED
@@ -0,0 +1,393 @@
1
+ """Statement imports → staging drafts (docs/plans/02).
2
+
3
+ Importers never write to ``books/``: drafts land in ``staging/`` (git-ignored)
4
+ where a human (or agent, via the reconcile workflow) completes the counter leg,
5
+ decides VAT treatment, links the document, and cut-pastes into the ledger.
6
+
7
+ Dedup is two-layered:
8
+
9
+ - the importers themselves skip source references (``ubs_ref:`` / ``wise_id:``
10
+ / ``stripe_id:``) already present in the ledger — re-imports are idempotent
11
+ once entries carry references;
12
+ - entries booked **before** the importers existed carry no reference, so a
13
+ second pass drops drafts whose cash-account posting matches an existing one
14
+ by amount within a ±3-day window (reported, so the match is auditable).
15
+
16
+ Wise statements are fetched through the SCA-capable client in
17
+ ``beangulp_wise`` and saved as JSON next to the drafts — every import stays
18
+ auditable and replayable offline. Credentials come from ``.env``:
19
+ ``QUINTS_WISE_API_TOKEN`` and ``QUINTS_WISE_PRIVATE_KEY`` (path to the RSA key
20
+ whose public half is registered in Wise → Settings → API tokens).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from datetime import date as TodayDate
30
+ from pathlib import Path
31
+
32
+ from beancount.core import data
33
+ from beancount.parser import printer
34
+
35
+ from beangulp_mt940 import Importer as Mt940Importer
36
+ from beangulp_stripe import Importer as StripeImporter
37
+ from beangulp_stripe import StripeClient, StripeError
38
+ from beangulp_wise import Importer as WiseImporter
39
+ from beangulp_wise import ScaChallenge, WiseClient, merge_conversions
40
+
41
+ from . import config, ledger
42
+ from . import receivables as recv_mod
43
+
44
+ LEGACY_WINDOW_DAYS = 3
45
+ DEFAULT_STAGING = Path("staging")
46
+
47
+
48
+ def _require(section, name: str, *keys: str):
49
+ """The importer's entity data ([import.<name>] in quints.toml) is config."""
50
+ if section is None:
51
+ raise ValueError(f"[import.{name}] is not configured — add it to quints.toml")
52
+ for key in keys:
53
+ if getattr(section, key) in (None, ""):
54
+ raise ValueError(f"[import.{name}] needs '{key}' in quints.toml")
55
+ return section
56
+
57
+
58
+ # Rule flags: '*' = clearing legs that are complete as drafted; '!' = direct
59
+ # expenses that still need a VAT decision and a linked document before books/.
60
+
61
+
62
+ def ubs_importer(cfg: config.Config | None = None) -> Mt940Importer:
63
+ s = _require((cfg or config.get()).import_ubs, "ubs", "iban")
64
+ return Mt940Importer(
65
+ s.account,
66
+ iban=s.iban,
67
+ currency=s.currency,
68
+ meta_key="ubs_ref",
69
+ payee_rules=s.rules,
70
+ )
71
+
72
+
73
+ def wise_importer(cfg: config.Config | None = None) -> WiseImporter:
74
+ s = _require((cfg or config.get()).import_wise, "wise")
75
+ return WiseImporter(
76
+ s.account_map,
77
+ fees_account=s.fees_account,
78
+ holder=s.holder,
79
+ meta_key="wise_id",
80
+ payee_rules=s.rules,
81
+ )
82
+
83
+
84
+ def stripe_importer(cfg: config.Config | None = None) -> StripeImporter:
85
+ s = _require((cfg or config.get()).import_stripe, "stripe", "account_id")
86
+ return StripeImporter(
87
+ s.account_map,
88
+ fees_account=s.fees_account,
89
+ tax_account=s.tax_account,
90
+ account_id=s.account_id,
91
+ meta_key="stripe_id",
92
+ payee_rules=s.rules,
93
+ )
94
+
95
+
96
+ # ── shared pipeline ──────────────────────────────────────────────────────────
97
+
98
+
99
+ @dataclass
100
+ class ImportResult:
101
+ source: str
102
+ out_path: Path | None = None
103
+ drafts: list = field(default_factory=list) # Transactions written to staging
104
+ balances: list = field(default_factory=list) # closing-balance assertions
105
+ skipped_ref: int = 0 # deduped by reference metadata
106
+ legacy_matches: list = field(default_factory=list) # (draft, booked date) pairs
107
+ receivable_matches: list = field(default_factory=list) # (invoice number, draft)
108
+
109
+
110
+ def _cash_pool(entries, accounts: set[str]) -> list[tuple]:
111
+ """Mutable pool of (date, amount, account) cash postings for legacy matching."""
112
+ pool = []
113
+ for e in entries:
114
+ if not isinstance(e, data.Transaction):
115
+ continue
116
+ for p in e.postings:
117
+ if p.account in accounts:
118
+ pool.append((e.date, p.units.number, p.account))
119
+ return pool
120
+
121
+
122
+ def _split(result: ImportResult, extracted, pool) -> None:
123
+ """Sort extracted entries into drafts / legacy matches / balance assertions."""
124
+ for entry in extracted:
125
+ if isinstance(entry, data.Balance):
126
+ result.balances.append(entry)
127
+ continue
128
+ cash = entry.postings[0]
129
+ match = next(
130
+ (
131
+ (d, n, a)
132
+ for d, n, a in pool
133
+ if a == cash.account
134
+ and n == cash.units.number
135
+ and abs((d - entry.date).days) <= LEGACY_WINDOW_DAYS
136
+ ),
137
+ None,
138
+ )
139
+ if match:
140
+ pool.remove(match) # a booked posting can absorb only one draft
141
+ result.legacy_matches.append((entry, match[0]))
142
+ else:
143
+ result.drafts.append(entry)
144
+
145
+
146
+ def _match_receivables(result: ImportResult, existing, cfg: config.Config) -> None:
147
+ """Link incoming drafts to open invoices (docs/plans/02 §2.1).
148
+
149
+ `quints invoice` derives the QRR (QR-bill) and SCOR/RF (SEPA) references
150
+ deterministically from the invoice number, so a payment carrying either —
151
+ or the plain number — identifies its invoice exactly. On a match the
152
+ draft's counter leg becomes the receivable clearing, linked `^<number>`
153
+ and flagged `*` (complete as drafted)."""
154
+ from .match import reference_index
155
+
156
+ opens = recv_mod.compute_from_entries(existing, TodayDate.today(), cfg)
157
+ if not opens:
158
+ return
159
+ index = reference_index(opens)
160
+
161
+ for i, draft in enumerate(result.drafts):
162
+ cash = draft.postings[0]
163
+ if cash.units is None or cash.units.number <= 0:
164
+ continue # only incoming payments clear receivables
165
+ blob = " ".join(
166
+ [draft.payee or "", draft.narration or ""]
167
+ + [str(v) for v in (draft.meta or {}).values()]
168
+ ).upper()
169
+ compact = re.sub(r"[^A-Z0-9]", "", blob)
170
+ number = next((n for ref, n in index.items() if ref in compact), None)
171
+ if number is None:
172
+ continue
173
+ postings = list(draft.postings)
174
+ if len(postings) == 2:
175
+ postings[1] = postings[1]._replace(account=cfg.receivable)
176
+ meta = dict(draft.meta or {})
177
+ meta["invoice"] = number
178
+ matched = draft._replace(
179
+ flag="*",
180
+ meta=meta,
181
+ links=frozenset(draft.links or ()) | {number},
182
+ postings=postings,
183
+ )
184
+ result.drafts[i] = matched
185
+ result.receivable_matches.append((number, matched))
186
+
187
+
188
+ def _write_staging(result: ImportResult, out_dir: Path, source: str) -> None:
189
+ if not (result.drafts or result.balances):
190
+ return
191
+ out_dir.mkdir(exist_ok=True)
192
+ out = out_dir / f"{TodayDate.today().isoformat()}-{source}.bean"
193
+ with open(out, "w", encoding="utf-8") as f:
194
+ f.write(f"; drafts from {result.source} — review, complete, move to books/, delete\n")
195
+ f.write("; legs to fill: counter account, VAT (InputVAT/Bezugsteuer/none), document:\n\n")
196
+ for entry in result.drafts:
197
+ f.write(printer.format_entry(entry) + "\n")
198
+ for balance in result.balances:
199
+ f.write(printer.format_entry(balance))
200
+ result.out_path = out
201
+
202
+
203
+ def run_ubs(
204
+ statement: Path,
205
+ ledger_path: Path,
206
+ out_dir: Path = DEFAULT_STAGING,
207
+ cfg: config.Config | None = None,
208
+ ) -> ImportResult:
209
+ cfg = cfg or config.get()
210
+ importer = ubs_importer(cfg)
211
+ if not importer.identify(str(statement)):
212
+ raise ValueError(f"{statement} is not an MT940 statement for {cfg.import_ubs.iban}")
213
+
214
+ existing, _ = ledger.load_entries(ledger_path)
215
+ total = len(importer.extract(str(statement), existing=[]))
216
+ extracted = importer.extract(str(statement), existing=existing)
217
+
218
+ result = ImportResult(source=str(statement))
219
+ result.skipped_ref = total - len(extracted)
220
+ _split(result, extracted, _cash_pool(existing, {cfg.import_ubs.account}))
221
+ _match_receivables(result, existing, cfg)
222
+ _write_staging(result, out_dir, "ubs")
223
+ return result
224
+
225
+
226
+ def run_wise(
227
+ statements: list[Path],
228
+ ledger_path: Path,
229
+ out_dir: Path = DEFAULT_STAGING,
230
+ cfg: config.Config | None = None,
231
+ ) -> ImportResult:
232
+ cfg = cfg or config.get()
233
+ importer = wise_importer(cfg)
234
+ wise = cfg.import_wise
235
+ for statement in statements:
236
+ if not importer.identify(str(statement)):
237
+ raise ValueError(
238
+ f"{statement} is not a Wise balance statement for {wise.holder} "
239
+ f"in {sorted(wise.account_map)}"
240
+ )
241
+
242
+ existing, _ = ledger.load_entries(ledger_path)
243
+ raw: list = [] # without ledger dedup, to count reference skips
244
+ deduped: list = []
245
+ for statement in statements:
246
+ raw += importer.extract(str(statement), existing=[])
247
+ deduped += importer.extract(str(statement), existing=existing)
248
+
249
+ result = ImportResult(source=", ".join(str(s) for s in statements))
250
+ result.skipped_ref = _txn_count(raw) - _txn_count(deduped)
251
+ _split(result, merge_conversions(deduped), _cash_pool(existing, set(wise.account_map.values())))
252
+ _match_receivables(result, existing, cfg)
253
+ _write_staging(result, out_dir, "wise")
254
+ return result
255
+
256
+
257
+ def run_stripe(
258
+ statements: list[Path],
259
+ ledger_path: Path,
260
+ out_dir: Path = DEFAULT_STAGING,
261
+ cfg: config.Config | None = None,
262
+ ) -> ImportResult:
263
+ cfg = cfg or config.get()
264
+ importer = stripe_importer(cfg)
265
+ stripe = cfg.import_stripe
266
+ for statement in statements:
267
+ if not importer.identify(str(statement)):
268
+ raise ValueError(
269
+ f"{statement} is not a Stripe balance-transactions file for "
270
+ f"{stripe.account_id} in {sorted(stripe.account_map)}"
271
+ )
272
+
273
+ existing, _ = ledger.load_entries(ledger_path)
274
+ raw: list = [] # without ledger dedup, to count reference skips
275
+ deduped: list = []
276
+ for statement in statements:
277
+ raw += importer.extract(str(statement), existing=[])
278
+ deduped += importer.extract(str(statement), existing=existing)
279
+
280
+ result = ImportResult(source=", ".join(str(s) for s in statements))
281
+ result.skipped_ref = _txn_count(raw) - _txn_count(deduped)
282
+ _split(result, deduped, _cash_pool(existing, set(stripe.account_map.values())))
283
+ _match_receivables(result, existing, cfg)
284
+ _write_staging(result, out_dir, "stripe")
285
+ return result
286
+
287
+
288
+ def _txn_count(entries) -> int:
289
+ return sum(1 for e in entries if isinstance(e, data.Transaction))
290
+
291
+
292
+ def fetch_wise(
293
+ interval_start: str,
294
+ interval_end: str,
295
+ out_dir: Path = DEFAULT_STAGING,
296
+ cfg: config.Config | None = None,
297
+ ) -> list[Path]:
298
+ """Download one statement.json per currency balance into ``out_dir``."""
299
+ wise = _require((cfg or config.get()).import_wise, "wise", "holder")
300
+ _load_env()
301
+ token = os.environ.get("QUINTS_WISE_API_TOKEN")
302
+ if not token:
303
+ raise ScaChallenge("QUINTS_WISE_API_TOKEN is not set (.env)")
304
+ pem = None
305
+ key_path = os.environ.get("QUINTS_WISE_PRIVATE_KEY")
306
+ if key_path and Path(key_path).exists():
307
+ pem = Path(key_path).read_bytes()
308
+
309
+ client = WiseClient(token, private_key_pem=pem)
310
+ profile = client.profile_id(wise.holder)
311
+ paths: list[Path] = []
312
+ out_dir.mkdir(exist_ok=True)
313
+ for balance in client.balances(profile):
314
+ currency = balance["currency"]
315
+ if currency not in wise.account_map:
316
+ continue
317
+ statement = client.balance_statement(
318
+ profile,
319
+ balance["id"],
320
+ currency,
321
+ f"{interval_start}T00:00:00.000Z",
322
+ f"{interval_end}T23:59:59.999Z",
323
+ )
324
+ path = out_dir / f"wise-{currency.lower()}-{interval_start}-{interval_end}.json"
325
+ path.write_text(json.dumps(statement, indent=1))
326
+ paths.append(path)
327
+ return paths
328
+
329
+
330
+ def fetch_stripe(
331
+ interval_start: str,
332
+ interval_end: str,
333
+ out_dir: Path = DEFAULT_STAGING,
334
+ cfg: config.Config | None = None,
335
+ ) -> list[Path]:
336
+ """Download balance transactions (+ balance snapshot) into ``out_dir``.
337
+
338
+ Saved as one JSON wrapper the importer reads back — auditable and
339
+ replayable offline, same convention as Wise. The balance snapshot (and
340
+ with it the assertion) is only meaningful when the window extends to
341
+ today, so it is included only then.
342
+ """
343
+ import datetime as dt
344
+
345
+ stripe = _require((cfg or config.get()).import_stripe, "stripe", "account_id")
346
+ _load_env()
347
+ key = os.environ.get("QUINTS_STRIPE_API_KEY")
348
+ if not key:
349
+ raise StripeError("QUINTS_STRIPE_API_KEY is not set (.env)")
350
+
351
+ client = StripeClient(key)
352
+ account = client.account()
353
+ if account.get("id") != stripe.account_id:
354
+ raise StripeError(
355
+ f"this key belongs to {account.get('id')}, expected {stripe.account_id} "
356
+ "([import.stripe] account_id) — wrong Stripe account"
357
+ )
358
+
359
+ def _ts(day: str, end: bool) -> int:
360
+ moment = dt.datetime.fromisoformat(day).replace(tzinfo=dt.timezone.utc)
361
+ if end:
362
+ moment += dt.timedelta(days=1)
363
+ return int(moment.timestamp()) - (1 if end else 0)
364
+
365
+ wrapper: dict = {
366
+ "account": {"id": account.get("id")},
367
+ "data": client.balance_transactions(_ts(interval_start, False), _ts(interval_end, True)),
368
+ }
369
+ today = TodayDate.today()
370
+ if TodayDate.fromisoformat(interval_end) >= today:
371
+ snapshot = client.balance()
372
+ wrapper["balance"] = {
373
+ "as_of": today.isoformat(),
374
+ "available": snapshot.get("available") or [],
375
+ "pending": snapshot.get("pending") or [],
376
+ }
377
+
378
+ out_dir.mkdir(exist_ok=True)
379
+ path = out_dir / f"stripe-{interval_start}-{interval_end}.json"
380
+ path.write_text(json.dumps(wrapper, indent=1))
381
+ return [path]
382
+
383
+
384
+ def _load_env(path: Path = Path(".env")) -> None:
385
+ """Minimal .env loader (no new dependency); never overrides real env."""
386
+ if not path.exists():
387
+ return
388
+ for line in path.read_text().splitlines():
389
+ line = line.strip()
390
+ if not line or line.startswith("#") or "=" not in line:
391
+ continue
392
+ key, value = line.split("=", 1)
393
+ os.environ.setdefault(key.strip(), value.strip())
quints/inbox.py ADDED
@@ -0,0 +1,127 @@
1
+ """Inbox inventory: documents waiting to be booked (docs/plans/06 step 2).
2
+
3
+ Deterministic only — no AI here. For each file in ``inbox/`` this reports
4
+ what the filename convention already tells us (date, payee, narrative),
5
+ whether an identical file is already filed under ``documents/`` (content
6
+ hash), and whether the basename is already linked from a transaction's
7
+ ``document:`` metadata. The judgment layer (Claude) consumes this JSON to
8
+ decide what each document *is*; the engine only says what's known.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import re
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from beancount.core import data
19
+ from rich import box
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+
23
+ from . import config, ledger, ui
24
+
25
+ _NAMED = re.compile(r"^(\d{4}-\d{2}-\d{2})\.([^.]+)\.(.+)\.(\w+)$")
26
+
27
+
28
+ @dataclass
29
+ class InboxDoc:
30
+ name: str
31
+ size: int
32
+ sha256: str # short content hash (12 hex chars)
33
+ date_hint: str | None # from the YYYY-MM-DD.payee.narrative.ext convention
34
+ payee_hint: str | None
35
+ narrative_hint: str | None
36
+ duplicate_of: str | None # identical file already under documents/
37
+ linked: bool # basename referenced by a document: metadata
38
+
39
+
40
+ def _sha(path: Path) -> str:
41
+ return hashlib.sha256(path.read_bytes()).hexdigest()[:12]
42
+
43
+
44
+ def _linked_basenames(entries) -> set[str]:
45
+ names: set[str] = set()
46
+ for e in entries:
47
+ if not isinstance(e, data.Transaction) or not e.meta:
48
+ continue
49
+ for key, value in e.meta.items():
50
+ if key.startswith("document"):
51
+ names.add(str(value))
52
+ return names
53
+
54
+
55
+ def scan(root: Path, entries) -> list[InboxDoc]:
56
+ box_dir = root / "inbox"
57
+ if not box_dir.is_dir():
58
+ return []
59
+ files = sorted(
60
+ p
61
+ for p in box_dir.iterdir()
62
+ if p.is_file() and not p.name.startswith(".") and not p.name.lower().startswith("readme")
63
+ )
64
+ if not files:
65
+ return []
66
+
67
+ # Content-hash dedup against documents/, prefiltered by size so we only
68
+ # hash filed documents that could possibly collide.
69
+ inbox_sizes = {p.stat().st_size for p in files}
70
+ filed_by_hash: dict[str, Path] = {}
71
+ docs_dir = root / "documents"
72
+ if docs_dir.is_dir():
73
+ for p in docs_dir.rglob("*"):
74
+ if p.is_file() and p.stat().st_size in inbox_sizes:
75
+ filed_by_hash.setdefault(_sha(p), p)
76
+
77
+ linked = _linked_basenames(entries)
78
+ out = []
79
+ for p in files:
80
+ m = _NAMED.match(p.name)
81
+ dup = filed_by_hash.get(_sha(p))
82
+ out.append(
83
+ InboxDoc(
84
+ name=p.name,
85
+ size=p.stat().st_size,
86
+ sha256=_sha(p),
87
+ date_hint=m.group(1) if m else None,
88
+ payee_hint=m.group(2) if m else None,
89
+ narrative_hint=m.group(3) if m else None,
90
+ duplicate_of=str(dup.relative_to(root)) if dup else None,
91
+ linked=p.name in linked,
92
+ )
93
+ )
94
+ return out
95
+
96
+
97
+ def compute(ledger_path: Path, cfg: config.Config | None = None) -> list[InboxDoc]:
98
+ entries, _ = ledger.load_entries(ledger_path)
99
+ return scan(ledger_path.resolve().parent, entries)
100
+
101
+
102
+ # ── render ────────────────────────────────────────────────────────────────────
103
+
104
+
105
+ def render(docs: list[InboxDoc], console: Console | None = None) -> None:
106
+ console = console or ui.console
107
+ console.print()
108
+ console.rule("[bold]Inbox[/]")
109
+ if not docs:
110
+ console.print("[ok]Inbox is empty.[/]")
111
+ console.print()
112
+ return
113
+ t = Table(box=box.SIMPLE_HEAVY, pad_edge=False)
114
+ t.add_column("Document")
115
+ t.add_column("Date", no_wrap=True)
116
+ t.add_column("Payee")
117
+ t.add_column("Status")
118
+ for d in docs:
119
+ if d.duplicate_of:
120
+ status = f"[warn]duplicate of {d.duplicate_of}[/]"
121
+ elif d.linked:
122
+ status = "[warn]already linked in ledger[/]"
123
+ else:
124
+ status = "[muted]to book[/]"
125
+ t.add_row(d.name, d.date_hint or "—", d.payee_hint or "—", status)
126
+ console.print(t)
127
+ console.print()