runningtab 0.1.0.dev3__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.
Files changed (52) hide show
  1. runningtab/__init__.py +3 -0
  2. runningtab/access.py +419 -0
  3. runningtab/books.py +225 -0
  4. runningtab/chart.py +408 -0
  5. runningtab/cli.py +2216 -0
  6. runningtab/coa.py +212 -0
  7. runningtab/doc.py +185 -0
  8. runningtab/forecast/__init__.py +1 -0
  9. runningtab/forecast/compute.py +224 -0
  10. runningtab/forecast/doctype.py +228 -0
  11. runningtab/forecast/model.py +251 -0
  12. runningtab/forecast/names.py +8 -0
  13. runningtab/forecast/sources.py +85 -0
  14. runningtab/gl_codes.py +50 -0
  15. runningtab/health.py +661 -0
  16. runningtab/mercury/__init__.py +7 -0
  17. runningtab/mercury/categories.py +500 -0
  18. runningtab/mercury/classify.py +101 -0
  19. runningtab/mercury/client.py +360 -0
  20. runningtab/mercury/ingest.py +733 -0
  21. runningtab/mercury/mapping.py +130 -0
  22. runningtab/mercury/models.py +544 -0
  23. runningtab/mercury/posting.py +757 -0
  24. runningtab/mercury/receipts.py +304 -0
  25. runningtab/mercury/session.py +65 -0
  26. runningtab/mercury/state.py +285 -0
  27. runningtab/mercury/vouchers.py +951 -0
  28. runningtab/period/__init__.py +9 -0
  29. runningtab/period/balances.py +283 -0
  30. runningtab/period/bank.py +154 -0
  31. runningtab/period/close.py +510 -0
  32. runningtab/period/crosscheck.py +257 -0
  33. runningtab/period/csvfmt.py +86 -0
  34. runningtab/period/documents.py +260 -0
  35. runningtab/period/fence.py +277 -0
  36. runningtab/period/ledger.py +205 -0
  37. runningtab/period/lock.py +294 -0
  38. runningtab/period/manifest.py +259 -0
  39. runningtab/period/open_items.py +84 -0
  40. runningtab/period/package.py +140 -0
  41. runningtab/period/qbo.py +521 -0
  42. runningtab/period/qbo_map.py +174 -0
  43. runningtab/period/quarter.py +90 -0
  44. runningtab/period/readiness.py +146 -0
  45. runningtab/period/record.py +247 -0
  46. runningtab/period/status.py +312 -0
  47. runningtab/site.py +409 -0
  48. runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
  49. runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
  50. runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
  51. runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
  52. runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,733 @@
