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
runningtab/site.py ADDED
@@ -0,0 +1,409 @@
1
+ """Which ERPNext site, and where its credentials come from.
2
+
3
+ Two targets, deliberately asymmetric. `test` is a disposable site whose
4
+ credentials come from the environment, falling back to a stored key for that
5
+ one site when nothing is exported, and it refuses to run against anything but
6
+ a host named in the books' `test_hosts`, no matter what the environment or the
7
+ secret says. `books` is the real ledger, and its
8
+ credential is fetched from Secret Manager at the moment it is used -- never
9
+ held during development, and never substitutable by an exported variable.
10
+
11
+ There is no default target. Reaching the ledger requires naming it.
12
+
13
+ Which hosts, which secrets and which project come from the books directory's
14
+ `books.toml` (`runningtab.books`), as a `Site`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import dataclasses
20
+ import enum
21
+ import os
22
+ from urllib.parse import urlsplit
23
+
24
+ from icedcoffee_sync.frappe.client import SiteConfig
25
+
26
+ from runningtab.mercury.client import MercuryConfig, MercuryEnv, config_for
27
+
28
+
29
+ @dataclasses.dataclass(frozen=True)
30
+ class Site:
31
+ """Where the ledger and its test site are, and which Secret Manager secrets reach them.
32
+
33
+ Every secret here is a Secret Manager secret ID in `secret_project`, not a
34
+ credential.
35
+ """
36
+
37
+ # The ledger's host. Only `books_secret`'s and `books_readonly_secret`'s
38
+ # credentials are sent there.
39
+ host: str
40
+ # Every host `--target test` may point at. An allowlist, so an alias of the
41
+ # production site that shares nothing textually with its hostname is
42
+ # refused by default; reaching a test site means naming it here.
43
+ # `runningtab.books` refuses a list that names `host` itself.
44
+ test_hosts: frozenset[str]
45
+ secret_project: str
46
+ # The bot's credential: the one that changes the ledger.
47
+ books_secret: str
48
+ # The reader's own credential, held in its own secret so that read access
49
+ # can be granted to something write access is not: `coa plan` runs on every
50
+ # merge request, where the branch decides what code runs, and it has no
51
+ # business holding a key that can change the ledger.
52
+ books_readonly_secret: str
53
+ # Mercury's token, which is **read-only**: the integration never writes a
54
+ # Mercury record, so nothing in production should hold a credential that
55
+ # could.
56
+ mercury_secret: str
57
+ # A System Manager key for the test site, so anything running under
58
+ # application default credentials -- an agent, a scheduled job -- reaches
59
+ # the disposable site with nothing exported. It is a *site* API key: it
60
+ # reaches that one site and cannot touch another, which is why one may be
61
+ # stored while the Frappe Cloud team token, which can reach the books
62
+ # site, stays with a human.
63
+ test_admin_secret: str
64
+
65
+
66
+ # The sandbox token stays an environment variable. It reads seeded data on a
67
+ # host that is not the bank, and putting it in Secret Manager would suggest the
68
+ # two are interchangeable.
69
+ MERCURY_SANDBOX_ENV = "MERCURY_SANDBOX_TOKEN"
70
+
71
+
72
+ class Target(enum.StrEnum):
73
+ """Which site a command acts on."""
74
+
75
+ TEST = "test"
76
+ BOOKS = "books"
77
+
78
+
79
+ class Profile(enum.StrEnum):
80
+ """Which identity on that site: the one that changes the ledger, or the one that only reads it."""
81
+
82
+ BOT = "bot"
83
+ READER = "reader"
84
+
85
+
86
+ def _hostname(url: str) -> str:
87
+ """`url`'s host, lowercased, trailing dot stripped -- the one thing that identifies a site.
88
+
89
+ A fully-qualified "ledger.example." resolves to the same site,
90
+ and a hostname mentioned in a path or query string is not that site.
91
+ """
92
+ return (urlsplit(url).hostname or "").lower().rstrip(".")
93
+
94
+
95
+ def normalised_host(host: str) -> str:
96
+ """A bare hostname as `_hostname` would read it out of a URL: lowercased, trailing dot stripped."""
97
+ return host.lower().rstrip(".")
98
+
99
+
100
+ def is_books_site(site: Site, url: str) -> bool:
101
+ """True when `url`'s host is the books site's. Identification only; `is_test_site` is the guard."""
102
+ return _hostname(url) == normalised_host(site.host)
103
+
104
+
105
+ def is_test_site(site: Site, url: str) -> bool:
106
+ """True when `url`'s host is in `site.test_hosts` -- the guard the test target and live fixtures share."""
107
+ return _hostname(url) in {normalised_host(host) for host in site.test_hosts}
108
+
109
+
110
+ class SiteCredentialError(RuntimeError):
111
+ """A credential this target needs could not be obtained, for any reason.
112
+
113
+ Its own type so a command that reports rather than crashes can print what
114
+ went wrong instead of handing an operator a traceback. A subclass of
115
+ `RuntimeError`, so nothing that already catches one changes behaviour.
116
+ """
117
+
118
+
119
+ class MissingSiteCredentialError(SiteCredentialError):
120
+ """A credential this target needs is not in the environment.
121
+
122
+ Distinct from its parent because "nobody exported this" and "Secret
123
+ Manager refused us" send whoever reads the message to different places.
124
+ """
125
+
126
+
127
+ def _require(name: str) -> str:
128
+ value = os.environ.get(name)
129
+ if not value:
130
+ raise MissingSiteCredentialError(f"{name} is not set; export the test site's credentials first")
131
+ return value
132
+
133
+
134
+ def _split_token(value: str, source: str) -> tuple[str, str]:
135
+ """Split a Frappe credential into the key and secret it joins with a colon.
136
+
137
+ Accepts the value bare (`key:secret`) or exactly as ERPNext displays it in
138
+ the Authorization header (`token key:secret`), so a paste from the app
139
+ works without editing, wherever it was pasted. `source` names where the
140
+ value came from, since that is what whoever reads the message has to fix.
141
+ """
142
+ value = value.strip().removeprefix("token ").strip()
143
+ key, _, secret = value.partition(":")
144
+ if not (key and secret):
145
+ raise SiteCredentialError(f"{source} is not in the expected 'key:secret' form (a leading 'token ' is allowed)")
146
+ return key, secret
147
+
148
+
149
+ def _fetch_books_credential(site: Site, secret_id: str) -> tuple[str, str, str]:
150
+ """Read the books site's URL, key, and secret from Secret Manager.
151
+
152
+ Every way the read can fail -- an absent secret, a refused permission, an
153
+ unreachable API, a payload in the wrong shape -- becomes a
154
+ `SiteCredentialError` carrying the underlying message, so a command that
155
+ reports can print it instead of crashing halfway through.
156
+ """
157
+ project = site.secret_project
158
+ path = f"projects/{project}/secrets/{secret_id}/versions/latest"
159
+ try:
160
+ from google.cloud import secretmanager
161
+
162
+ client = secretmanager.SecretManagerServiceClient()
163
+ payload = client.access_secret_version(request={"name": path}).payload.data.decode()
164
+ except Exception as exc:
165
+ raise SiteCredentialError(f"could not read secret {secret_id} in {project}: {exc}") from exc
166
+ key, _, secret = payload.partition(":")
167
+ if not (key and secret):
168
+ raise SiteCredentialError(f"secret {secret_id} in {project} is not in the expected 'key:secret' form")
169
+ return f"https://{site.host}", key, secret
170
+
171
+
172
+ def _secret_payload(site: Site, secret_id: str) -> str:
173
+ """The latest version of one secret in `site.secret_project`, decoded and stripped.
174
+
175
+ Split out from its caller so a test can stub the read alone and still
176
+ exercise the parsing, the empty check, and the error wrapping for real.
177
+ """
178
+ from google.cloud import secretmanager
179
+
180
+ path = f"projects/{site.secret_project}/secrets/{secret_id}/versions/latest"
181
+ client = secretmanager.SecretManagerServiceClient()
182
+ return client.access_secret_version(request={"name": path}).payload.data.decode().strip()
183
+
184
+
185
+ def _fetch_test_credential(site: Site) -> tuple[str, str]:
186
+ """Read the test site's stored System Manager key from Secret Manager.
187
+
188
+ Every way the read can fail -- an absent secret, a refused permission, an
189
+ unreachable API -- becomes a `SiteCredentialError` naming the secret, so a
190
+ command that reports can print it instead of crashing partway through.
191
+ """
192
+ secret_id, project = site.test_admin_secret, site.secret_project
193
+ try:
194
+ payload = _secret_payload(site, secret_id)
195
+ except Exception as exc:
196
+ raise SiteCredentialError(
197
+ f"could not read secret {secret_id} in {project}: {exc}. "
198
+ f"That secret holds a System Manager API key for the test site."
199
+ ) from exc
200
+ if not payload:
201
+ raise MissingSiteCredentialError(
202
+ f"secret {secret_id} in {project} is empty; it should hold the test site's '<api_key>:<api_secret>'"
203
+ )
204
+ return _split_token(payload, f"secret {secret_id} in {project}")
205
+
206
+
207
+ def _test_site_url(site: Site) -> str:
208
+ """The allowlisted test host as a URL.
209
+
210
+ Derived from `site.test_hosts` rather than kept as a second setting: a
211
+ default the allowlist then refuses is a stall whose cause nobody can read.
212
+ A second allowlisted host makes the choice ambiguous, and saying so beats
213
+ picking one.
214
+ """
215
+ if len(site.test_hosts) != 1:
216
+ raise SiteCredentialError(
217
+ f"test_hosts names {len(site.test_hosts)} hosts "
218
+ f"({', '.join(sorted(site.test_hosts))}); set RUNNINGTAB_TEST_URL to say which one"
219
+ )
220
+ (host,) = site.test_hosts
221
+ return f"https://{host}"
222
+
223
+
224
+ def store_books_credential(site: Site, key: str, secret: str, secret_id: str) -> str:
225
+ """Store a freshly minted credential as the latest version of `secret_id`.
226
+
227
+ Mirrors `_fetch_books_credential`'s `"key:secret"` payload shape exactly --
228
+ that is the only format `site_config` knows how to read back.
229
+
230
+ Args:
231
+ site: Which project holds the secret.
232
+ key: The bot's API key.
233
+ secret: The bot's API secret.
234
+ secret_id: `site.books_secret` for the bot, `site.books_readonly_secret` for the reader.
235
+
236
+ Returns:
237
+ The created version's resource name (`.../versions/<n>`).
238
+ """
239
+ from google.cloud import secretmanager
240
+
241
+ client = secretmanager.SecretManagerServiceClient()
242
+ parent = f"projects/{site.secret_project}/secrets/{secret_id}"
243
+ payload = f"{key}:{secret}".encode()
244
+ response = client.add_secret_version(request={"parent": parent, "payload": {"data": payload}})
245
+ return response.name
246
+
247
+
248
+ def mercury_token(site: Site, env: MercuryEnv) -> str:
249
+ """The Mercury token for one deployment.
250
+
251
+ Asymmetric on purpose, the same way the ERPNext targets are: the sandbox's
252
+ token is an exported variable because it reads seeded data, and the real
253
+ one is fetched from Secret Manager at the moment it is used.
254
+
255
+ Every way this can fail is a `SiteCredentialError` carrying the underlying
256
+ message, so a report can print it instead of crashing: `runningtab health` has
257
+ five sections to print, and a traceback out of the middle of one takes the
258
+ other four with it. It does not claim to know *why* -- a revoked permission
259
+ and a secret nobody has created yet both arrive here, and the API's own
260
+ error says which.
261
+ """
262
+ if env is MercuryEnv.SANDBOX:
263
+ if not os.environ.get(MERCURY_SANDBOX_ENV):
264
+ raise MissingSiteCredentialError(
265
+ f"{MERCURY_SANDBOX_ENV} is not set. Create an API token inside the Mercury sandbox dashboard "
266
+ f"(a production token does not work there) and export it."
267
+ )
268
+ return _require(MERCURY_SANDBOX_ENV)
269
+ secret_id, project = site.mercury_secret, site.secret_project
270
+ path = f"projects/{project}/secrets/{secret_id}/versions/latest"
271
+ try:
272
+ from google.cloud import secretmanager
273
+
274
+ client = secretmanager.SecretManagerServiceClient()
275
+ token = client.access_secret_version(request={"name": path}).payload.data.decode().strip()
276
+ except Exception as exc:
277
+ raise SiteCredentialError(
278
+ f"could not read secret {secret_id} in {project}: {exc}. That secret holds the read-only Mercury token."
279
+ ) from exc
280
+ if not token:
281
+ raise MissingSiteCredentialError(
282
+ f"secret {secret_id} in {project} is empty; it should hold a read-only Mercury token"
283
+ )
284
+ return token
285
+
286
+
287
+ def mercury_config(site: Site, env: MercuryEnv) -> MercuryConfig:
288
+ """Where Mercury is and which token reads it. Never defaults to production."""
289
+ return config_for(env, mercury_token(site, env))
290
+
291
+
292
+ # The administrator's own credential, as one variable because that is how
293
+ # ERPNext hands it out: a single `<api_key>:<api_secret>` token.
294
+ RUNNINGTAB_ADMIN_ENV = "RUNNINGTAB_ADMIN_TOKEN"
295
+
296
+ # Mercury's write token, and the one credential here deliberately kept *out* of
297
+ # Secret Manager. Mercury issues no category-only write scope -- its custom-token
298
+ # picker offers "Fetch Categories" and nothing else for categories -- so the only
299
+ # token that can create a category is a full Read and Write one, whose
300
+ # server-side boundary is "may send money" rather than "may edit categories".
301
+ # It is minted for one sitting at a person's keyboard, exported by hand, and
302
+ # revoked in the dashboard afterwards. Storing it would put a money-moving
303
+ # credential exactly where every read-only one lives, and Mercury would downgrade
304
+ # it to read-only after 45 unused days anyway.
305
+ MERCURY_ADMIN_ENV = "MERCURY_ADMIN_TOKEN"
306
+
307
+
308
+ def mercury_admin_token(env: MercuryEnv) -> str:
309
+ """The token allowed to write a Mercury category, from the environment and nowhere else.
310
+
311
+ Sandbox reuses `MERCURY_SANDBOX_TOKEN`: it reads and writes seeded data on
312
+ a host that is not the bank, so it needs no ceremony. Production takes
313
+ `MERCURY_ADMIN_TOKEN`, which no code stores and nothing but an interactive
314
+ shell sets.
315
+
316
+ Raises:
317
+ MissingSiteCredentialError: The variable is unset, named in the message
318
+ along with what it is for, so the refusal lands before any request.
319
+ """
320
+ if env is MercuryEnv.SANDBOX:
321
+ sandbox = os.environ.get(MERCURY_SANDBOX_ENV, "").strip()
322
+ if not sandbox:
323
+ raise MissingSiteCredentialError(
324
+ f"{MERCURY_SANDBOX_ENV} is not set. Create an API token inside the Mercury sandbox dashboard "
325
+ f"(a production token does not work there) and export it."
326
+ )
327
+ return sandbox
328
+ value = os.environ.get(MERCURY_ADMIN_ENV, "").strip()
329
+ if not value:
330
+ raise MissingSiteCredentialError(
331
+ f"{MERCURY_ADMIN_ENV} is not set. Writing a Mercury category needs a Read and Write token, "
332
+ f"which Mercury will only issue with an IP allowlist and which can also send money -- so it is "
333
+ f"never stored. Mint one in the dashboard, export it into this shell for this run, and revoke it "
334
+ f"afterwards."
335
+ )
336
+ return value
337
+
338
+
339
+ def mercury_write_config(env: MercuryEnv) -> MercuryConfig:
340
+ """Where Mercury is and which token may write a category there. Never defaults to production."""
341
+ return config_for(env, mercury_admin_token(env))
342
+
343
+
344
+ def _admin_token() -> tuple[str, str]:
345
+ """`RUNNINGTAB_ADMIN_TOKEN`, split into the key and secret Frappe joined with a colon."""
346
+ value = os.environ.get(RUNNINGTAB_ADMIN_ENV, "").strip()
347
+ if not value:
348
+ raise MissingSiteCredentialError(
349
+ f"{RUNNINGTAB_ADMIN_ENV} is not set; export your own System Manager API token "
350
+ f"(the 'key:secret' ERPNext generates for your user) first"
351
+ )
352
+ return _split_token(value, RUNNINGTAB_ADMIN_ENV)
353
+
354
+
355
+ def admin_config(site: Site, target: Target) -> SiteConfig:
356
+ """A System Manager credential for provisioning the bot -- a human's token, read from the environment.
357
+
358
+ The stored books credential is the bot's own and cannot provision.
359
+ The test site's environment credential is already an administrator's.
360
+ """
361
+ if target is Target.TEST:
362
+ return site_config(site, target)
363
+ key, secret = _admin_token()
364
+ return SiteConfig(url=f"https://{site.host}", api_key=key, api_secret=secret, timeout=60.0)
365
+
366
+
367
+ def site_config(site: Site, target: Target, *, readonly: bool = False) -> SiteConfig:
368
+ """Credentials for one target. May not submit; `ingest_config` is the one caller that needs to.
369
+
370
+ `readonly` picks the reader's credential, which the server refuses every
371
+ write for. Only the books target has two: the test site is disposable, so
372
+ its one credential serves both.
373
+
374
+ The test target reads the environment first and the stored key only when
375
+ nothing is exported, so a developer pointed at their own bench never
376
+ silently reaches the shared test site instead. An exported
377
+ `RUNNINGTAB_TEST_URL` therefore commits to the environment: a missing half of
378
+ that credential is a mistake to name, not a reason to fall back.
379
+ """
380
+ if target is Target.BOOKS:
381
+ secret_id = site.books_readonly_secret if readonly else site.books_secret
382
+ url, key, secret = _fetch_books_credential(site, secret_id)
383
+ return SiteConfig(url=url, api_key=key, api_secret=secret, timeout=60.0)
384
+
385
+ url = os.environ.get("RUNNINGTAB_TEST_URL", "")
386
+ if url:
387
+ key, secret = _require("RUNNINGTAB_TEST_KEY"), _require("RUNNINGTAB_TEST_SECRET")
388
+ else:
389
+ url, (key, secret) = _test_site_url(site), _fetch_test_credential(site)
390
+ if not is_test_site(site, url):
391
+ raise SiteCredentialError(
392
+ f"RUNNINGTAB_TEST_URL ({url}) does not name a host in books.toml's test_hosts "
393
+ f"({', '.join(sorted(site.test_hosts))}); the test target refuses anything not allowlisted there"
394
+ )
395
+ return SiteConfig(url=url, api_key=key, api_secret=secret, timeout=60.0)
396
+
397
+
398
+ def ingest_config(site: Site, target: Target) -> SiteConfig:
399
+ """The bot's credential with submit capability, for the Mercury ingest alone.
400
+
401
+ The ingest submits the Bank Transactions it creates: a Bank Transaction
402
+ posts nothing to the general ledger until a person reconciles it, and the
403
+ Bank Reconciliation Tool only sees submitted rows. `icedcoffee-sync`'s client gates
404
+ every submit route behind one flag, so this is the narrowest grant it
405
+ offers; what keeps it to Bank Transactions is that the posting module sets
406
+ a `docstatus` on that one document and nothing else, and that the bot's
407
+ role on the server holds submit on that one doctype and nothing else.
408
+ """
409
+ return dataclasses.replace(site_config(site, target), may_submit=True)