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,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