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.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- 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
|