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/__init__.py +3 -0
- quints/cli.py +887 -0
- quints/config.py +214 -0
- quints/fava/__init__.py +65 -0
- quints/fava/templates/QuintDashboard.html +85 -0
- quints/fx.py +164 -0
- quints/importing.py +393 -0
- quints/inbox.py +127 -0
- quints/init.py +462 -0
- quints/invoice/__init__.py +1 -0
- quints/invoice/draft.py +38 -0
- quints/invoice/labels.py +84 -0
- quints/invoice/model.py +333 -0
- quints/invoice/qr.py +67 -0
- quints/invoice/render.py +163 -0
- quints/invoice/template.typ +140 -0
- quints/invoice/vatid.py +48 -0
- quints/invoice/verify.py +60 -0
- quints/kmu.py +712 -0
- quints/ledger.py +59 -0
- quints/match.py +245 -0
- quints/mwst.py +353 -0
- quints/plugins/__init__.py +1 -0
- quints/plugins/kmu.py +58 -0
- quints/prices.py +114 -0
- quints/receivables.py +132 -0
- quints/report.typ +55 -0
- quints/report_pdf.py +183 -0
- quints/settlement.py +225 -0
- quints/ui.py +34 -0
- quints/vat.py +88 -0
- quints-0.1.0.dist-info/METADATA +60 -0
- quints-0.1.0.dist-info/RECORD +36 -0
- quints-0.1.0.dist-info/WHEEL +4 -0
- quints-0.1.0.dist-info/entry_points.txt +2 -0
- quints-0.1.0.dist-info/licenses/LICENSE +339 -0
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()
|