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