1
+ """The applier: snapshot for initial state, then the event log as the authority.
2
+
3
+ Three layers, as the design has them:
4
+
5
+ 1. **Snapshot** -- transaction lists establish initial state. Not a one-off
6
+ convenience: events are retained 90 days, so any gap wider than that is
7
+ closed by re-snapshotting rather than by resuming a cursor into a hole.
8
+ 2. **Log** -- every subsequent change arrives as an event. `apply_event` is the
9
+ single entry point, and it does not care where the event came from.
10
+ 3. **Audit** -- the balance invariant as independent arithmetic on top.
11
+
12
+ One thing the log cannot carry, and one departure from it: Mercury emits no
13
+ event when a person codes a transaction -- the GL code and note change with no
14
+ `resourceVersion` bump either -- so every sync ends by sweeping the
15
+ transactions it read for those two fields. `sweep_hints` says why that is not
16
+ the scheduled poll this design otherwise refuses.
17
+
18
+ **The webhook receiver is not built yet, and this is the shape that lets it be
19
+ added without touching any of this.** A Mercury webhook delivers exactly the
20
+ event the log serves; when the second `webhooks` deployment lands, its consumer
21
+ parses the delivery with `models.parse_event` and hands it to `apply_event` with
22
+ the same state file. Dedup on event id makes a webhook and a later pull of the
23
+ same event idempotent with respect to each other, so the two paths can run at
24
+ once without coordination.
25
+
26
+ The applier deliberately reads only the event's envelope -- which resource
27
+ changed, at which version -- and re-reads the transaction itself from the
28
+ transactions endpoint. A pending charge that settled has a different body than
29
+ the one the event carried, and re-reading is also how this avoids depending on
30
+ the exact shape of a JSON Merge Patch nobody has captured yet.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import dataclasses
36
+ import datetime as dt
37
+ from collections.abc import Iterable
38
+ from decimal import Decimal
39
+
40
+ from icedcoffee_sync.frappe.errors import FrappeError, PermissionError_, RateLimitError
41
+
42
+ from runningtab.mercury.classify import Decision, Route, classify, is_internal_transfer
43
+ from runningtab.mercury.client import MercuryClient
44
+ from runningtab.mercury.models import Event, Operation, Transaction
45
+ from runningtab.mercury.posting import (
46
+ TRANSACTION_ID_FIELD,
47
+ Ledger,
48
+ Outcome,
49
+ Plan,
50
+ PostingContext,
51
+ PostingError,
52
+ bank_transaction,
53
+ carry_out,
54
+ plain_message,
55
+ plan,
56
+ posted_transaction_ids,
57
+ )
58
+ from runningtab.mercury.receipts import ReceiptFetcher, ReceiptReport, attach_receipts
59
+ from runningtab.mercury.state import DryRunState, IngestState, State
60
+ from runningtab.mercury.vouchers import Unneeded, VoucherReport, draft_vouchers
61
+
62
+
63
+ @dataclasses.dataclass(frozen=True)
64
+ class BalanceCheck:
65
+ """One account's reported balance against the transactions we read for it.
66
+
67
+ Exact arithmetic, and free. It caught a real truncation during the
68
+ investigation -- a probe that never paged past a hundred rows and never
69
+ queried the credit account at all -- which is the failure mode that matters
70
+ most here: a ledger that looks fine while being incomplete.
71
+ """
72
+
73
+ account_id: str
74
+ label: str
75
+ reported: Decimal
76
+ computed: Decimal
77
+
78
+ @property
79
+ def ok(self) -> bool:
80
+ """Whether the sum of settled rows is exactly the balance Mercury reports."""
81
+ return self.reported == self.computed
82
+
83
+ @property
84
+ def difference(self) -> Decimal:
85
+ return self.computed - self.reported
86
+
87
+
88
+ class BalanceMismatchError(RuntimeError):
89
+ """An account's transactions do not sum to the balance Mercury reports."""
90
+
91
+
92
+ @dataclasses.dataclass
93
+ class IngestReport:
94
+ """What one run did, and what it wants a person to look at."""
95
+
96
+ # New Bank Transactions, posted already submitted -- the one document
97
+ # this ingest submits, because a Bank Transaction reaches the general
98
+ # ledger only when a person reconciles it.
99
+ submitted: list[str] = dataclasses.field(default_factory=list)
100
+ # Draft Journal Entries proposed beside a coded Bank Transaction. Nothing
101
+ # here is submitted, and nothing reaches the general ledger until a person
102
+ # does. See `runningtab.mercury.vouchers`.
103
+ drafted: list[str] = dataclasses.field(default_factory=list)
104
+ # Documents this run had already written and brought up to date: Bank
105
+ # Transactions and draft vouchers both.
106
+ updated: list[str] = dataclasses.field(default_factory=list)
107
+ # Hand-imported Bank Transactions recognised by their trace number and
108
+ # stamped with Mercury's fields. Their content was a person's already.
109
+ adopted: list[str] = dataclasses.field(default_factory=list)
110
+ # Mercury receipts now on their Bank Transaction, as
111
+ # `<file name> -> <document name>`. See `runningtab.mercury.receipts`.
112
+ attached: list[str] = dataclasses.field(default_factory=list)
113
+ planned: list[str] = dataclasses.field(default_factory=list)
114
+ skipped: list[tuple[str, str]] = dataclasses.field(default_factory=list)
115
+ attention: list[tuple[str, str]] = dataclasses.field(default_factory=list)
116
+ # A coded row dated on or before Books Closed Through: its Bank
117
+ # Transaction is posted above like any other, but no voucher is drafted.
118
+ # See `runningtab.mercury.vouchers.VoucherReport.closed`.
119
+ closed: list[str] = dataclasses.field(default_factory=list)
120
+ # Draft vouchers of ours nothing needs any more. A person deletes them.
121
+ # See `runningtab.mercury.vouchers.Unneeded`.
122
+ unneeded: list[Unneeded] = dataclasses.field(default_factory=list)
123
+ balances: list[BalanceCheck] = dataclasses.field(default_factory=list)
124
+ uncoded: list[tuple[str, int]] = dataclasses.field(default_factory=list)
125
+ # Bank Transactions naming a transaction the snapshot did not find in
126
+ # Mercury: (transaction id, document name). Only a snapshot can populate
127
+ # this -- a sync reads the log, which says nothing about what is absent.
128
+ orphans: list[tuple[str, str]] = dataclasses.field(default_factory=list)
129
+ # Mercury transaction ids this run's events already put through the
130
+ # applier. The hint sweep skips them: the event path has just written
131
+ # whatever Mercury says about them, and planning them a second time would
132
+ # only repeat whatever it already reported.
133
+ applied_transactions: set[str] = dataclasses.field(default_factory=set)
134
+ events_read: int = 0
135
+ events_applied: int = 0
136
+ events_deduplicated: int = 0
137
+ events_out_of_order: int = 0
138
+ cursor: str | None = None
139
+
140
+ @property
141
+ def balances_ok(self) -> bool:
142
+ return all(check.ok for check in self.balances)
143
+
144
+ @property
145
+ def needs_attention(self) -> bool:
146
+ """Whether anything in this run wants a person, which is what the command's exit code says.
147
+
148
+ Wider than the rows that failed. A settled row with no GL code gives
149
+ the person reconciling nothing to go on and will have to be coded
150
+ somewhere, a balance that does not reconcile means the read was
151
+ short, a closed-period row wants its quarter reopened, and an unneeded
152
+ draft wants deleting, which only a person can do -- each leaves the
153
+ ledger incomplete, or wanting a person's decision, while every
154
+ individual step looks like it worked.
155
+ """
156
+ return bool(
157
+ self.attention or self.uncoded or self.orphans or self.closed or self.unneeded or not self.balances_ok
158
+ )
159
+
160
+
161
+ def reconcile(account_id: str, label: str, reported: Decimal, transactions: Iterable[Transaction]) -> BalanceCheck:
162
+ """Sum the rows that count toward the balance and compare.
163
+
164
+ `currentBalance` excludes pending and failed transactions, verified exactly
165
+ on two sandbox accounts. Anything that does not reconcile means the read was
166
+ incomplete, not that the arithmetic is approximate.
167
+ """
168
+ computed = sum((t.amount for t in transactions if t.counts_toward_balance), Decimal(0))
169
+ return BalanceCheck(account_id=account_id, label=label, reported=reported, computed=computed)
170
+
171
+
172
+ def uncoded_ages(
173
+ transactions: Iterable[Transaction], now: dt.datetime, *, own_account_ids: Iterable[str]
174
+ ) -> list[tuple[str, int]]:
175
+ """Settled, non-transfer transactions carrying no GL code at Mercury, aged in days.
176
+
177
+ The GL code is the one hint the person reconciling gets about which account
178
+ a row belongs in, and a settled row without one has to be coded somewhere
179
+ -- in Mercury, so the hint lands on the Bank Transaction, or by hand at
180
+ reconciliation. Either way the run names it rather than leaving it to be
181
+ found.
182
+
183
+ Transfers are left out: a movement between our own accounts is not
184
+ something anybody should be coding, so counting it would put a floor under
185
+ the number and hide the signal.
186
+ """
187
+ own = set(own_account_ids)
188
+ out: list[tuple[str, int]] = []
189
+ for transaction in transactions:
190
+ if not transaction.is_postable or transaction.gl_code or is_internal_transfer(transaction, own):
191
+ continue
192
+ age = (now - (transaction.posted_at or transaction.created_at)).days
193
+ out.append((transaction.id, age))
194
+ return sorted(out, key=lambda row: -row[1])
195
+
196
+
197
+ def _record_route(report: IngestReport, transaction: Transaction, decision: Decision) -> None:
198
+ if decision.route is Route.SKIP:
199
+ report.skipped.append((transaction.id, decision.reason))
200
+ elif decision.route is Route.ATTENTION:
201
+ report.attention.append((transaction.id, decision.reason))
202
+
203
+
204
+ # What the report calls a plan it could not carry out, in the verbs the dry run
205
+ # already uses for the same three writes.
206
+ _VERBS = {Outcome.SUBMITTED: "submit", Outcome.UPDATED: "update", Outcome.ADOPTED: "adopt"}
207
+
208
+
209
+ def _write(ledger: Ledger, verdict: Plan) -> tuple[Outcome, str]:
210
+ """Carry the plan out, reporting a document the server refuses rather than ending the run.
211
+
212
+ The first production run died on a Rich traceback here, one transaction in,
213
+ because a custom field had been installed without `allow_on_submit` -- so
214
+ nothing was attempted after it and the report nobody saw would have named
215
+ the other sixteen rows. A refusal of one write is the same shape as a
216
+ transaction we cannot build a document for: one report line, the run goes
217
+ on, and the cursor stays behind it.
218
+
219
+ Only a refusal of this document. A ledger we cannot reach -- a connection
220
+ that fails, a request that times out -- a site rate limiting us, and a
221
+ credential the site will not accept are each one problem the whole run
222
+ has, not one row's, so they propagate and end it. Seventeen identical
223
+ lines would hide that there is only one thing wrong.
224
+ """
225
+ try:
226
+ return carry_out(ledger, verdict)
227
+ except RateLimitError, PermissionError_:
228
+ # Declared ahead of the base so a throttled site and a revoked key or
229
+ # lost submit grant keep ending the run: both answer every write the
230
+ # same way, and a line per row would bury the single real problem.
231
+ raise
232
+ except FrappeError as exc:
233
+ where = f" {verdict.name}" if verdict.name else ""
234
+ # `.get` rather than `[...]`: an outcome nobody named a verb for is
235
+ # still a refusal to report, and a KeyError raised while reporting one
236
+ # would trade a diagnosable problem for a mysterious one.
237
+ verb = _VERBS.get(verdict.outcome, str(verdict.outcome))
238
+ raise PostingError(f"{verb} {verdict.doctype}{where} refused by the ledger: {plain_message(str(exc))}") from exc
239
+
240
+
241
+ def _post(
242
+ ledger: Ledger,
243
+ transaction: Transaction,
244
+ ctx: PostingContext,
245
+ report: IngestReport,
246
+ *,
247
+ version: int | None,
248
+ dry_run: bool,
249
+ refresh_only: bool = False,
250
+ ) -> None:
251
+ """Plan the write, then do it -- or, on a dry run, only say what it would have been.
252
+
253
+ The dry run reads the ledger exactly as the real run does, so it plans an
254
+ adoption or an amendment where the real run would make one, rather than
255
+ calling everything a submit.
256
+
257
+ `refresh_only` is the hint sweep: it brings an existing row's GL code and
258
+ note up to date and posts nothing at all. See `sweep_hints`.
259
+
260
+ A write the server refuses comes back out as a `PostingError`, so the
261
+ caller holds this transaction exactly as it holds one it could not build a
262
+ document for. A ledger we cannot reach, cannot authenticate to, or is
263
+ throttling us is not that, and is left to propagate. See `_write`.
264
+ """
265
+ doc = bank_transaction(transaction, ctx)
266
+ transaction_id = doc[TRANSACTION_ID_FIELD]
267
+ verdict = plan(ledger, doc, transaction, resource_version=version)
268
+ if verdict.outcome is Outcome.REFUSED:
269
+ report.attention.append((transaction_id, f"{doc['doctype']} {verdict.name} {verdict.reason}"))
270
+ return
271
+ if refresh_only and verdict.outcome in {Outcome.SUBMITTED, Outcome.ADOPTED}:
272
+ # A settled transaction the ledger holds no row for means an event went
273
+ # missing, and posting it here would settle that by silence: the row
274
+ # would appear with nothing in the log ever having said so, and nobody
275
+ # would learn the log had a hole. Snapshot is the documented repair.
276
+ # A transaction that arrived between this run's read of the log and its
277
+ # read of the accounts lands here too and clears itself on the next
278
+ # run, which is why the message asks for a second sighting first.
279
+ report.attention.append((
280
+ transaction_id,
281
+ "settled at Mercury with no Bank Transaction of its own; the hint sweep refreshes rows and never "
282
+ "posts one, so if the next sync says this again an event went missing -- run `runningtab mercury snapshot`",
283
+ ))
284
+ return
285
+ if dry_run:
286
+ if verdict.outcome is Outcome.ADOPTED:
287
+ report.planned.append(
288
+ f"adopt {doc['doctype']} {verdict.name} (reference {transaction.tracking_number}) for {transaction_id}"
289
+ )
290
+ elif verdict.outcome is Outcome.SUBMITTED:
291
+ report.planned.append(f"submit {doc['doctype']} for {transaction_id}")
292
+ elif verdict.outcome is Outcome.UPDATED:
293
+ report.planned.append(f"update {doc['doctype']} {verdict.name} for {transaction_id}")
294
+ return
295
+ outcome, name = _write(ledger, verdict)
296
+ if outcome is Outcome.SUBMITTED:
297
+ report.submitted.append(name)
298
+ elif outcome is Outcome.UPDATED:
299
+ report.updated.append(name)
300
+ elif outcome is Outcome.ADOPTED:
301
+ report.adopted.append(name)
302
+
303
+
304
+ def apply_transaction(
305
+ transaction: Transaction,
306
+ ctx: PostingContext,
307
+ ledger: Ledger,
308
+ report: IngestReport,
309
+ *,
310
+ version: int | None = None,
311
+ dry_run: bool = False,
312
+ refresh_only: bool = False,
313
+ ) -> bool:
314
+ """Post the one Bank Transaction this transaction should have, or record why it has none.
315
+
316
+ Returns whether this transaction is **settled with**, which is not the same
317
+ as whether it posted. A skipped or reversed row is settled with: there is
318
+ nothing to retry. A row whose status nobody has classified is not, and says
319
+ so through `Decision.retryable`. A `PostingError` is not either -- a
320
+ Mercury account with no Bank Account, or a custom field the site will not
321
+ let us write, is a configuration a person fixes, and the next run must
322
+ post what this one could not. The caller uses the answer to decide how far
323
+ the cursor may move.
324
+ """
325
+ decision = classify(transaction)
326
+ if not decision.posts:
327
+ _record_route(report, transaction, decision)
328
+ # A reversal is settled with; a status nobody has classified is not.
329
+ # Retiring the second would mean the transaction never posts once
330
+ # somebody says what its status means -- it emits no further events, so
331
+ # only a re-snapshot would find it again.
332
+ return not decision.retryable
333
+ try:
334
+ _post(ledger, transaction, ctx, report, version=version, dry_run=dry_run, refresh_only=refresh_only)
335
+ except PostingError as exc:
336
+ # A transaction we cannot post is a report line, not the end of the run:
337
+ # stalling on one unmapped account would leave every later transaction
338
+ # unposted too. It is still unfinished business, so it comes back False
339
+ # and the cursor stays behind it.
340
+ report.attention.append((transaction.id, str(exc)))
341
+ return False
342
+ return True
343
+
344
+
345
+ def apply_event(
346
+ event: Event,
347
+ transactions: dict[str, Transaction],
348
+ ctx: PostingContext,
349
+ ledger: Ledger,
350
+ state: State,
351
+ report: IngestReport,
352
+ *,
353
+ now: dt.datetime,
354
+ dry_run: bool = False,
355
+ ) -> bool:
356
+ """Apply one event. The single entry point a webhook consumer will call too.
357
+
358
+ Two guards, in this order:
359
+
360
+ - **Dedup on event id.** Mercury names it the idempotency key and delivers
361
+ at-least-once.
362
+ - **Order on `resourceVersion`.** An int64, monotonic per resource, so an
363
+ event is applied only when it advances what we have recorded -- which is
364
+ what makes an out-of-order delivery harmless rather than a rewind.
365
+
366
+ An event that fails either guard is still marked seen. It has been
367
+ considered; reconsidering it would reach the same conclusion more slowly.
368
+
369
+ An event whose transaction could not be posted is **not** marked seen, and
370
+ comes back False so the caller keeps its cursor behind it. Recording it
371
+ would retire the event permanently while the ledger never received it --
372
+ the one failure a bank feed must not have.
373
+
374
+ `dry_run` buffers the state writes here rather than leaving that to the
375
+ caller. Handing this a real `IngestState` with `dry_run=True` would
376
+ otherwise retire events that never reached the ledger, which is exactly the
377
+ bug above wearing a different hat.
378
+ """
379
+ if dry_run and not isinstance(state, DryRunState):
380
+ state = DryRunState(state)
381
+ if state.has_seen(event.id):
382
+ report.events_deduplicated += 1
383
+ return True
384
+ if not event.is_transaction:
385
+ state.mark_seen(event.id, now)
386
+ return True
387
+ if event.resource_version is not None:
388
+ recorded = state.version_of(event.resource_id)
389
+ if recorded is not None and event.resource_version <= recorded:
390
+ report.events_out_of_order += 1
391
+ state.mark_seen(event.id, now)
392
+ return True
393
+
394
+ applied = True
395
+ if event.operation is Operation.DELETE:
396
+ # Never destructive. A transaction the bank no longer says happened must
397
+ # produce an explicit response rather than a silent one, but which
398
+ # response -- cancel the row, reverse it, leave a note -- is a
399
+ # bookkeeping judgement, and a person makes it.
400
+ report.attention.append((
401
+ event.resource_id,
402
+ "deleted at Mercury; any Bank Transaction posted for it is still in the ledger and needs a person",
403
+ ))
404
+ report.applied_transactions.add(event.resource_id)
405
+ else:
406
+ transaction = transactions.get(event.resource_id)
407
+ if transaction is None:
408
+ # Retryable. The absence may be an account we cannot see yet, and
409
+ # retiring the event would make the transaction unreachable the
410
+ # moment visibility is fixed -- there is no second event coming.
411
+ report.attention.append((
412
+ event.resource_id,
413
+ "changed at Mercury but absent from the accounts we read; check the account is in [accounts]",
414
+ ))
415
+ applied = False
416
+ else:
417
+ applied = apply_transaction(
418
+ transaction, ctx, ledger, report, version=event.resource_version, dry_run=dry_run
419
+ )
420
+ # Recorded whether or not it posted: the event path has already
421
+ # said everything there is to say about this transaction, and the
422
+ # hint sweep repeating it would only double the report.
423
+ report.applied_transactions.add(transaction.id)
424
+
425
+ if not applied:
426
+ return False
427
+ state.mark_seen(event.id, now)
428
+ if event.resource_version is not None:
429
+ state.record_version(event.resource_id, event.resource_version)
430
+ report.events_applied += 1
431
+ return True
432
+
433
+
434
+ def sweep_hints(
435
+ transactions: Iterable[Transaction],
436
+ ctx: PostingContext,
437
+ ledger: Ledger,
438
+ report: IngestReport,
439
+ *,
440
+ dry_run: bool = False,
441
+ ) -> None:
442
+ """Bring Mercury's GL code and note up to date on every settled row no event touched.
443
+
444
+ Coding a transaction at Mercury -- picking its GL code, writing its note --
445
+ **emits no event and bumps no `resourceVersion`**. A sync that applied only
446
+ what the log named would therefore never deliver a person's coding, and
447
+ would say so in no report: the correction quietly stops applying and
448
+ nobody learns to distrust it. That work is the person's, so the run goes
449
+ and gets it rather than waiting for a message that is never sent.
450
+
451
+ Not the scheduled poll the design rules out. It adds no read: the run has
452
+ already fetched every transaction for the balance invariant, and this
453
+ compares what it holds against the ledger.
454
+
455
+ Writes through the same planner the event path uses, so a submitted row's
456
+ amount, date, description and account are compared and refused exactly as
457
+ before and only the two `allow_on_submit` hint fields are ever written.
458
+ `version=None` throughout: a GL edit bumps no resource version at Mercury,
459
+ and stamping one here would claim the log said something it did not.
460
+
461
+ O(rows) -- one ledger read per settled transaction per run, which at the
462
+ seventeen rows these books hold is nothing. If that ever matters, read the
463
+ posted rows once (`posted_transaction_ids` already pages the doctype),
464
+ widen it to carry the hint fields, and plan only the rows whose hints
465
+ differ.
466
+ """
467
+ for transaction in transactions:
468
+ if transaction.id in report.applied_transactions or not classify(transaction).posts:
469
+ continue
470
+ # The return value is the cursor's business and the sweep is not: it
471
+ # reads the transaction list, not the log, so nothing it finds may move
472
+ # or hold a cursor into the log.
473
+ apply_transaction(transaction, ctx, ledger, report, version=None, dry_run=dry_run, refresh_only=True)
474
+
475
+
476
+ def propose_vouchers(
477
+ transactions: Iterable[Transaction],
478
+ ctx: PostingContext,
479
+ ledger: Ledger,
480
+ report: IngestReport,
481
+ *,
482
+ own_account_ids: Iterable[str],
483
+ dry_run: bool = False,
484
+ ) -> None:
485
+ """Draft the voucher every coded row should have, and fold the result into the run's report.
486
+
487
+ Runs after the Bank Transaction side of every transaction has been dealt
488
+ with, on both paths. A sweep rather than an event response, for the reason
489
+ `sweep_hints` already is one: a person's GL code arrives with no event
490
+ behind it. `runningtab.mercury.vouchers` has the rules; nothing it does can
491
+ move or hold the cursor, because nothing it reads is the log.
492
+ """
493
+ result: VoucherReport = draft_vouchers(transactions, ctx, ledger, own_account_ids=own_account_ids, dry_run=dry_run)
494
+ report.drafted.extend(result.drafted)
495
+ report.updated.extend(result.updated)
496
+ report.planned.extend(result.planned)
497
+ report.attention.extend(result.attention)
498
+ report.closed.extend(result.closed)
499
+ report.unneeded.extend(result.unneeded)
500
+ # Mercury had no code for these, and the ledger accounts for them anyway.
501
+ report.uncoded = [row for row in report.uncoded if row[0] not in result.handled_in_ledger]
502
+
503
+
504
+ def fetch_receipts(
505
+ transactions: Iterable[Transaction],
506
+ ledger: Ledger,
507
+ report: IngestReport,
508
+ *,
509
+ receipts: ReceiptFetcher | None,
510
+ dry_run: bool = False,
511
+ ) -> None:
512
+ """Attach each Mercury receipt to its Bank Transaction, and fold the result into the run's report.
513
+
514
+ Runs last on both paths, after the Bank Transaction side of every
515
+ transaction has been dealt with, because a receipt needs the row it hangs
516
+ off to exist. A sweep for the reason `sweep_hints` is one: the attachment
517
+ list emits no event either. `runningtab.mercury.receipts` has the rules.
518
+
519
+ Nothing here can move or hold the cursor. A receipt that could not be
520
+ fetched is a document missing from a row, not an event missing from the
521
+ ledger, and holding the log's cursor for it would stall the feed over a
522
+ PDF.
523
+
524
+ **A caller that passes no fetcher gets no receipt pass.** This is the one
525
+ part of the ingest that reaches a host which is neither Mercury nor the
526
+ ledger, and a caller with no business making that request -- a test, a
527
+ future webhook consumer that only wants the log applied -- has to be able
528
+ to say so without a flag that reads like a feature toggle. The CLI always
529
+ passes one.
530
+ """
531
+ if receipts is None:
532
+ return
533
+ result: ReceiptReport = attach_receipts(transactions, ledger, fetcher=receipts, dry_run=dry_run)
534
+ report.attached.extend(result.attached)
535
+ report.planned.extend(result.planned)
536
+ report.attention.extend(result.attention)
537
+
538
+
539
+ def read_everything(mercury: MercuryClient) -> tuple[list[Transaction], list[BalanceCheck], list[str]]:
540
+ """Every transaction on every account, with each account's balance checked.
541
+
542
+ Reads the credit accounts as well as the deposit ones. `/accounts` omits
543
+ credit accounts entirely, and the first probe that forgot them produced a
544
+ balance that looked plausible and was wrong.
545
+ """
546
+ transactions: list[Transaction] = []
547
+ balances: list[BalanceCheck] = []
548
+ account_ids: list[str] = []
549
+ for account in mercury.accounts():
550
+ rows = mercury.account_transactions(account.id, start=account.created_at)
551
+ transactions.extend(rows)
552
+ balances.append(reconcile(account.id, account.name or account.id, account.current_balance, rows))
553
+ account_ids.append(account.id)
554
+ for credit in mercury.credit_accounts():
555
+ rows = mercury.account_transactions(credit.id, start=credit.created_at)
556
+ transactions.extend(rows)
557
+ balances.append(reconcile(credit.id, f"credit {credit.id}", credit.current_balance, rows))
558
+ account_ids.append(credit.id)
559
+ return transactions, balances, account_ids
560
+
561
+
562
+ def run_snapshot(
563
+ mercury: MercuryClient,
564
+ ledger: Ledger,
565
+ ctx: PostingContext,
566
+ state: IngestState,
567
+ *,
568
+ now: dt.datetime,
569
+ dry_run: bool = False,
570
+ receipts: ReceiptFetcher | None = None,
571
+ ) -> IngestReport:
572
+ """Establish initial state, and record where in the log that state was true.
573
+
574
+ The cursor is read **before** the transactions, not after. Anything that
575
+ changes while the snapshot is running then replays through the applier
576
+ afterwards; the alternative -- taking the cursor last -- silently drops
577
+ every change made during the read. Replay is harmless because a transaction
578
+ posts to one document, found by its Mercury id.
579
+
580
+ The cursor is recorded only when the run posted everything, reconciled, and
581
+ found a Mercury transaction behind every row the ledger already holds.
582
+ Any of those failing means the snapshot did not establish the state the
583
+ cursor would claim, and the rows it missed emit no further events.
584
+ """
585
+ report = IngestReport()
586
+ place = DryRunState(state) if dry_run else state
587
+ cursor = mercury.latest_event_id()
588
+ transactions, balances, account_ids = read_everything(mercury)
589
+ report.balances = balances
590
+ report.uncoded = uncoded_ages(transactions, now, own_account_ids=account_ids)
591
+ posted = True
592
+ for transaction in transactions:
593
+ posted &= apply_transaction(transaction, ctx, ledger, report, dry_run=dry_run)
594
+ propose_vouchers(transactions, ctx, ledger, report, own_account_ids=account_ids, dry_run=dry_run)
595
+ fetch_receipts(transactions, ledger, report, receipts=receipts, dry_run=dry_run)
596
+ # Bank Transactions whose Mercury transaction is no longer returned. The
597
+ # loop above only ever walks FORWARD, from Mercury into the ledger, so a
598
+ # transaction that disappeared -- a pending charge cancelled while the log
599
+ # had already aged past its retention window -- leaves its row standing and
600
+ # is invisible to every check here. Snapshot is the documented recovery
601
+ # from exactly that gap, which makes this the one place that can notice.
602
+ #
603
+ # Safe to read as "no longer returns": read_everything pages each account
604
+ # from its own createdAt and raises on a short read, so the set below is
605
+ # every transaction Mercury currently holds, not a recent window.
606
+ if transactions or report.balances:
607
+ seen = {transaction.id for transaction in transactions}
608
+ report.orphans = sorted(
609
+ (transaction_id, name)
610
+ for transaction_id, name in posted_transaction_ids(ledger).items()
611
+ if transaction_id not in seen
612
+ )
613
+ # Three ways a snapshot fails to establish the state its cursor would claim.
614
+ # The balance one is the more dangerous: the rows a short read missed were
615
+ # never seen at all, so nothing in the loop above could have reported them,
616
+ # and they emit no further events for a later sync to find.
617
+ unestablished: list[str] = []
618
+ if not posted:
619
+ unestablished.append("some transactions could not be posted")
620
+ if not report.balances:
621
+ # `balances_ok` is vacuously true over no accounts, so a read that saw
622
+ # none at all -- a token scoped to nothing, an endpoint that changed
623
+ # shape -- would otherwise sail past every check and record a cursor
624
+ # over transactions it never looked at.
625
+ unestablished.append("no account was read, so there was no state to establish")
626
+ if not report.balances_ok:
627
+ unestablished.append("an account does not reconcile, so the read was short")
628
+ if report.orphans:
629
+ # Not repaired here: nothing in this ingest cancels or deletes from the
630
+ # ledger, and whether an orphaned row is cancelled, reversed or kept is
631
+ # a bookkeeping decision. Held rather than merely reported, because
632
+ # moving the cursor would settle the question by silence -- the rows
633
+ # stay, and no later run ever looks for them again.
634
+ unestablished.append(
635
+ f"{len(report.orphans)} Bank Transaction(s) name a transaction Mercury no longer returns "
636
+ f"({', '.join(f'{name} -> {tid}' for tid, name in report.orphans[:5])}"
637
+ f"{', ...' if len(report.orphans) > 5 else ''}) -- resolve those rows, then re-run"
638
+ )
639
+ if unestablished:
640
+ report.attention.append((
641
+ "cursor",
642
+ f"not recorded: {'; '.join(unestablished)} -- a cursor would claim a state this run "
643
+ "did not establish, so fix the reported problems and re-run the snapshot",
644
+ ))
645
+ else:
646
+ place.record_cursor(cursor, now)
647
+ report.cursor = cursor
648
+ return report
649
+
650
+
651
+ def run_sync(
652
+ mercury: MercuryClient,
653
+ ledger: Ledger,
654
+ ctx: PostingContext,
655
+ state: IngestState,
656
+ *,
657
+ now: dt.datetime,
658
+ dry_run: bool = False,
659
+ receipts: ReceiptFetcher | None = None,
660
+ ) -> IngestReport:
661
+ """Apply everything that happened since the cursor, then the three sweeps the log cannot carry.
662
+
663
+ Four passes, and only the first is the log's. Mercury reports a GL code or
664
+ note edited after a transaction posted through no event at all, so the
665
+ second pass sweeps every settled row this run read and brings those two
666
+ fields up to date -- `sweep_hints` has the reasoning -- the third drafts
667
+ the voucher each coded row should have, which keys on the same unannounced
668
+ field, and the fourth attaches the receipts, whose list is unannounced too.
669
+ All three are independent of the cursor in both directions: they neither
670
+ move it nor hold it.
671
+
672
+ Raises `StaleCursorError` rather than resuming a cursor older than Mercury's
673
+ retention window: past 90 days the log cannot say what was missed, and the
674
+ honest recovery is another snapshot.
675
+
676
+ Holds the cursor when no account was read at all, the same as a snapshot
677
+ does. A quiet hour and a token that lost its accounts look identical from
678
+ here -- no events, nothing posted -- and only the account inventory tells
679
+ them apart.
680
+ """
681
+ report = IngestReport()
682
+ place = DryRunState(state) if dry_run else state
683
+ cursor = place.require_resumable(now)
684
+ events = mercury.events(start_after=cursor.event_id)
685
+ report.events_read = len(events)
686
+ transactions, balances, account_ids = read_everything(mercury)
687
+ report.balances = balances
688
+ by_id = {t.id: t for t in transactions}
689
+ report.uncoded = uncoded_ages(transactions, now, own_account_ids=account_ids)
690
+ # The cursor stops before the first event that could not be applied, while
691
+ # the events after it are still attempted: they are idempotent, so doing
692
+ # them now saves work, and the ones that succeeded are marked seen and
693
+ # deduplicated when the re-run reaches them. What must not happen is the
694
+ # cursor stepping over an event whose transaction never reached the ledger.
695
+ last = cursor.event_id
696
+ held = False
697
+ for event in events:
698
+ applied = apply_event(event, by_id, ctx, ledger, place, report, now=now, dry_run=dry_run)
699
+ if not applied:
700
+ held = True
701
+ elif not held:
702
+ last = event.id
703
+ # Then everything the log had nothing to say about. A person's coding at
704
+ # the bank arrives no other way -- see `sweep_hints` -- and it changes
705
+ # nothing above it: the cursor, the dedup set and the version guard are the
706
+ # log's, and the sweep never touches them.
707
+ sweep_hints(transactions, ctx, ledger, report, dry_run=dry_run)
708
+ propose_vouchers(transactions, ctx, ledger, report, own_account_ids=account_ids, dry_run=dry_run)
709
+ fetch_receipts(transactions, ledger, report, receipts=receipts, dry_run=dry_run)
710
+ if not report.balances:
711
+ # `balances_ok` is vacuously true over no accounts, so losing account
712
+ # visibility during a quiet spell produced a clean report and a
713
+ # refreshed cursor: nothing to apply, nothing failing to reconcile,
714
+ # nothing wrong. The ingest would go on looking healthy for as long as
715
+ # the access stayed broken, and the retention guard -- which measures
716
+ # from the last recorded cursor -- would never fire either, because
717
+ # every run kept moving it.
718
+ held = True
719
+ report.attention.append((
720
+ "accounts",
721
+ "no account was read, so this run could not tell a quiet period from lost access -- "
722
+ "the cursor is held until at least one account comes back",
723
+ ))
724
+ if held:
725
+ # The timestamp is left alone as well as the id. Re-stamping a cursor
726
+ # that did not move would make a stuck ingest look freshly synced
727
+ # forever, and the retention guard measures from the last time we
728
+ # actually caught up -- which this run did not.
729
+ report.cursor = cursor.event_id
730
+ else:
731
+ place.record_cursor(last, now)
732
+ report.cursor = last
733
+ return report