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,726 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: runningtab
|
|
3
|
+
Version: 0.1.0.dev3
|
|
4
|
+
Summary: Keep a company's books in ERPNext: apply a chart of accounts, ingest the bank, close the quarter
|
|
5
|
+
Author-email: Gavin Carothers <gavin@carothers.name>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.14
|
|
9
|
+
Requires-Dist: google-cloud-secret-manager>=2.20.0
|
|
10
|
+
Requires-Dist: google-cloud-storage>=2.10.0
|
|
11
|
+
Requires-Dist: httpx2>=2.0.0
|
|
12
|
+
Requires-Dist: icedcoffee-sync==0.1.0.dev9
|
|
13
|
+
Requires-Dist: typer>=0.15.0
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# runningtab
|
|
17
|
+
|
|
18
|
+
Keeps a company's books in ERPNext: applies a versioned chart of accounts, runs
|
|
19
|
+
the Mercury ingest that posts bank activity into the ledger as Bank Transactions
|
|
20
|
+
for a person to reconcile, forecasts cash, and closes quarters.
|
|
21
|
+
|
|
22
|
+
[Source on GitHub](https://github.com/transpire-dev/runningtab)
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Requires Python 3.14. To include pre-releases:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install --pre runningtab
|
|
30
|
+
# or
|
|
31
|
+
uv add --prerelease allow runningtab
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## The books directory
|
|
35
|
+
|
|
36
|
+
runningtab ships no books. Every command reads one **books directory**, named by
|
|
37
|
+
`--books DIR` (before the command: `runningtab --books DIR coa plan ...`) or by
|
|
38
|
+
`RUNNINGTAB_BOOKS`. There is no default: with neither set, every command refuses
|
|
39
|
+
and names both, because a fallback would plan against books nobody meant.
|
|
40
|
+
`examples/books/` is a complete example, and the one this package's test suite
|
|
41
|
+
runs against. A books directory holds:
|
|
42
|
+
|
|
43
|
+
| File | What it is |
|
|
44
|
+
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `chart.coa` | the chart of accounts |
|
|
46
|
+
| `mercury.toml` | which chart account and ERPNext Bank Account each Mercury account is |
|
|
47
|
+
| `forecast.toml` | the expected cash movements `forecast` reads |
|
|
48
|
+
| `qbo.toml` | what QuickBooks Online needs to know about the chart, for `period export` |
|
|
49
|
+
| `books.toml` | the books' name, the ledger and test-site hosts, Secret Manager secret ids, record buckets, the bot and reader identities, the fence probe role, and the chart doc's path |
|
|
50
|
+
|
|
51
|
+
`books.toml` is read strictly: an unknown key is refused by its dotted name, and
|
|
52
|
+
so is a missing one. Its `site.test_hosts` may not name `site.host`. See
|
|
53
|
+
`examples/books/books.toml` for every key.
|
|
54
|
+
|
|
55
|
+
The chart is a versioned file (`chart.coa`), not something edited in the ERPNext
|
|
56
|
+
UI. It is an indented outline, not TOML: each account is one line — number,
|
|
57
|
+
name, and an optional `[tag]` block holding its root type, its ERPNext account
|
|
58
|
+
type, and the reserved word `no-bank-line` (this account is only ever reached by
|
|
59
|
+
a period-end or adjustment journal, so `mercury categories` offers no category
|
|
60
|
+
for it) — nested two spaces per level under its parent, with `>`-prefixed lines
|
|
61
|
+
carrying a note. The page `books.toml`'s `chart_doc` names is the annotated
|
|
62
|
+
version — what each account is for, and how to choose between neighbours — and
|
|
63
|
+
its account tables are **generated** from `chart.coa` by `chart render-doc`, not
|
|
64
|
+
maintained by hand; only its prose sections are edited directly.
|
|
65
|
+
|
|
66
|
+
- `chart.py` — loads and validates the chart: unique numbers, every parent
|
|
67
|
+
present, no cycles, a root type inherited from each of the five roots, and
|
|
68
|
+
account types ERPNext actually accepts. Raises `ChartError` describing exactly
|
|
69
|
+
what is wrong rather than failing partway through an apply.
|
|
70
|
+
- `load_defaults` — the Company default-account fields (default bank account,
|
|
71
|
+
default receivable account, and so on), as account numbers, from the chart's
|
|
72
|
+
`[defaults]` block.
|
|
73
|
+
- `books.py` — finds and loads the books directory, and refuses a `books.toml`
|
|
74
|
+
it cannot trust.
|
|
75
|
+
- `doc.py` — renders the chart doc's account tables from the chart, rewriting
|
|
76
|
+
only the content between `<!-- chart:table N -->` / `<!-- chart:defaults -->`
|
|
77
|
+
and `<!-- chart:end -->` marker pairs.
|
|
78
|
+
- `coa.py`, `access.py` — the chart orchestration and the bot policy behind the
|
|
79
|
+
commands below; `cli.py` is the typer wiring that prints and prompts.
|
|
80
|
+
- `mercury/` — the bank. `client.py` reads Mercury (GET only), `categories.py`
|
|
81
|
+
mirrors the chart into Mercury's custom categories and holds the only three
|
|
82
|
+
writes this package can make, `models.py` parses its payloads into `Decimal`
|
|
83
|
+
money, `classify.py` decides with no I/O at all whether a transaction is
|
|
84
|
+
settled enough to post, `posting.py` builds and submits the Bank Transactions,
|
|
85
|
+
`vouchers.py` drafts the Journal Entry a coded row will be matched to,
|
|
86
|
+
`receipts.py` puts the receipt a person uploaded at the bank onto that row,
|
|
87
|
+
`state.py` holds the cursor and the dedup set, and `ingest.py` is the applier
|
|
88
|
+
a webhook will later feed. `mercury.toml` names the chart account and the
|
|
89
|
+
ERPNext Bank Account behind each Mercury account. There is one client:
|
|
90
|
+
`runningtab health` reads the bank through the same `client.py` and the same
|
|
91
|
+
`ingest.reconcile` the ingest does, so the two cannot drift apart.
|
|
92
|
+
- `health.py` — the five checks behind `runningtab health`, and the rule that
|
|
93
|
+
turns them into an exit code.
|
|
94
|
+
|
|
95
|
+
For `chart.coa` syntax highlighting in Zed, see `zed-coa/README.md`.
|
|
96
|
+
|
|
97
|
+
Depends on `icedcoffee-sync` for the account model (`DesiredAccount`), the
|
|
98
|
+
diff/apply/ prune engine, and ERPNext facts (`ROOT_TYPES`, `ACCOUNT_TYPES`);
|
|
99
|
+
`icedcoffee-sync` does not depend back.
|
|
100
|
+
|
|
101
|
+
## The `coa` commands
|
|
102
|
+
|
|
103
|
+
- `coa plan --target <target> [--company NAME]` — diff the chart against the
|
|
104
|
+
site and print what would change. Writes nothing; exits non-zero if the plan
|
|
105
|
+
carries a refusal, so it also works as a CI-style gate.
|
|
106
|
+
- `coa apply --target <target> [--company NAME] [--yes]` — apply the plan
|
|
107
|
+
(create, renumber, retype, re-enable), then point the Company record's
|
|
108
|
+
default-account fields at the chart. Prompts unless `--yes`; re-plans after
|
|
109
|
+
the prompt and refuses if anything changed meanwhile; checks the default
|
|
110
|
+
fields exist on Company before touching any account. Run it before
|
|
111
|
+
`coa prune`.
|
|
112
|
+
- `coa prune --target <target> [--company NAME] [--yes] [--allow-unapplied]` —
|
|
113
|
+
**the only destructive command.** Deletes (or, where ERPNext refuses,
|
|
114
|
+
disables) accounts the chart does not describe. Refuses until the chart is
|
|
115
|
+
applied, since pruning first would strand the Company defaults; refuses
|
|
116
|
+
outright on a `ROOT_NOT_UNIQUE` or `AMBIGUOUS_MATCH` refusal, the two that
|
|
117
|
+
make the extras set untrustworthy. `--allow-unapplied` is the teardown path
|
|
118
|
+
for a pre-existing chart whose numbers collide with ours: it clears the
|
|
119
|
+
Company defaults pointing at accounts it deletes and parks any survivor still
|
|
120
|
+
holding a chart number onto a 9-prefixed one (ERPNext keeps some sample
|
|
121
|
+
accounts alive by name until apply repoints the defaults), so `coa apply` can
|
|
122
|
+
run and a closing `coa prune` retires them.
|
|
123
|
+
- `coa export-gl-codes [--output PATH]` — emit the single-column CSV of
|
|
124
|
+
non-group account names the bank accepts for its GL-code picker. Writes to
|
|
125
|
+
stdout by default; the upload into the bank's dashboard is manual. Every label
|
|
126
|
+
is **comma-free** — Mercury's importer splits a line at the first comma
|
|
127
|
+
whatever the quoting says, so a thousands separator is dropped and any other
|
|
128
|
+
comma becomes a spaced slash. The ERPNext account name is untouched, so a GL
|
|
129
|
+
code in the bank can read differently from the account it stands for.
|
|
130
|
+
|
|
131
|
+
## The `chart` commands
|
|
132
|
+
|
|
133
|
+
- `chart render-doc [--check]` — regenerate the chart doc's (`books.toml`'s
|
|
134
|
+
`chart_doc`) account tables from `chart.coa`. Without `--check`, writes the
|
|
135
|
+
file and reports whether it changed. With `--check`, writes nothing and exits
|
|
136
|
+
1 with a unified diff if the doc is stale.
|
|
137
|
+
|
|
138
|
+
## `health`
|
|
139
|
+
|
|
140
|
+
`health --target <target> [--company NAME] [--strict] [--mercury-mapping PATH]`
|
|
141
|
+
— whether the books are actually current. Reads only. The failures it looks for
|
|
142
|
+
are silent ones: a webhook the bank disabled, a cursor that stopped advancing,
|
|
143
|
+
entries piling up in suspense. `--mercury-mapping` is
|
|
144
|
+
`period export`/`period status`'s own option, needed for the same reason there:
|
|
145
|
+
the `closed periods` section rebuilds each stored quarter, and `--target test`
|
|
146
|
+
reads the Mercury sandbox, whose account ids are not in the books directory's
|
|
147
|
+
`mercury.toml`.
|
|
148
|
+
|
|
149
|
+
Six named sections, always printed, in this order:
|
|
150
|
+
|
|
151
|
+
| Section | State |
|
|
152
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
153
|
+
| `balance invariant` | **runs.** Per account, the transactions that are neither pending nor failed must sum to the bank's `currentBalance` |
|
|
154
|
+
| `uncoded spend, aged` | not available: settled rows with no GL code at Mercury; `runningtab mercury sync` reports them per run as `uncoded`, and this section has nothing persisted to read them from between runs |
|
|
155
|
+
| `suspense contents` | **runs.** What sits in `5299 UNCATEGORISED (needs review)`, and how long it has been there |
|
|
156
|
+
| `webhook subscription health` | not available: needs a registered subscription to report the status of |
|
|
157
|
+
| `event cursor age` | not available: needs the ingest's persisted last-processed event id |
|
|
158
|
+
| `closed periods` | **runs.** `runningtab period status`'s own report, folded in: a reopened quarter, a quarter locked with no stored package, an unfinished re-close, `role_allowed_for_frozen_entries` left set, or a closed quarter's rebuilt files no longer matching its stored package |
|
|
159
|
+
|
|
160
|
+
The balance invariant reads **per account**, not through the bank's org-wide
|
|
161
|
+
transaction endpoint: only the per-account response carries a `total`, and
|
|
162
|
+
without one a short read is undetectable. Pages are joined on transaction id, so
|
|
163
|
+
a server that ignores `offset` produces a reported short read rather than a sum
|
|
164
|
+
counting the same charge twice. It reads the credit endpoint too, because the
|
|
165
|
+
account list omits credit accounts entirely.
|
|
166
|
+
|
|
167
|
+
Which statuses `currentBalance` counts was **measured**, not read off the enum:
|
|
168
|
+
`sent` counts, `pending` and `failed` do not. `cancelled`, `reversed`, and
|
|
169
|
+
`blocked` exist and have never been observed. The sum treats them as counting,
|
|
170
|
+
which is what "the balance is the bank's number" implies and what the ingest
|
|
171
|
+
already does, and this check **fails by name** the moment one appears — deciding
|
|
172
|
+
silently either way is how a real discrepancy gets explained away.
|
|
173
|
+
|
|
174
|
+
Suspense ages from the **last time the account was empty**, not from its oldest
|
|
175
|
+
entry, so a reclassified account is not reported as ancient, and it lists only
|
|
176
|
+
the entries behind the current balance. It reads the ledger, which holds
|
|
177
|
+
submitted documents only. Nothing machine-made lands in suspense: the ingest
|
|
178
|
+
posts Bank Transactions, which reach the general ledger only when a person
|
|
179
|
+
reconciles them, so an entry against `5299` is a voucher a person reconciled a
|
|
180
|
+
statement line to because they could not yet say where it belongs — and a
|
|
181
|
+
person's voucher is submitted, so this section sees it.
|
|
182
|
+
|
|
183
|
+
`closed periods` is `runningtab period status`'s report (see below), reached as
|
|
184
|
+
the reader — the same credential `period export` uses, its effective grants
|
|
185
|
+
proven the same way before anything is compared. It rebuilds every closed
|
|
186
|
+
quarter through that exact same path and diffs it byte for byte against the
|
|
187
|
+
stored package, so a section that passes here means every handed-off quarter
|
|
188
|
+
still reconciles with the ledger as it stands today, not merely as it stood when
|
|
189
|
+
it was closed.
|
|
190
|
+
|
|
191
|
+
Exit codes:
|
|
192
|
+
|
|
193
|
+
| Outcome | Default | `--strict` |
|
|
194
|
+
| ------------------------------------------------------------------------------------------------------------------------ | ------- | ---------- |
|
|
195
|
+
| every section passes | `0` | `0` |
|
|
196
|
+
| a section is out of bounds (`FAIL`) | `1` | `1` |
|
|
197
|
+
| a section could not run (`ERROR`) — credential unset or refused, site unreachable, no account, a response it cannot read | `1` | `1` |
|
|
198
|
+
| a section is not built yet (`N/A`) | `0` | `1` |
|
|
199
|
+
|
|
200
|
+
`N/A` is only ever the three sections above that need the ingest. A section that
|
|
201
|
+
exists and did not run is an `ERROR`, an unset credential included: the report
|
|
202
|
+
cannot say whether the books are current without it, and every such message
|
|
203
|
+
names what to set or create.
|
|
204
|
+
|
|
205
|
+
The bank credential pairs with the target. `--target test` reads the bank's
|
|
206
|
+
sandbox, with the token in `MERCURY_SANDBOX_TOKEN` — created inside the sandbox
|
|
207
|
+
dashboard, since a production token does not work there. `--target books` reads
|
|
208
|
+
the real organisation through the read-only token in the Secret Manager secret
|
|
209
|
+
`books.toml` names as `secrets.mercury`.
|
|
210
|
+
|
|
211
|
+
## The `period` commands
|
|
212
|
+
|
|
213
|
+
`period export --target <target> --quarter <YYYYQn> [--company NAME] [--output DIR] [--mercury-mapping PATH]`
|
|
214
|
+
builds one quarter's books into a package for an accountant or auditor who has
|
|
215
|
+
never worked with you. Reads only, against both outside systems — the ledger (GL
|
|
216
|
+
Entries, Fiscal Years, and every voucher and Bank Transaction dated in the
|
|
217
|
+
quarter, plus the receipts and invoices attached to them) and the bank (each
|
|
218
|
+
Mercury account's balance and transactions) — and writes one new local
|
|
219
|
+
directory, `<quarter>-DRAFT/` by default (`--output` names another). It never
|
|
220
|
+
overwrites: an existing directory at that path is refused rather than mixed with
|
|
221
|
+
or replaced, since a person may already be working in it.
|
|
222
|
+
|
|
223
|
+
`--mercury-mapping PATH` loads a `mercury.toml`-shaped mapping from `PATH`
|
|
224
|
+
instead of the books directory's `mercury.toml`, which names only the real
|
|
225
|
+
Mercury accounts; `--target test` reads the Mercury sandbox, whose account ids
|
|
226
|
+
are not in that file, so exporting against it needs a mapping of its own.
|
|
227
|
+
|
|
228
|
+
The directory holds `MANIFEST.md` (the package explained in plain sentences, for
|
|
229
|
+
the reader), `SHA256SUMS`, the trial balance and general ledger, the profit and
|
|
230
|
+
loss statement and balance sheet, open receivables and payables, the bank
|
|
231
|
+
reconciliation, a QuickBooks Online import kit under `qbo/`, and the attachments
|
|
232
|
+
under `documents/`.
|
|
233
|
+
|
|
234
|
+
Exit codes:
|
|
235
|
+
|
|
236
|
+
| Outcome | Exit code |
|
|
237
|
+
| --------------------------------------------------------------------- | --------- |
|
|
238
|
+
| every Mercury account reconciles with the ledger at the quarter's end | `0` |
|
|
239
|
+
| the bank and the ledger disagree at the quarter's end | `1` |
|
|
240
|
+
| the package could not be built at all (nothing is written) | `1` |
|
|
241
|
+
| `--quarter` is not `YYYYQn` | `2` |
|
|
242
|
+
|
|
243
|
+
A bank difference still writes the draft — it is what a person investigates with
|
|
244
|
+
— but a package that cannot be built (an account `chart.coa` does not describe,
|
|
245
|
+
a credential problem, an unreachable site) writes nothing at all, the same
|
|
246
|
+
"refuse rather than hand over something wrong" rule `coa apply` follows.
|
|
247
|
+
|
|
248
|
+
`period close --target <target> --quarter <YYYYQn> [--company NAME] [--mercury-mapping PATH]`
|
|
249
|
+
runs the close ceremony and locks the quarter: read Books Closed Through and the
|
|
250
|
+
ledger, cross-check the draft against ERPNext's own Trial Balance and AR/AP
|
|
251
|
+
reports account by account and party by party, move Books Closed Through to the
|
|
252
|
+
quarter's last day, rebuild the package and require it byte-identical to the
|
|
253
|
+
pre-lock build, then upload it to the record bucket and read every object back
|
|
254
|
+
to verify it. Runs as an administrator: `--target books` needs
|
|
255
|
+
`RUNNINGTAB_ADMIN_TOKEN` (your own System Manager `key:secret`), refused by name
|
|
256
|
+
before any request when it is unset — no stored credential can close a quarter;
|
|
257
|
+
`--target test`'s environment credential is already an administrator's. A
|
|
258
|
+
failure before the lock leaves nothing changed. A failure after it leaves the
|
|
259
|
+
lock in place (there is no rollback), prints Books Closed Through, and names the
|
|
260
|
+
next step. On a quarter's first close, running `period close` again is the
|
|
261
|
+
recovery: it finds the lock already at the quarter's end, re-runs readiness and
|
|
262
|
+
the cross-check against the now-locked ledger, and stores the package. Check
|
|
263
|
+
`period status` first: an error can arrive after the package landed, and a
|
|
264
|
+
re-run would then only be refused as already stored. On a re-close of a quarter
|
|
265
|
+
that already has a stored package, a re-run is refused ("already has a stored
|
|
266
|
+
package"), so the recovery is to check `period status` (an error can arrive
|
|
267
|
+
after the new revision landed), then reopen the quarter, close it again, and
|
|
268
|
+
re-close every stored quarter after it in order. When the ledger moved between
|
|
269
|
+
the check and the lock, the refusal names every differing file and the vouchers
|
|
270
|
+
whose general-ledger rows differ. If what posted belongs in the quarter, run
|
|
271
|
+
`close` again (reopening first on a re-close); if it does not, reopen the
|
|
272
|
+
quarter and reverse it first. The finished package lands in
|
|
273
|
+
`<company-abbr>/<quarter>/` (`revN/` beside the original for a reopened
|
|
274
|
+
quarter's re-close; nothing is ever deleted or overwritten, because the bucket
|
|
275
|
+
is under a retention policy) in the bucket `books.toml`'s `record_buckets.books`
|
|
276
|
+
names (`record_buckets.test` for `--target test`).
|
|
277
|
+
|
|
278
|
+
`period close`, `period status`, and `health` (whose `closed periods` section
|
|
279
|
+
reads the record bucket) need Application Default Credentials
|
|
280
|
+
(`gcloud auth application-default login`) for an identity in the engineering
|
|
281
|
+
group, which holds the buckets' object-create and object-viewer grants.
|
|
282
|
+
|
|
283
|
+
`period status --target <target> [--company NAME] [--mercury-mapping PATH]`
|
|
284
|
+
reads only, as the reader — the same credential and effective-grant proof
|
|
285
|
+
`period export` uses. Prints Books Closed Through, then one line per closed
|
|
286
|
+
quarter (`ok`, or the changed files when its rebuild disagrees with the stored
|
|
287
|
+
`SHA256SUMS`), then every problem found; exits 1 unless everything comes back
|
|
288
|
+
clean. Every closed quarter's latest revision is rebuilt through the exact same
|
|
289
|
+
read-and-build path `period export` uses and compared byte for byte against what
|
|
290
|
+
is stored — package bytes only, nothing is written anywhere. Also detects a
|
|
291
|
+
reopened quarter (Books Closed Through moved backward, naming who and when from
|
|
292
|
+
the Company `Version` history), a quarter locked with no stored package (Books
|
|
293
|
+
Closed Through past every stored quarter, which a close that locked and then
|
|
294
|
+
failed to store leaves behind), an unfinished re-close (Books Closed Through set
|
|
295
|
+
to a stored quarter's end by a newer Company `Version` than the one its stored
|
|
296
|
+
`MANIFEST.md` records, so every file can still match the old revision), and
|
|
297
|
+
`role_allowed_for_frozen_entries` left set. Reopening is a person, in the
|
|
298
|
+
ERPNext UI, and nothing else — there is no `period reopen` command. To reopen a
|
|
299
|
+
quarter, move Books Closed Through back to the previous quarter's end. Any
|
|
300
|
+
stored quarter can be reopened, not only the latest: re-close it, then every
|
|
301
|
+
stored quarter after it, in order (reopening 2026Q2 while 2026Q3 is stored means
|
|
302
|
+
closing 2026Q2, then 2026Q3, each as a new revision). Until then `status` (and
|
|
303
|
+
`health`'s `closed periods` section, above) fail and name that order. The two
|
|
304
|
+
share one report: `health --target <target>`'s `closed periods` section, above,
|
|
305
|
+
is this same check run the same way.
|
|
306
|
+
|
|
307
|
+
Bank activity dated in a closed quarter that reaches Mercury after the close
|
|
308
|
+
changes that quarter's rebuilt `bank-reconciliation.csv`. `mercury sync` names
|
|
309
|
+
each such row and drafts no voucher for it. Reopen that quarter, book the row
|
|
310
|
+
there (the next sync drafts its voucher once the quarter is open), and close it
|
|
311
|
+
again, then re-close any stored quarter after it. Booking it in the open quarter
|
|
312
|
+
instead would leave the closed quarter's package differing from its rebuild for
|
|
313
|
+
good.
|
|
314
|
+
|
|
315
|
+
`period export` runs as the reader, the same credential `coa plan` and `health`
|
|
316
|
+
use. Its role needs more than they do — the ledger's parties, the fiscal years,
|
|
317
|
+
and every voucher doctype whose attachments a private-file download is checked
|
|
318
|
+
against — so after this lands, re-provision the reader with
|
|
319
|
+
`runningtab access provision --target books --profile reader` (and
|
|
320
|
+
`--target test`) with `RUNNINGTAB_ADMIN_TOKEN` set, or its stored credential
|
|
321
|
+
will still be missing the new grants.
|
|
322
|
+
|
|
323
|
+
## The `mercury` commands
|
|
324
|
+
|
|
325
|
+
Read the bank and post every settled transaction into ERPNext as a **Bank
|
|
326
|
+
Transaction** on the Bank Account of the Mercury account it moved through,
|
|
327
|
+
submitted, for a person to reconcile in the Banking workspace (Bank Statement
|
|
328
|
+
Import and the Bank Reconciliation Tool). The ingest is read-only against
|
|
329
|
+
Mercury — `runningtab.mercury.client` has no verb but GET. The one command here
|
|
330
|
+
that writes to Mercury is `mercury categories apply`, which touches categories
|
|
331
|
+
and nothing else; see below. Each row is written to be read on the workspace's
|
|
332
|
+
card, which shows the date, `reference_number`, `description` and the amount and
|
|
333
|
+
nothing else. The reference is the ACH trace number Mercury reports as
|
|
334
|
+
`trackingNumber`, so an ingested row and one imported by hand carry the same
|
|
335
|
+
"Ref"; a transaction with no trace number — a card charge never went through the
|
|
336
|
+
ACH network — carries the Mercury transaction id instead. The description is the
|
|
337
|
+
person's Mercury note, then the GL code, then the bank's own text, joined by a
|
|
338
|
+
spaced middle dot from whichever of the three exist, so a coded row reads
|
|
339
|
+
without being opened and an uncoded one reads exactly as the bank wrote it. Both
|
|
340
|
+
are fixed when the row is posted. Mercury's GL code and note also ride along in
|
|
341
|
+
custom fields of their own, which stay the live copy — a later edit at Mercury
|
|
342
|
+
lands there by sweep — and they route nothing. An internal transfer is two
|
|
343
|
+
statement lines on two accounts and becomes two Bank Transactions, which the
|
|
344
|
+
tool matches to one transfer voucher. A row with a category nobody mapped, or no
|
|
345
|
+
category at all, is just a Bank Transaction with whatever hint Mercury has. The
|
|
346
|
+
event log is the authority for every change.
|
|
347
|
+
|
|
348
|
+
- `mercury install-fields --target <target> [--yes]` — create the four custom
|
|
349
|
+
fields on Bank Transaction: `mercury_transaction_id`,
|
|
350
|
+
`mercury_resource_version`, `mercury_gl_code` and `mercury_note`. Run once,
|
|
351
|
+
before the first ingest, as an administrator (`RUNNINGTAB_ADMIN_TOKEN`, see
|
|
352
|
+
Access): the books bot has no Custom Field permission. The transaction id is
|
|
353
|
+
**unique**, so the server itself refuses a second document for the same
|
|
354
|
+
transaction. The GL code and note are what a person coded in Mercury — the
|
|
355
|
+
account they expect to pick at reconciliation, carried verbatim as
|
|
356
|
+
`<number> - <name>`, and their free-text note. The ingest fills both; a
|
|
357
|
+
statement export imported by hand through Bank Statement Import maps its
|
|
358
|
+
`GL Code` and `Note` columns onto them, which is why they are writable rather
|
|
359
|
+
than read-only. Nothing acts on either; they are hints for the person
|
|
360
|
+
reconciling, and the row's `description` carries both so the hint reaches the
|
|
361
|
+
card as well as the field. All four are `allow_on_submit`, so the ingest can
|
|
362
|
+
stamp them onto a row a person already submitted (see adoption below). It also
|
|
363
|
+
creates `mercury_transaction_id` on **Journal Entry**, which is how the draft
|
|
364
|
+
voucher beside a coded row is found again (see below); that one is
|
|
365
|
+
deliberately **not** `allow_on_submit`, because nothing ever writes to a
|
|
366
|
+
voucher a person has submitted, and it carries no `mercury_resource_version`
|
|
367
|
+
because a GL code edit bumps none. It is also **not unique**: ERPNext's Amend
|
|
368
|
+
copies it onto the amendment while the cancelled original still holds it, so a
|
|
369
|
+
unique index would refuse every correction to our vouchers. The ingest refuses
|
|
370
|
+
a second _live_ voucher for one transaction instead, and treats a cancelled
|
|
371
|
+
one as history. Re-running compares and repairs an existing field whose flags
|
|
372
|
+
differ, so fields installed before a flag was added are brought up to date
|
|
373
|
+
rather than assumed correct.
|
|
374
|
+
- `mercury install-bank-accounts --target <target> [--yes]` — create the Bank
|
|
375
|
+
"Mercury" and one Bank Account per Mercury account named in `mercury.toml`'s
|
|
376
|
+
`[bank_accounts]`, each linked to the chart account `[accounts]` gives it.
|
|
377
|
+
Also administrator-only, for the same reason. Re-running compares and repairs
|
|
378
|
+
the links rather than duplicating; an entry that does not follow ERPNext's
|
|
379
|
+
`<account name> - Mercury` naming is refused before anything is created.
|
|
380
|
+
- `mercury snapshot --env <env> --target <target> [--state PATH] [--dry-run]` —
|
|
381
|
+
establish initial state from the transaction lists and record where in the
|
|
382
|
+
event log that state was true. Also the recovery path: events are retained 90
|
|
383
|
+
days, so a gap wider than that is closed by re-snapshotting.
|
|
384
|
+
- `mercury sync --env <env> --target <target> [--state PATH] [--dry-run]` —
|
|
385
|
+
apply every event after the cursor, then refresh Mercury's GL code and note on
|
|
386
|
+
every settled row the run read, because those two are edited with no event
|
|
387
|
+
behind them (see below). Deduplicates on the event id and applies only when
|
|
388
|
+
`resourceVersion` advances, so a redelivery does nothing and an out-of-order
|
|
389
|
+
delivery cannot rewind. Refuses a cursor older than the retention window
|
|
390
|
+
instead of resuming into a hole. **The webhook receiver is not built yet**;
|
|
391
|
+
when it is, its consumer calls the same
|
|
392
|
+
`runningtab.mercury.ingest.apply_event` with the same state file, and the two
|
|
393
|
+
paths need no coordination.
|
|
394
|
+
- `mercury status --env <env> --target <target> [--state PATH]` — the cursor,
|
|
395
|
+
its age, and how many events have been applied. Exits 1 when the cursor is
|
|
396
|
+
past retention.
|
|
397
|
+
- `mercury categories plan --env <env> [--prune]` and
|
|
398
|
+
`mercury categories apply --env <env> [--yes] [--prune]` — mirror the chart's
|
|
399
|
+
codeable accounts as Mercury's **custom categories**, named with exactly the
|
|
400
|
+
GL-code label `coa export-gl-codes` emits, so one dashboard rule per category
|
|
401
|
+
maps a category to the GL code of the same name and rows arrive coded.
|
|
402
|
+
Postable `5xxx` accounts get all three visibility flags; postable `31xx`
|
|
403
|
+
(member capital) and `4xxx` (revenue) are money coming in and get
|
|
404
|
+
`visibleForOther` only. An account the chart tags `no-bank-line` gets no
|
|
405
|
+
category at all — 5203 depreciation, 5217 round-off, 5218 FX, 5220 write-offs
|
|
406
|
+
and 5221 disposal gain are fed by period-end and adjustment journals, never by
|
|
407
|
+
a statement line, so offering them at a card reader offers an answer that
|
|
408
|
+
cannot be right; they keep their GL codes, because ERPNext reconciliation does
|
|
409
|
+
reach them. Reconciled **by name**: a missing name is a create, a wrong flag
|
|
410
|
+
is an edit, a category the chart does not name is reported as `unmanaged` and
|
|
411
|
+
never renamed, and two categories sharing one desired name are refused with
|
|
412
|
+
both UUIDs named rather than guessed between. Renaming a chart account
|
|
413
|
+
therefore creates a new category and leaves the old one unmanaged; no rename
|
|
414
|
+
is inferred. `plan` writes nothing, uses the read-only token, and exits 1 on
|
|
415
|
+
any drift, so it works as a standing check; its output is also a checklist a
|
|
416
|
+
person can work through by hand.
|
|
417
|
+
- **`--prune` shortens the dropdown, and never at the cost of somebody's
|
|
418
|
+
coding.** It deletes an unmanaged category — Mercury's 22 shipped defaults
|
|
419
|
+
among them — only when **no transaction on any account carries it**, measured
|
|
420
|
+
by category UUID against the same exhaustive, short-read-refusing read the
|
|
421
|
+
ingest uses, counting unsettled rows too. One that is carried is reported as
|
|
422
|
+
`in use <n> transaction(s) <name>` and left standing; re-code those rows in
|
|
423
|
+
Mercury (or leave them) and run again. The exit code stays 1 while any remain,
|
|
424
|
+
because the dropdown still holds them. Deletes go last, after the creates and
|
|
425
|
+
edits, one request each; a delete Mercury refuses is one report line and the
|
|
426
|
+
rest proceed. Without `--prune` nothing is ever deleted. `plan --prune` shows
|
|
427
|
+
the same list and writes nothing.
|
|
428
|
+
|
|
429
|
+
**`apply` is a ceremony, not an automation.** Mercury's custom-token scope
|
|
430
|
+
picker offers no category **write** scope — only "Fetch Categories" — so the
|
|
431
|
+
three write endpoints are reachable solely from a full **Read and Write** token,
|
|
432
|
+
whose server-side boundary is "may send money" rather than "may edit
|
|
433
|
+
categories". That token is therefore never stored: not in Secret Manager, not in
|
|
434
|
+
a file. `apply` reads it from `MERCURY_ADMIN_TOKEN` (or, for the sandbox,
|
|
435
|
+
`MERCURY_SANDBOX_TOKEN`) and from nowhere else, and refuses by name before any
|
|
436
|
+
request when it is unset. Mercury also requires an **IP allowlist** on any token
|
|
437
|
+
holding a write scope, downgrades an unused write permission after 45 days, and
|
|
438
|
+
deletes an unused token after 45 — all of which suit a token minted for one
|
|
439
|
+
sitting and revoked afterwards, and none of which suit a stored one. The
|
|
440
|
+
sequence: mint the token and allowlist your IP in the dashboard,
|
|
441
|
+
`read -s -x MERCURY_ADMIN_TOKEN`, run `plan`, run `apply`,
|
|
442
|
+
`set -e MERCURY_ADMIN_TOKEN`, revoke the token. `--prune` runs under the same
|
|
443
|
+
ceremony and relaxes nothing about it. The three-method write surface
|
|
444
|
+
(`MercuryCategoryWriter.create_category`, `.edit_category` and
|
|
445
|
+
`.delete_category`, the only three writes in this package) is a courtesy that
|
|
446
|
+
keeps a stray call from compiling; what actually bounds that token is its
|
|
447
|
+
lifetime. `MercuryClient` — what the ingest and `runningtab health` hold — still
|
|
448
|
+
has no verb but GET, `delete_category` least of all: it reads the very rows a
|
|
449
|
+
deletion would strip the coding off.
|
|
450
|
+
|
|
451
|
+
**Adopting hand-imported rows.** A statement CSV imported through Bank Statement
|
|
452
|
+
Import with `Tracking ID` mapped to `reference_number` produces Bank
|
|
453
|
+
Transactions that carry none of the Mercury fields, and the ingest would post a
|
|
454
|
+
second row for each. Mercury's API reports the same ACH trace number as
|
|
455
|
+
`trackingNumber` — the number the ingest writes as its own rows'
|
|
456
|
+
`reference_number` — so when a transaction routes to a Bank Transaction, has no
|
|
457
|
+
document of its own, and carries a trace number, the ingest looks for rows on
|
|
458
|
+
the same Bank Account whose `reference_number` equals it. Exactly one match is
|
|
459
|
+
**adopted**: the four Mercury fields are written onto it and nothing else — the
|
|
460
|
+
person's amount, date, description and reference stay as imported, submitted or
|
|
461
|
+
not — and later events find it by id like any other row. More than one match (a
|
|
462
|
+
statement imported twice, a number typed onto the wrong row) goes to `ATTENTION`
|
|
463
|
+
naming the rows rather than guessing; a person removes the duplicate and the
|
|
464
|
+
next run adopts the survivor. No match posts a new row. Adoptions are reported
|
|
465
|
+
as `adopted`, apart from `submitted` and `updated`, and a dry run plans them the
|
|
466
|
+
same way. Two limits: a row with no trace number — a card charge never went
|
|
467
|
+
through the ACH network — cannot be matched and is posted, so a hand-imported
|
|
468
|
+
card charge is still a duplicate for a person to remove; and a mistyped
|
|
469
|
+
reference is simply no match, so the ingest posts a second row and the balance
|
|
470
|
+
check reports the account off by that amount.
|
|
471
|
+
|
|
472
|
+
**A row's GL code.** Mercury's `generalLedgerCodeName` when a person set one,
|
|
473
|
+
else the row's custom category. The custom categories are the chart's GL-code
|
|
474
|
+
labels word for word (`mercury categories apply --prune` leaves Mercury offering
|
|
475
|
+
nothing else), and coding a row at the card reader picks a category while
|
|
476
|
+
leaving `generalLedgerCodeName` empty — so the category is the code. Mercury's
|
|
477
|
+
automatic category is not: nobody chose it, and it names Mercury's taxonomy
|
|
478
|
+
rather than the chart. This one value is what the description, the
|
|
479
|
+
`mercury_gl_code` hint, the draft voucher and `uncoded` all read.
|
|
480
|
+
|
|
481
|
+
**A draft voucher beside a coded row.** Every settled transaction is already a
|
|
482
|
+
submitted Bank Transaction, so reconciling one means opening the create-voucher
|
|
483
|
+
dialog and typing what Mercury already knows. Where a person has coded a row —
|
|
484
|
+
its GL code parses as `<number> - <name>` and the number is a postable expense
|
|
485
|
+
(`5xxx`) or member-capital (`31xx`) account in `chart.coa` — the ingest also
|
|
486
|
+
drafts the Journal Entry that dialog would have produced: a `Bank Entry` whose
|
|
487
|
+
`cheque_no` is the row's reference, with two lines, the bank account the money
|
|
488
|
+
moved through and the account the code names. Its list title is the payee and
|
|
489
|
+
the code, and its description (note, code, payee, Mercury id) is in `remark`
|
|
490
|
+
with `custom_remark` set: ERPNext's `user_remark` on Journal Entry is hidden
|
|
491
|
+
from every list, report and form, and without `custom_remark` ERPNext rebuilds
|
|
492
|
+
`remark` from the reference on save. **A proposal and nothing else.** It sets no
|
|
493
|
+
`docstatus` in any request, so a voucher is a draft the Bank Reconciliation Tool
|
|
494
|
+
does not even offer as a match; a person reviews it, submits it — in bulk if
|
|
495
|
+
they like — and matches it themselves. Revenue codes are skipped (a customer
|
|
496
|
+
payment clears a Sales Invoice through a Payment Entry), an internal transfer
|
|
497
|
+
gets no coded voucher (it has its own, below), and a code the chart cannot place
|
|
498
|
+
is reported once per run, naming every such code, because the cause is one
|
|
499
|
+
thing: the list uploaded to Mercury has drifted. Drafts are reported as
|
|
500
|
+
`drafted`, apart from `submitted`. A draft somebody else has since edited is
|
|
501
|
+
left alone **and not reported** — a person taking the proposal over is what the
|
|
502
|
+
proposal is for. A draft whose Bank Transaction has since been reconciled to
|
|
503
|
+
something else is listed under `delete`, with a link to its page on the site,
|
|
504
|
+
and never deleted by the ingest: deleting would occasionally destroy a draft
|
|
505
|
+
somebody had edited. The voucher arrives by the same sweep the GL-code hint
|
|
506
|
+
does, for the same reason — coding a transaction at Mercury emits no event.
|
|
507
|
+
|
|
508
|
+
**A transfer between our own accounts** — checking paying the card, or moving
|
|
509
|
+
money to savings — is two statement lines and one movement, so the ingest drafts
|
|
510
|
+
one voucher for both: debit the account the money arrived on, credit the one it
|
|
511
|
+
left, identified by the outgoing half's Mercury id and titled
|
|
512
|
+
`Transfer to <account> · <amount>`. Both Bank Transactions are matched to it.
|
|
513
|
+
Two halves pair only when each is the other's one match: the same amount with
|
|
514
|
+
opposite signs, each naming the other's account as counterparty, dated within
|
|
515
|
+
five days. A half with no match, or with more than one, is reported and never
|
|
516
|
+
guessed at.
|
|
517
|
+
|
|
518
|
+
**A row with no code at Mercury.** If a voucher already carries its Mercury id,
|
|
519
|
+
or its Bank Transaction is reconciled, a person coded it in the ledger: it is
|
|
520
|
+
left alone and no longer reported as `uncoded`. Otherwise the ingest looks at
|
|
521
|
+
the payee's history — every submitted voucher carrying the Mercury id of a row
|
|
522
|
+
with the same `counterpartyId` — and when all of them went to one account,
|
|
523
|
+
drafts the same voucher, its remark naming the account and the voucher it copied
|
|
524
|
+
(`4210 - Interest Income as ACC-JV-2026-00025`). Drafts and cancelled vouchers
|
|
525
|
+
are not history, and a payee booked to more than one account gets nothing.
|
|
526
|
+
Unlike a Mercury code, history drafts revenue too: it repeats what a person
|
|
527
|
+
already did for that payee, such as card cashback booked to income.
|
|
528
|
+
|
|
529
|
+
On any submitted Bank Transaction the ingest already knows — adopted or its own
|
|
530
|
+
— a later note or GL-code change at Mercury still lands, because those two
|
|
531
|
+
fields are `allow_on_submit`. It arrives by **sweep, not by event**: coding a
|
|
532
|
+
transaction at Mercury emits no event and bumps no `resourceVersion`, so a run
|
|
533
|
+
that waited for the log would never hear about it. Every `mercury sync`
|
|
534
|
+
therefore re-plans those two fields on every settled transaction it read, not
|
|
535
|
+
only the ones events named, and reports what changed as `updated`. A settled
|
|
536
|
+
transaction the ledger holds no row for is reported instead of posted — the
|
|
537
|
+
sweep never inserts, because a row appearing with nothing in the log having said
|
|
538
|
+
so would hide the missing event; re-snapshotting is the repair. Anything that
|
|
539
|
+
would change the amount, date, description or account of a submitted row is
|
|
540
|
+
refused and reported with the disagreement named, as before; the person's
|
|
541
|
+
commitment stands and they decide. The description is held to the bank's own
|
|
542
|
+
text — the part of it the bank said — so a row keeps whatever note and GL code
|
|
543
|
+
were composed into it when it was posted, and a person recoding a transaction at
|
|
544
|
+
Mercury afterwards gets the custom fields updated rather than the row refused.
|
|
545
|
+
`reference_number` is compared nowhere: the ingest has spelled its own two ways,
|
|
546
|
+
and an adopted row's is whatever reference the person meant by it.
|
|
547
|
+
|
|
548
|
+
The state file holds a cursor into one bank's event log and accounts for rows in
|
|
549
|
+
one ledger, so there is one file per `(--env, --target)` pair:
|
|
550
|
+
`$XDG_STATE_HOME/<name>-books/mercury-<env>-<target>.sqlite3`, where `<name>` is
|
|
551
|
+
`books.toml`'s name, lowercased. The pair is also recorded inside the file, so a
|
|
552
|
+
`--state` path pointing at another pair's state is refused rather than resumed —
|
|
553
|
+
a sandbox cursor read as a production one would start the next sync after an
|
|
554
|
+
event the bank never emitted and skip everything before it.
|
|
555
|
+
|
|
556
|
+
Every run reports what it posted (`submitted`, `updated`, `adopted`), the
|
|
557
|
+
receipts it brought across (`attached` — each one named with the Bank
|
|
558
|
+
Transaction it landed on), the balance invariant (each account's settled
|
|
559
|
+
transactions summed against `currentBalance`), and the settled non-transfer rows
|
|
560
|
+
carrying no GL code at Mercury, aged (`uncoded`). An uncoded row gives the
|
|
561
|
+
person reconciling nothing to go on, and somebody will have to code it — in
|
|
562
|
+
Mercury, so the hint lands on the Bank Transaction, or by hand at reconciliation
|
|
563
|
+
— so the run names it rather than leaving it to be found. A run that wants a
|
|
564
|
+
person exits 1.
|
|
565
|
+
|
|
566
|
+
Receipts come across on the same pass. A Mercury transaction carries the files
|
|
567
|
+
somebody uploaded against it as presigned S3 URLs good for twelve hours, and
|
|
568
|
+
each one is fetched — streamed, capped at 20 MB — and attached, private, to that
|
|
569
|
+
transaction's Bank Transaction. Identity is the attachment UUID out of the URL
|
|
570
|
+
path, kept in the `File`'s name as `<uuid>-<file name>`, so a run compares what
|
|
571
|
+
is on the row against what Mercury reports and uploads only what is missing.
|
|
572
|
+
**Nothing is ever deleted or replaced**, including when Mercury drops an
|
|
573
|
+
attachment: a receipt on the bank's record is a person's record, and the URL
|
|
574
|
+
behind it is long expired. A `File` on the row with no UUID prefix is somebody
|
|
575
|
+
else's and is left alone without a word. Like the GL code, the attachment list
|
|
576
|
+
emits no event, which is why it is compared on every run rather than waited for.
|
|
577
|
+
|
|
578
|
+
`--env` takes `sandbox` or `production`, with no default — they are different
|
|
579
|
+
hosts and a client that picks one eventually picks the bank. The sandbox token
|
|
580
|
+
comes from `MERCURY_SANDBOX_TOKEN`; the real one from the Secret Manager secret
|
|
581
|
+
`books.toml` names as `secrets.mercury`. `--env sandbox` with `--target books`
|
|
582
|
+
is refused: seeded data has no business in the company's ledger.
|
|
583
|
+
|
|
584
|
+
`mercury.toml`, beside `chart.coa`, says what each Mercury account is here —
|
|
585
|
+
which chart account holds its balance (`[accounts]`, which
|
|
586
|
+
`install-bank-accounts` links each Bank Account to) and which ERPNext Bank
|
|
587
|
+
Account its Bank Transactions attach to (`[bank_accounts]`). There is one Bank
|
|
588
|
+
Account per Mercury account rather than one for all of them: the ingest reads
|
|
589
|
+
checking, savings and the card, and a savings withdrawal posted against the
|
|
590
|
+
checking Bank Account is wrong in the ledger and invisible to the reconciliation
|
|
591
|
+
the doctype exists for. An account with no entry refuses. There is no category
|
|
592
|
+
table: a file that still carries `[categories]` or `[posting]` is refused rather
|
|
593
|
+
than read past, because a table that reads as configured while doing nothing is
|
|
594
|
+
exactly what a person editing it cannot see.
|
|
595
|
+
|
|
596
|
+
## The forecast commands
|
|
597
|
+
|
|
598
|
+
Compute the capital call: how much each member puts in so cash stays at or above
|
|
599
|
+
a floor (the largest single charge) at its lowest point through the end of the
|
|
600
|
+
third full month ahead. The inputs are `forecast.toml` (in the books directory
|
|
601
|
+
beside the chart; validated against it on load), Mercury's balances and
|
|
602
|
+
transactions, and the ledger's reconciliation frontier. Money is `Decimal`
|
|
603
|
+
throughout; each line costs its `high` amount, a charge not yet seen in the bank
|
|
604
|
+
is still owed, and each member's share rounds up to the cent.
|
|
605
|
+
|
|
606
|
+
Install order on a site: `forecast install-doctype` (administrator), then
|
|
607
|
+
`access provision` (grants the bot `read` and `create` on Cash Forecast), then
|
|
608
|
+
`forecast plan` and `forecast apply`. `access provision` and `access verify`
|
|
609
|
+
refuse with one sentence, writing nothing, if the doctype is missing.
|
|
610
|
+
|
|
611
|
+
| Command | Credential | Reads | Writes |
|
|
612
|
+
| --------------------------------------- | ------------- | ------------------------------------------ | ------------------------------- |
|
|
613
|
+
| `forecast plan --env <e> --target <t>` | reader | Mercury, Bank Transaction, `forecast.toml` | nothing |
|
|
614
|
+
| `forecast apply --env <e> --target <t>` | bot | the same, Company and Cash Forecast | one new Cash Forecast |
|
|
615
|
+
| `forecast install-doctype --target <t>` | administrator | DocType | the four Cash Forecast doctypes |
|
|
616
|
+
|
|
617
|
+
`install-doctype` takes `--yes` to skip the prompt and prints one line per
|
|
618
|
+
doctype with its outcome (`created`, `updated`, `unchanged`). Re-running repairs
|
|
619
|
+
a doctype whose field list drifted and never deletes one.
|
|
620
|
+
|
|
621
|
+
`plan` and `apply` print the window, opening cash (the sum of every Mercury
|
|
622
|
+
account's balance, net of the card), the ledger-through date (the day before the
|
|
623
|
+
oldest unreconciled Bank Transaction, or today), a table of months, each due
|
|
624
|
+
with its status (`owed` or `seen paid`), the floor and what set it (a line name,
|
|
625
|
+
`[call] floor`, or `no owed charges`), the total call, and each member's share.
|
|
626
|
+
`apply` then prints `wrote CF-00001` and the document's desk URL. Every run adds
|
|
627
|
+
a document and never edits an earlier one. `now` is local time.
|
|
628
|
+
|
|
629
|
+
Exit status is 0 on success and 1 on a refusal, which prints one sentence:
|
|
630
|
+
|
|
631
|
+
- Mercury's balance for an account differs from the sum of its transactions, or
|
|
632
|
+
Mercury returned no accounts.
|
|
633
|
+
- A Bank Transaction has no readable date.
|
|
634
|
+
- `forecast.toml` does not validate.
|
|
635
|
+
- The site is in maintenance mode (`SessionStopped`).
|
|
636
|
+
- `apply` only: the Cash Forecast doctype is not installed on the site.
|
|
637
|
+
- Mercury's API refuses a request.
|
|
638
|
+
- `apply` only: the bot lacks permission to create Cash Forecast documents; run
|
|
639
|
+
`access provision`.
|
|
640
|
+
|
|
641
|
+
`--env sandbox` with `--target books` is refused before any request as a usage
|
|
642
|
+
error, with exit status 2.
|
|
643
|
+
|
|
644
|
+
## Targets
|
|
645
|
+
|
|
646
|
+
`--target` takes one of two values, and there is no default — reaching a real
|
|
647
|
+
ledger requires naming it:
|
|
648
|
+
|
|
649
|
+
- `test` — a disposable ERPNext site. Credentials come from the environment
|
|
650
|
+
first (`RUNNINGTAB_TEST_URL`, `RUNNINGTAB_TEST_KEY`, `RUNNINGTAB_TEST_SECRET`,
|
|
651
|
+
all three required once the URL is set, so a developer pointed at their own
|
|
652
|
+
bench is never silently redirected); with none exported it falls back to the
|
|
653
|
+
`secrets.test_admin` secret and the one allowlisted host, so anything running
|
|
654
|
+
under application default credentials reaches the site with nothing set up. A
|
|
655
|
+
stored **site** key is allowed there because it reaches that one disposable
|
|
656
|
+
site and nothing else; the Frappe Cloud team token, which serves the books
|
|
657
|
+
site by name however reassuringly `press.api.site.all` lists only the test
|
|
658
|
+
one, stays with a human. Either way the target checks the host against an
|
|
659
|
+
**allowlist** (`books.toml`'s `site.test_hosts`), not a denylist on the books
|
|
660
|
+
hostname, and refuses anything not named there — a custom domain or alias can
|
|
661
|
+
never slip through by looking unlike production.
|
|
662
|
+
- `books` — the real ledger. Its credential is fetched from Secret Manager at
|
|
663
|
+
the moment it is used, never held during development and never substitutable
|
|
664
|
+
by an exported variable.
|
|
665
|
+
|
|
666
|
+
The tests that actually exercise the `test` target (`pytest -m live_runningtab`)
|
|
667
|
+
are skipped unless `RUNNINGTAB_LIVE=1` is set — `RUNNINGTAB_TEST_URL` alone no
|
|
668
|
+
longer runs them, since `site_config` now falls back to the stored test-site key
|
|
669
|
+
when it is unset, and that fallback must not turn a normal test run into a real
|
|
670
|
+
network call. Like the CLI, they take the books directory from
|
|
671
|
+
`RUNNINGTAB_BOOKS`. Run them with `just test-live`, or export
|
|
672
|
+
`RUNNINGTAB_LIVE=1`, `RUNNINGTAB_BOOKS`, and your own `RUNNINGTAB_TEST_*`
|
|
673
|
+
variables to point at a personal bench instead of the shared site. They never
|
|
674
|
+
run as part of `just test` or CI's default suite.
|
|
675
|
+
|
|
676
|
+
## Access
|
|
677
|
+
|
|
678
|
+
"Machines draft, humans post" holds only if the credential a machine uses cannot
|
|
679
|
+
commit money — `icedcoffee-sync`'s client-side guard is a courtesy any caller
|
|
680
|
+
can bypass, so the boundary is the API key's server-side roles.
|
|
681
|
+
`runningtab access` provisions a dedicated bot user whose role holds
|
|
682
|
+
create/write permission for drafting and the chart of accounts, **never** cancel
|
|
683
|
+
or amend on anything, and submit on exactly one doctype: **Bank Transaction**.
|
|
684
|
+
That is the one exception, and the reason it is safe is what a Bank Transaction
|
|
685
|
+
is: submitting it posts nothing to the general ledger. ERPNext writes the GL
|
|
686
|
+
entries when a person reconciles the row to a voucher in the Bank Reconciliation
|
|
687
|
+
Tool, which only works on submitted rows — so the ingest submitting the
|
|
688
|
+
statement lines it creates commits the bank's record of what happened, not the
|
|
689
|
+
books' account of why. Every other submittable doctype the bot can reach
|
|
690
|
+
(Journal Entry, Purchase Invoice) commits money on submit and stays draft-only.
|
|
691
|
+
The ingest is the one command that takes the credential with submit capability
|
|
692
|
+
(`ingest_config`); everything else keeps the drafts-only client, so a stray
|
|
693
|
+
`docstatus` anywhere else fails before it is sent.
|
|
694
|
+
|
|
695
|
+
- `access provision --target <target> [--yes]` — as an administrator (for
|
|
696
|
+
`books`, your own System Manager API token in `RUNNINGTAB_ADMIN_TOKEN`, the
|
|
697
|
+
`key:secret` ERPNext shows for your user, with or without its `token ` prefix;
|
|
698
|
+
the stored books credential is the bot's and cannot provision), create or
|
|
699
|
+
repair the bot role and user `books.toml`'s `[bot]` names, reconcile its
|
|
700
|
+
grants to exactly:
|
|
701
|
+
|
|
702
|
+
- `Account`: read, write, create, delete (the chart apply/prune)
|
|
703
|
+
- `Company`: read, write (default-account links)
|
|
704
|
+
- `GL Entry`: read (the ledger guard)
|
|
705
|
+
- `Journal Entry`, `Purchase Invoice`, `Supplier`, `File`: read, write, create
|
|
706
|
+
(AP drafting and attachments)
|
|
707
|
+
- `Bank Transaction`: read, write, create, **submit** (the Mercury ingest; see
|
|
708
|
+
above for why this one submit is safe)
|
|
709
|
+
- `Cash Forecast`: read, create (the capital forecast; each run writes a new
|
|
710
|
+
document, and the bot can neither change nor delete one once written)
|
|
711
|
+
|
|
712
|
+
then mint a fresh key and prove it with the server before storing it: a real
|
|
713
|
+
submit attempt on Journal Entry is refused (after a real draft proved the key
|
|
714
|
+
can create), `submit`/`cancel`/`amend` are denied on Journal Entry and
|
|
715
|
+
Purchase Invoice, `cancel`/`amend` on Bank Transaction, and `write`/`delete`
|
|
716
|
+
on Cash Forecast, and every granted flag is actually in effect — `submit` on
|
|
717
|
+
Bank Transaction included, since a grant on paper the server ignores would
|
|
718
|
+
fail the ingest on its first row, and a permission row that exists is not
|
|
719
|
+
proof it works. Only then is the key stored (to Secret Manager for `books`;
|
|
720
|
+
the `test` target keeps its credential in the environment); anything else
|
|
721
|
+
exits 1 naming the doctype and flag. Re-running rotates the bot's secret every
|
|
722
|
+
time, and the command says so when a failure leaves the stored one stale.
|
|
723
|
+
|
|
724
|
+
- `access verify --target <target> [--company NAME]` — run the same three proofs
|
|
725
|
+
against the target's current credential; exits 0 only on a full pass, usable
|
|
726
|
+
as a gate like `coa plan`.
|