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,360 @@
|
|
|
1
|
+
"""A read-only Mercury client.
|
|
2
|
+
|
|
3
|
+
Read-only structurally, not by convention: the only verb this class can send is
|
|
4
|
+
GET. There is no `post`, no `patch`, and no generic `request` escape hatch, so
|
|
5
|
+
"the ingest never writes a Mercury record" is a property of the code rather
|
|
6
|
+
than a rule someone has to remember. Writing a transaction note would need
|
|
7
|
+
`updateTransactions`, an allowlisted scope, and the static egress behind it --
|
|
8
|
+
deferred deliberately, so nothing here is arranged to make it easy.
|
|
9
|
+
|
|
10
|
+
This repo can make exactly three Mercury writes, and none lives here:
|
|
11
|
+
`create_category`, `edit_category` and `delete_category` on
|
|
12
|
+
`MercuryCategoryWriter`, in `runningtab.mercury.categories`, reached only by
|
|
13
|
+
`runningtab mercury categories apply`. `runningtab.mercury.ingest` does not import that
|
|
14
|
+
module, so the ingest's dependency on Mercury stops at this file and this file
|
|
15
|
+
has no verb but GET.
|
|
16
|
+
|
|
17
|
+
The base URL is construction input and never defaults to production. Sandbox and
|
|
18
|
+
production are different hosts, and a client that defaults to one of them is a
|
|
19
|
+
client that eventually reaches it by accident.
|
|
20
|
+
|
|
21
|
+
`runningtab health` imports this module directly: `MercuryClient` plus `reconcile`
|
|
22
|
+
in `runningtab.mercury.ingest` are the two pieces a health check needs, and neither
|
|
23
|
+
depends on the ledger side.
|
|
24
|
+
|
|
25
|
+
Two shapes matter more than the rest, both established by measurement:
|
|
26
|
+
|
|
27
|
+
- **`/accounts` omits credit accounts entirely.** The card comes back only from
|
|
28
|
+
`/credit`, with a much smaller schema. Two models, not one with optional
|
|
29
|
+
fields.
|
|
30
|
+
- **Per-account transactions default to the last 30 days.** `start` is not
|
|
31
|
+
optional in practice: leaving it off silently answers a different question
|
|
32
|
+
than "every transaction on this account", and the balance invariant would
|
|
33
|
+
then report a discrepancy that is really a short read. Callers pass the
|
|
34
|
+
account's `createdAt` to mean "all of them".
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import contextlib
|
|
40
|
+
import dataclasses
|
|
41
|
+
import datetime as dt
|
|
42
|
+
import enum
|
|
43
|
+
import json
|
|
44
|
+
from collections.abc import Generator
|
|
45
|
+
from decimal import Decimal
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
import httpx2
|
|
49
|
+
|
|
50
|
+
from runningtab.mercury.models import (
|
|
51
|
+
Account,
|
|
52
|
+
Category,
|
|
53
|
+
CreditAccount,
|
|
54
|
+
Event,
|
|
55
|
+
Transaction,
|
|
56
|
+
parse_account,
|
|
57
|
+
parse_category,
|
|
58
|
+
parse_credit_account,
|
|
59
|
+
parse_event,
|
|
60
|
+
parse_transaction,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Mercury's own page ceiling for the event log.
|
|
64
|
+
EVENT_PAGE_SIZE = 1000
|
|
65
|
+
# And for the category list, which pages the same way with a different cursor.
|
|
66
|
+
CATEGORY_PAGE_SIZE = 1000
|
|
67
|
+
# Transactions have no documented ceiling; this sizes each request, and is not
|
|
68
|
+
# a limit on what `account_transactions` returns.
|
|
69
|
+
TRANSACTION_PAGE_SIZE = 500
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MercuryEnv(enum.StrEnum):
|
|
73
|
+
"""Which Mercury deployment. Named explicitly at every call site; no default."""
|
|
74
|
+
|
|
75
|
+
SANDBOX = "sandbox"
|
|
76
|
+
PRODUCTION = "production"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
BASE_URLS: dict[MercuryEnv, str] = {
|
|
80
|
+
MercuryEnv.SANDBOX: "https://api-sandbox.mercury.com/api/v1",
|
|
81
|
+
MercuryEnv.PRODUCTION: "https://api.mercury.com/api/v1",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MercuryError(RuntimeError):
|
|
86
|
+
"""Mercury refused a request."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, message: str, *, status: int | None = None) -> None:
|
|
89
|
+
super().__init__(message)
|
|
90
|
+
self.status = status
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TruncatedReadError(MercuryError):
|
|
94
|
+
"""The account returned fewer transactions than its own `total` claims exist.
|
|
95
|
+
|
|
96
|
+
Worth its own type. A silently short read is the failure mode that put a
|
|
97
|
+
wrong balance in front of the investigation, and on a feed driving a general
|
|
98
|
+
ledger a silent gap is the worst thing that can happen.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class MalformedResponseError(MercuryError):
|
|
103
|
+
"""Mercury answered with a success status and a body this client cannot read.
|
|
104
|
+
|
|
105
|
+
A proxy's HTML error page under a 200, a truncated body, a field that has
|
|
106
|
+
moved. An error status is the same failure wearing different clothes, which
|
|
107
|
+
is why this is a `MercuryError`: to a caller both mean "no answer". Typed so
|
|
108
|
+
the failure arrives as a report line -- `runningtab health` has five sections to
|
|
109
|
+
print, and a `JSONDecodeError` out of the middle of one takes the other four
|
|
110
|
+
with it.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclasses.dataclass(frozen=True)
|
|
115
|
+
class MercuryConfig:
|
|
116
|
+
"""Where Mercury is and which token reads it."""
|
|
117
|
+
|
|
118
|
+
base_url: str
|
|
119
|
+
# Kept out of the repr: this object shows up in log lines and pytest
|
|
120
|
+
# assertion output, and a bank token has no business riding along.
|
|
121
|
+
token: str = dataclasses.field(repr=False)
|
|
122
|
+
timeout: float = 30.0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def config_for(env: MercuryEnv, token: str, *, timeout: float = 30.0) -> MercuryConfig:
|
|
126
|
+
"""Config for a named deployment. The only place a base URL is chosen."""
|
|
127
|
+
return MercuryConfig(base_url=BASE_URLS[env], token=token, timeout=timeout)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _rows(payload: Any, *keys: str) -> list[dict[str, Any]]:
|
|
131
|
+
"""The row list out of a response envelope.
|
|
132
|
+
|
|
133
|
+
Mercury's envelope keys are not uniform across resources and the
|
|
134
|
+
investigation recorded the pagination behaviour rather than the exact
|
|
135
|
+
wrappers, so each caller names the keys it expects and a bare list is
|
|
136
|
+
accepted too. An envelope carrying none of them raises rather than
|
|
137
|
+
returning nothing -- an empty read and an unrecognised envelope must not
|
|
138
|
+
look alike.
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(payload, list):
|
|
141
|
+
return payload
|
|
142
|
+
if isinstance(payload, dict):
|
|
143
|
+
for key in keys:
|
|
144
|
+
value = payload.get(key)
|
|
145
|
+
if isinstance(value, list):
|
|
146
|
+
return value
|
|
147
|
+
raise MercuryError(f"expected a list under one of {list(keys)}; got {type(payload).__name__} {str(payload)[:200]}")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _loads(body: str) -> Any:
|
|
151
|
+
"""Parse a Mercury response with every JSON number as `Decimal`.
|
|
152
|
+
|
|
153
|
+
`parse_float=Decimal` is the whole point: `-47.61` must stay `-47.61`, not
|
|
154
|
+
become the nearest double. `models.money` would recover it from `str()`
|
|
155
|
+
anyway, but no float ever existing is a shorter thing to be sure of.
|
|
156
|
+
"""
|
|
157
|
+
return json.loads(body, parse_float=Decimal)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@contextlib.contextmanager
|
|
161
|
+
def _readable(path: str) -> Generator[None]:
|
|
162
|
+
"""Turn a body this client cannot read into `MalformedResponseError`.
|
|
163
|
+
|
|
164
|
+
Wraps decoding and parsing, which fail with whatever the standard library
|
|
165
|
+
raises for a missing key, a value of the wrong type, or a timestamp that is
|
|
166
|
+
not one -- `models.MercuryPayloadError` among them, since it is a
|
|
167
|
+
`ValueError`. Those are the ways a 200 can still be no answer, and they are
|
|
168
|
+
the caller's to report rather than to crash on.
|
|
169
|
+
"""
|
|
170
|
+
try:
|
|
171
|
+
yield
|
|
172
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
173
|
+
raise MalformedResponseError(f"{path} answered 200 with a body this client cannot read: {exc!r}") from exc
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class MercuryClient:
|
|
177
|
+
"""Synchronous, read-only. No verb but GET, and no method that writes anything.
|
|
178
|
+
|
|
179
|
+
The three Mercury writes this repo can make are on `MercuryCategoryWriter`
|
|
180
|
+
in `runningtab.mercury.categories`, which is a separate class holding a separate
|
|
181
|
+
token. Adding any of those verbs here would hand the ingest a capability it
|
|
182
|
+
has no use for -- `delete_category` most of all, since the ingest reads the
|
|
183
|
+
rows a deletion would strip the coding off.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, config: MercuryConfig, *, transport: httpx2.BaseTransport | None = None) -> None:
|
|
187
|
+
self._config = config
|
|
188
|
+
self._http = httpx2.Client(timeout=config.timeout, transport=transport)
|
|
189
|
+
|
|
190
|
+
def __enter__(self) -> MercuryClient:
|
|
191
|
+
return self
|
|
192
|
+
|
|
193
|
+
def __exit__(self, *exc: object) -> None:
|
|
194
|
+
self.close()
|
|
195
|
+
|
|
196
|
+
def close(self) -> None:
|
|
197
|
+
"""Release the underlying connection pool."""
|
|
198
|
+
self._http.close()
|
|
199
|
+
|
|
200
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
201
|
+
response = self._http.request(
|
|
202
|
+
"GET",
|
|
203
|
+
f"{self._config.base_url.rstrip('/')}{path}",
|
|
204
|
+
params=params or None,
|
|
205
|
+
headers={"Authorization": f"Bearer {self._config.token}", "Accept": "application/json"},
|
|
206
|
+
)
|
|
207
|
+
if response.status_code >= 400:
|
|
208
|
+
raise MercuryError(
|
|
209
|
+
f"GET {path} -> {response.status_code}: {response.text[:300]}", status=response.status_code
|
|
210
|
+
)
|
|
211
|
+
with _readable(path):
|
|
212
|
+
return _loads(response.text)
|
|
213
|
+
|
|
214
|
+
def accounts(self) -> list[Account]:
|
|
215
|
+
"""Deposit accounts. Credit accounts are not here; see `credit_accounts`."""
|
|
216
|
+
payload = self._get("/accounts")
|
|
217
|
+
with _readable("/accounts"):
|
|
218
|
+
return [parse_account(row) for row in _rows(payload, "accounts", "data")]
|
|
219
|
+
|
|
220
|
+
def credit_accounts(self) -> list[CreditAccount]:
|
|
221
|
+
"""Credit accounts, which `/accounts` omits entirely."""
|
|
222
|
+
payload = self._get("/credit")
|
|
223
|
+
with _readable("/credit"):
|
|
224
|
+
return [parse_credit_account(row) for row in _rows(payload, "accounts", "credit", "data")]
|
|
225
|
+
|
|
226
|
+
def categories(self, *, page_size: int = CATEGORY_PAGE_SIZE) -> list[Category]:
|
|
227
|
+
"""Every custom category, read to the end of the list.
|
|
228
|
+
|
|
229
|
+
Paged to exhaustion rather than read a page at a time, because a short
|
|
230
|
+
read here is not a smaller answer -- it is a wrong one.
|
|
231
|
+
`runningtab.mercury.categories` reconciles by name, so a category the read
|
|
232
|
+
missed is reported as absent, and acting on that creates a duplicate.
|
|
233
|
+
Two categories with one name is the single state that design then
|
|
234
|
+
refuses to resolve, since it cannot tell which one transactions carry.
|
|
235
|
+
|
|
236
|
+
Walks `page.nextPage`, which is the cursor Mercury's own envelope
|
|
237
|
+
supplies, and stops on a page carrying nothing new -- a server that
|
|
238
|
+
ignores the cursor repeats itself rather than paging forever.
|
|
239
|
+
"""
|
|
240
|
+
collected: list[Category] = []
|
|
241
|
+
seen: set[str] = set()
|
|
242
|
+
cursor: str | None = None
|
|
243
|
+
while True:
|
|
244
|
+
params: dict[str, Any] = {"limit": page_size, "order": "asc"}
|
|
245
|
+
if cursor is not None:
|
|
246
|
+
params["start_after"] = cursor
|
|
247
|
+
payload = self._get("/categories", params)
|
|
248
|
+
with _readable("/categories"):
|
|
249
|
+
page = [parse_category(row) for row in _rows(payload, "categories", "data")]
|
|
250
|
+
next_page = (payload.get("page") or {}).get("nextPage") if isinstance(payload, dict) else None
|
|
251
|
+
fresh = 0
|
|
252
|
+
for category in page:
|
|
253
|
+
if category.id in seen:
|
|
254
|
+
continue
|
|
255
|
+
seen.add(category.id)
|
|
256
|
+
collected.append(category)
|
|
257
|
+
fresh += 1
|
|
258
|
+
if next_page is None or not fresh:
|
|
259
|
+
break
|
|
260
|
+
cursor = str(next_page)
|
|
261
|
+
return collected
|
|
262
|
+
|
|
263
|
+
def account_transactions(
|
|
264
|
+
self, account_id: str, *, start: dt.datetime | None = None, page_size: int = TRANSACTION_PAGE_SIZE
|
|
265
|
+
) -> list[Transaction]:
|
|
266
|
+
"""Every transaction on one account, checked against the account's own `total`.
|
|
267
|
+
|
|
268
|
+
Per-account rather than org-wide deliberately: the org-wide endpoint
|
|
269
|
+
pages on an opaque cursor and reports no total, so a truncated read
|
|
270
|
+
there is indistinguishable from a small account. Here `total` makes
|
|
271
|
+
completeness assertable, and a short read raises. A response carrying no
|
|
272
|
+
`total` is unreadable rather than assumed complete: guessing would turn
|
|
273
|
+
the one check that makes this endpoint worth preferring into a shrug.
|
|
274
|
+
|
|
275
|
+
`start` is how "every transaction" is said -- pass the account's
|
|
276
|
+
`createdAt`. The endpoint's own default is the last 30 days, and a read
|
|
277
|
+
that quietly stopped there would hand the balance invariant a short sum
|
|
278
|
+
to blame on the bank.
|
|
279
|
+
|
|
280
|
+
Counted by distinct transaction id, because `total` counts distinct
|
|
281
|
+
transactions. A row the server repeats -- inside one page, or on a page
|
|
282
|
+
it re-sends after a retry -- would otherwise carry a short read up to
|
|
283
|
+
`total`, so the read would report itself complete while missing a
|
|
284
|
+
transaction and handing a doubled charge to the balance invariant that
|
|
285
|
+
exists to catch exactly that.
|
|
286
|
+
"""
|
|
287
|
+
path = f"/account/{account_id}/transactions"
|
|
288
|
+
collected: list[Transaction] = []
|
|
289
|
+
seen: set[str] = set()
|
|
290
|
+
total = 0
|
|
291
|
+
while True:
|
|
292
|
+
params: dict[str, Any] = {"limit": page_size, "offset": len(collected), "order": "asc"}
|
|
293
|
+
if start is not None:
|
|
294
|
+
params["start"] = start.date().isoformat()
|
|
295
|
+
payload = self._get(path, params)
|
|
296
|
+
with _readable(path):
|
|
297
|
+
total = int(payload["total"])
|
|
298
|
+
page = [parse_transaction(row, account_id) for row in _rows(payload, "transactions", "data")]
|
|
299
|
+
fresh = 0
|
|
300
|
+
for transaction in page:
|
|
301
|
+
if transaction.id in seen:
|
|
302
|
+
continue
|
|
303
|
+
seen.add(transaction.id)
|
|
304
|
+
collected.append(transaction)
|
|
305
|
+
fresh += 1
|
|
306
|
+
if not fresh:
|
|
307
|
+
# An empty page, or one carrying nothing this read has not
|
|
308
|
+
# already got: either way the read has stopped advancing, and
|
|
309
|
+
# asking again returns the same thing. Whether stopping here
|
|
310
|
+
# leaves it short is the `total` check's answer, below -- not
|
|
311
|
+
# something to page forever hoping to fix. The offset is walked
|
|
312
|
+
# by distinct rows for the same reason: a page overlapping the
|
|
313
|
+
# last one would otherwise step past as many positions as it
|
|
314
|
+
# repeated, and the gap would never be asked for.
|
|
315
|
+
break
|
|
316
|
+
if len(collected) >= total:
|
|
317
|
+
break
|
|
318
|
+
if len(collected) != total:
|
|
319
|
+
raise TruncatedReadError(
|
|
320
|
+
f"account {account_id}: read {len(collected)} transactions but the server reported total={total}; "
|
|
321
|
+
f"the balance invariant would blame the bank for a short read"
|
|
322
|
+
)
|
|
323
|
+
return collected
|
|
324
|
+
|
|
325
|
+
def events(self, *, start_after: str | None = None, page_size: int = EVENT_PAGE_SIZE) -> list[Event]:
|
|
326
|
+
"""Every event after `start_after`, in the log's own ascending order.
|
|
327
|
+
|
|
328
|
+
`start_after` is exclusive and the cursor is an event id, so resumption
|
|
329
|
+
has no overlap window to reason about. Paging walks by handing the last
|
|
330
|
+
id of one page back as the `start_after` of the next, which needs no
|
|
331
|
+
knowledge of the envelope's own cursor field.
|
|
332
|
+
"""
|
|
333
|
+
collected: list[Event] = []
|
|
334
|
+
cursor = start_after
|
|
335
|
+
while True:
|
|
336
|
+
params: dict[str, Any] = {"limit": page_size}
|
|
337
|
+
if cursor is not None:
|
|
338
|
+
params["start_after"] = cursor
|
|
339
|
+
payload = self._get("/events", params)
|
|
340
|
+
with _readable("/events"):
|
|
341
|
+
page = _rows(payload, "events", "data")
|
|
342
|
+
parsed = [parse_event(row) for row in page]
|
|
343
|
+
if not page:
|
|
344
|
+
break
|
|
345
|
+
collected.extend(parsed)
|
|
346
|
+
cursor = parsed[-1].id
|
|
347
|
+
if len(page) < page_size:
|
|
348
|
+
break
|
|
349
|
+
return collected
|
|
350
|
+
|
|
351
|
+
def latest_event_id(self) -> str | None:
|
|
352
|
+
"""The newest event id, or None on an empty log.
|
|
353
|
+
|
|
354
|
+
Read by walking the log to its end rather than by asking for it
|
|
355
|
+
descending: the log's ordering parameter is documented as defaulting to
|
|
356
|
+
ascending, and a reversed read is one more thing to be wrong about for
|
|
357
|
+
a call made once per snapshot.
|
|
358
|
+
"""
|
|
359
|
+
events = self.events()
|
|
360
|
+
return events[-1].id if events else None
|