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,757 @@
1
+ """Turning a settled transaction into an ERPNext Bank Transaction, and submitting it.
2
+
3
+ A Bank Transaction is the one document this ingest submits. Submitting it posts
4
+ nothing to the general ledger -- ERPNext writes the GL entries when a person
5
+ reconciles the row to a voucher in the Bank Reconciliation Tool, which only
6
+ works on submitted rows -- so the bot submitting it commits a statement line,
7
+ not the books. The submit rides on the insert as `docstatus: 1`, which Frappe's
8
+ own insert path treats as a submit: one request, so a refused submit leaves no
9
+ draft behind. `bank_transaction` itself builds the document without a
10
+ `docstatus`, and only the plan for a brand-new row adds one, so an amendment or
11
+ an adoption can never submit a row a person left as a draft.
12
+
13
+ Idempotency is the ledger's, not the state file's. `mercury_transaction_id` is a
14
+ unique custom field on Bank Transaction, so a transaction that arrives twice --
15
+ a replayed event, a later note, a re-snapshot -- amends the row it already has
16
+ instead of posting a second one.
17
+
18
+ A row's `reference_number` and `description` are the two things ERPNext's
19
+ Banking workspace shows a person reconciling, so both are written for them to
20
+ read: the ACH trace number a statement would show, and the note and GL code
21
+ they coded at Mercury ahead of the bank's own text. Both are fixed when the row
22
+ is posted; the custom fields below stay the live copy of the note and the code.
23
+
24
+ A Bank Transaction somebody imported by hand through Bank Statement Import
25
+ carries none of those fields, but it does carry Mercury's `Tracking ID` -- the
26
+ ACH trace number -- as its `reference_number`, and the API reports the same
27
+ number as `trackingNumber`. That is the number this module writes too, so a
28
+ hand import and an ingested row agree on what a person reads as "Ref". A
29
+ transaction with no document of its own and
30
+ exactly one such row on its Bank Account **adopts** that row: Mercury's fields
31
+ are stamped onto it and nothing else is touched, so later events find it by id
32
+ like any other. It is the one case where this module writes to a submitted
33
+ document, and it writes only the fields that are `allow_on_submit`.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import dataclasses
39
+ import enum
40
+ import re
41
+ from collections.abc import Iterator
42
+ from decimal import Decimal
43
+ from typing import TYPE_CHECKING, Any, Protocol
44
+
45
+ from runningtab.mercury.mapping import Mapping
46
+ from runningtab.mercury.models import Transaction
47
+ from runningtab.period.lock import LockState
48
+
49
+ if TYPE_CHECKING:
50
+ # Annotation only: `runningtab.mercury.vouchers` imports this module.
51
+ from runningtab.mercury.vouchers import Chart
52
+
53
+ BANK_TRANSACTION = "Bank Transaction"
54
+ # Not written here -- `runningtab.mercury.vouchers` drafts the voucher -- but named
55
+ # here because this module owns the custom-field install spec, and the voucher
56
+ # is found by the same Mercury id.
57
+ JOURNAL_ENTRY = "Journal Entry"
58
+
59
+ TRANSACTION_ID_FIELD = "mercury_transaction_id"
60
+ RESOURCE_VERSION_FIELD = "mercury_resource_version"
61
+ GL_CODE_FIELD = "mercury_gl_code"
62
+ NOTE_FIELD = "mercury_note"
63
+
64
+ # What Mercury says about a Bank Transaction, as opposed to what the Bank
65
+ # Transaction says happened. These four are the only fields ever written to a
66
+ # submitted document -- they describe the row's provenance, not the money --
67
+ # and they are `allow_on_submit` on Bank Transaction for exactly that reason.
68
+ MERCURY_FIELDS = (TRANSACTION_ID_FIELD, RESOURCE_VERSION_FIELD, GL_CODE_FIELD, NOTE_FIELD)
69
+
70
+ # What a Bank Transaction says happened. A submitted document's values here are
71
+ # a person's commitment, and the ingest never changes them; it reports a
72
+ # disagreement instead.
73
+ BANK_TRANSACTION_CONTENT = ("date", "deposit", "withdrawal", "description", "bank_account")
74
+
75
+ # `reference_number` is deliberately not among them, and is compared nowhere.
76
+ # Two reasons, and either alone would settle it. Ours is spelled two ways
77
+ # across time -- rows posted before the trace number was preferred carry the
78
+ # Mercury transaction id -- and reporting our own change of mind as the
79
+ # person's disagreement would refuse every note that arrived after it. And an
80
+ # adopted row keeps whatever reference the person meant by it, a check number
81
+ # or an invoice, because adoption writes only the Mercury fields; that string
82
+ # is theirs, so the ingest neither reads it back nor holds them to it.
83
+
84
+ # The parts of a description, in the order a person needs them: what they wrote
85
+ # about this payment, the account they expect to pick, and then the bank's own
86
+ # words. ERPNext's Banking workspace shows a card with the date, the reference,
87
+ # the description and the amount and nothing else, so a hint left in a custom
88
+ # field is work the person has to do again by opening the row.
89
+ DESCRIPTION_SEPARATOR = " · "
90
+
91
+ # What a person has to know when a submitted document and Mercury disagree, or
92
+ # when a document somebody submitted is not one the ingest may amend at all.
93
+ _SUBMITTED = "is already submitted; a person decides whether it still says the right thing"
94
+
95
+
96
+ class PostingError(RuntimeError):
97
+ """A transaction cannot be posted as described."""
98
+
99
+
100
+ _MARKUP = re.compile(r"</?strong>")
101
+
102
+
103
+ def plain_message(message: str) -> str:
104
+ """A Frappe message without the emphasis it wrote for a browser.
105
+
106
+ The site wraps each value in `<strong>`, which in a terminal is noise
107
+ around the only part of the sentence a person needs. Here rather than in
108
+ one of the callers because both the posting path and the receipt path
109
+ turn a refusal into a report line.
110
+ """
111
+ return _MARKUP.sub("", message).strip()
112
+
113
+
114
+ class Outcome(enum.StrEnum):
115
+ """What happened to a document."""
116
+
117
+ CREATED = "created"
118
+ # A new Bank Transaction, inserted already submitted. Its own outcome
119
+ # rather than CREATED because the report has to say plainly that the row
120
+ # is past draft -- the one place this ingest is.
121
+ SUBMITTED = "submitted"
122
+ UPDATED = "updated"
123
+ UNCHANGED = "unchanged"
124
+ REFUSED = "refused"
125
+ # A row somebody imported by hand, recognised by its trace number and
126
+ # stamped with Mercury's fields. Neither created nor updated: the ingest
127
+ # wrote no content, and the person reading the report wants to know which
128
+ # of their imports it now accounts for.
129
+ ADOPTED = "adopted"
130
+
131
+
132
+ @dataclasses.dataclass(frozen=True)
133
+ class Plan:
134
+ """What `upsert` would do for one document, decided before anything is written.
135
+
136
+ A dry run reports this; a real run carries it out. Deciding once and in
137
+ one place is what keeps the two from disagreeing -- a dry run that plans a
138
+ create where the real run would adopt is a plan nobody can act on.
139
+ """
140
+
141
+ outcome: Outcome
142
+ doctype: str
143
+ name: str = ""
144
+ body: dict[str, Any] = dataclasses.field(default_factory=dict)
145
+ reason: str = ""
146
+
147
+
148
+ class Ledger(Protocol):
149
+ """The slice of `icedcoffee_sync.frappe.client.Client` this module uses."""
150
+
151
+ def list(self, doctype: str, **kwargs: Any) -> Any: ...
152
+ def iter_all(self, doctype: str, **kwargs: Any) -> Iterator[dict[str, Any]]: ...
153
+ # Neither of these is used here. Named so that one ledger type describes
154
+ # everything a run needs: the whole-document read `runningtab.mercury.vouchers`
155
+ # makes to compare a draft's child table, and the multipart upload
156
+ # `runningtab.mercury.receipts` makes to attach a receipt.
157
+ def get(self, doctype: str, name: str) -> Any: ...
158
+ def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
159
+ def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
160
+ def upload_file(
161
+ self, doctype: str, docname: str, filename: str, content: bytes, *, is_private: bool = True
162
+ ) -> Any: ...
163
+
164
+
165
+ @dataclasses.dataclass(frozen=True)
166
+ class PostingContext:
167
+ """Everything a document needs that is not on the transaction.
168
+
169
+ `account_names` maps a chart account number to the ERPNext document name it
170
+ became, which is derived from the number and title -- callers never build
171
+ that string themselves.
172
+
173
+ `lock` is Books Closed Through, read once per run alongside Company in
174
+ `runningtab.mercury.session.posting_context`, which refuses by name when the
175
+ bot cannot see the lock fields at all. Its default -- no date known --
176
+ is for a caller that builds a context by hand (the tests), and drafts a
177
+ voucher for every coded row. Nothing downstream backs that default up:
178
+ ERPNext's `check_freezing_date` runs only when GL entries are made, on
179
+ submit, and the ingest saves vouchers as drafts, so a draft dated in a
180
+ frozen quarter is accepted. The check in `runningtab.mercury.vouchers` is the
181
+ only guard.
182
+ """
183
+
184
+ company: str
185
+ account_names: dict[str, str]
186
+ mapping: Mapping
187
+ # The books bot's user. A draft voucher it last modified is its own to
188
+ # update; one anybody else touched has been taken over by a person.
189
+ bot_user: str
190
+ # The chart's postable accounts, which decide what a GL code drafts.
191
+ chart: Chart
192
+ mercury_account_names: dict[str, str] = dataclasses.field(default_factory=dict)
193
+ lock: LockState = dataclasses.field(default_factory=lambda: LockState(closed_through=None, bypass_role=None))
194
+
195
+ def link(self, number: str) -> str:
196
+ """The ERPNext document name for a chart account number."""
197
+ name = self.account_names.get(number)
198
+ if name is None:
199
+ raise PostingError(f"account {number} is not on the site; apply the chart before ingesting")
200
+ return name
201
+
202
+ def bank_account(self, transaction: Transaction) -> str:
203
+ """The ERPNext Bank Account document this transaction's account attaches to."""
204
+ name = self.mapping.bank_account_for(
205
+ transaction.account_id, self.mercury_account_names.get(transaction.account_id)
206
+ )
207
+ if name is None:
208
+ raise PostingError(
209
+ f"Mercury account {transaction.account_id} has no [bank_accounts] entry in mercury.toml; "
210
+ "ERPNext requires a Bank Account document on every Bank Transaction, and posting this "
211
+ "against another account's would be wrong in the ledger and invisible in reconciliation"
212
+ )
213
+ return name
214
+
215
+
216
+ def _amount(value: Decimal) -> float:
217
+ """A ledger amount as JSON.
218
+
219
+ ERPNext takes numbers, so a float is unavoidable at the wire; the conversion
220
+ happens here, once, after every arithmetic decision has already been made in
221
+ `Decimal`.
222
+ """
223
+ return float(value)
224
+
225
+
226
+ def bank_text(transaction: Transaction) -> str:
227
+ """What the bank calls this movement.
228
+
229
+ The only part of a description that is the bank's own fact rather than
230
+ something we composed, which is why it is the part `_content_disagreements`
231
+ holds a submitted row to.
232
+ """
233
+ return (transaction.bank_description or transaction.counterparty_name or transaction.kind).strip()
234
+
235
+
236
+ def statement_description(transaction: Transaction) -> str:
237
+ """The person's note, the GL code, then the bank's text, joined by `DESCRIPTION_SEPARATOR`.
238
+
239
+ Composed from the parts that exist, so a transaction nobody coded reads
240
+ exactly as it did before this composition existed -- the bank's text alone,
241
+ with no separator hanging off either end.
242
+
243
+ This is content we author once, when the row is posted. The custom fields
244
+ stay the live copy of the note and the GL code: they are `allow_on_submit`
245
+ and a later edit at Mercury lands there by sweep, while the description of
246
+ a submitted row is the person's committed statement line and stays put.
247
+ """
248
+ parts = (transaction.note, transaction.gl_code, bank_text(transaction))
249
+ return DESCRIPTION_SEPARATOR.join(part.strip() for part in parts if part and part.strip())
250
+
251
+
252
+ def bank_transaction(transaction: Transaction, ctx: PostingContext) -> dict[str, Any]:
253
+ """One statement line, for the reconciliation tool.
254
+
255
+ This is where the dogfooding lives: an outgoing payment has to find the
256
+ draft Purchase Invoice the AP path created from the emailed PDF, and that
257
+ match is the product thesis run against books we control.
258
+
259
+ Attached to the Bank Account of the Mercury account the money actually
260
+ moved through. The ingest reads checking, savings and the card, and a row
261
+ posted against another account's Bank Account contaminates exactly the
262
+ reconciliation this doctype exists for.
263
+ """
264
+ bank_account = ctx.bank_account(transaction)
265
+ deposit = transaction.amount if transaction.amount > 0 else Decimal(0)
266
+ withdrawal = -transaction.amount if transaction.amount < 0 else Decimal(0)
267
+ doc: dict[str, Any] = {
268
+ "doctype": BANK_TRANSACTION,
269
+ "date": transaction.accounting_date.isoformat(),
270
+ "bank_account": bank_account,
271
+ "company": ctx.company,
272
+ "deposit": _amount(deposit),
273
+ "withdrawal": _amount(withdrawal),
274
+ "description": statement_description(transaction),
275
+ # The ACH trace number when Mercury has one: that is what a bank
276
+ # statement shows, what a hand import carries, and the very value
277
+ # adoption matches on, so an ingested row and a hand-imported one read
278
+ # the same "Ref" on the reconciliation card. A card charge never went
279
+ # through the ACH network and has none, so its reference is the Mercury
280
+ # transaction id -- a poor thing to read and a real one, since it names
281
+ # the row at the bank.
282
+ "reference_number": transaction.tracking_number or transaction.id,
283
+ TRANSACTION_ID_FIELD: transaction.id,
284
+ }
285
+ # What a person coded in Mercury rides along for the person reconciling.
286
+ # The GL code is carried exactly as Mercury reports it -- the same
287
+ # `"<number> - <name>"` string the `GL Code` column of a statement export
288
+ # holds -- so a row posted here and a row imported by hand read the same.
289
+ if transaction.gl_code:
290
+ doc[GL_CODE_FIELD] = transaction.gl_code
291
+ if transaction.note:
292
+ doc[NOTE_FIELD] = transaction.note
293
+ return doc
294
+
295
+
296
+ # What a lookup reads back: enough to compare a submitted row's content with
297
+ # Mercury's, and to see which Mercury fields are already on it.
298
+ _LOOKUP_FIELDS = ["name", "docstatus", *BANK_TRANSACTION_CONTENT, *MERCURY_FIELDS]
299
+
300
+
301
+ def find_existing(ledger: Ledger, transaction_id: str) -> dict[str, Any] | None:
302
+ """The Bank Transaction already posted for a Mercury transaction, if there is one."""
303
+ rows = ledger.list(
304
+ BANK_TRANSACTION,
305
+ fields=_LOOKUP_FIELDS,
306
+ filters=[[TRANSACTION_ID_FIELD, "=", transaction_id]],
307
+ limit=2,
308
+ )
309
+ if not rows:
310
+ return None
311
+ if len(rows) > 1:
312
+ # Two documents for one transaction is the state the unique custom
313
+ # field exists to prevent, so reaching it means the field was never
314
+ # installed or was installed without `unique`. Amending one of them at
315
+ # random would leave the other in the ledger unnoticed.
316
+ names = ", ".join(str(row.get("name")) for row in rows)
317
+ raise PostingError(
318
+ f"{BANK_TRANSACTION} has more than one document for Mercury transaction {transaction_id} ({names}); "
319
+ f"run `runningtab mercury install-fields` and remove the duplicate before ingesting again"
320
+ )
321
+ return rows[0]
322
+
323
+
324
+ def posted_transaction_ids(ledger: Ledger) -> dict[str, str]:
325
+ """Every Mercury transaction id the ledger already holds a Bank Transaction for.
326
+
327
+ Maps the id to the name of the document carrying it.
328
+
329
+ Paged with `iter_all` rather than one capped `list`. The caller uses this
330
+ to find rows naming a transaction Mercury no longer returns, and a read cut
331
+ short at the page limit looks exactly like "nothing is orphaned" -- the
332
+ answer that lets the cursor move past them.
333
+
334
+ The `mercury_transaction_id` filter is applied here rather than sent, so
335
+ this needs nothing of the server beyond listing the doctype. A Bank
336
+ Transaction somebody imported by hand carries no Mercury id until a run
337
+ adopts it, and until then it is none of this module's business.
338
+ """
339
+ posted: dict[str, str] = {}
340
+ for row in ledger.iter_all(BANK_TRANSACTION, fields=["name", TRANSACTION_ID_FIELD]):
341
+ transaction_id = row.get(TRANSACTION_ID_FIELD)
342
+ if transaction_id:
343
+ posted[str(transaction_id)] = str(row.get("name", ""))
344
+ return posted
345
+
346
+
347
+ def find_by_reference(ledger: Ledger, bank_account: str, reference: str) -> list[dict[str, Any]]:
348
+ """The Bank Transactions on one Bank Account whose `reference_number` is this trace number.
349
+
350
+ Narrowed to the Bank Account because a trace number identifies an ACH
351
+ entry, and a transfer between two of our accounts is the same entry seen
352
+ from both sides. Cancelled rows are left out: a document somebody cancelled
353
+ is not one they want Mercury's identity stamped onto.
354
+ """
355
+ rows = ledger.list(
356
+ BANK_TRANSACTION,
357
+ fields=_LOOKUP_FIELDS,
358
+ filters=[["bank_account", "=", bank_account], ["reference_number", "=", reference]],
359
+ limit=5,
360
+ )
361
+ return [row for row in rows if int(row.get("docstatus") or 0) != 2]
362
+
363
+
364
+ def _mercury_fields(body: dict[str, Any]) -> dict[str, Any]:
365
+ return {k: body[k] for k in MERCURY_FIELDS if k in body}
366
+
367
+
368
+ def _content_disagreements(existing: dict[str, Any], body: dict[str, Any], transaction: Transaction) -> list[str]:
369
+ """Where a Bank Transaction's content says something Mercury does not.
370
+
371
+ The comparison is against Mercury's facts, not against the ingest's own
372
+ spelling of them. A row imported by hand carries the export's `Description`
373
+ and `Date (UTC)`, which are the counterparty's name and the day the
374
+ transaction was created; the ingest would have written the bank description
375
+ and the day it posted. Both describe the same transaction truthfully, so
376
+ any of Mercury's own spellings agrees, and only a value Mercury never said
377
+ -- a different amount, another account, an unrelated description -- is a
378
+ disagreement.
379
+
380
+ The description is held to the bank's text alone. Everything before it is
381
+ ours: a note and a GL code composed in when the row was posted, either of
382
+ which a person may have recoded at Mercury since, and the composition rule
383
+ itself has changed once already. Holding a submitted row to a prefix we
384
+ wrote would refuse a person's own note for disagreeing with our older
385
+ spelling of their older note -- the first law inverted. So a description
386
+ agrees when it ends with the bank text, which the whole composed string
387
+ does and so does a row posted before any prefix existed, or when it is one
388
+ of Mercury's own spellings of that movement. The bank's words changing
389
+ under a row somebody committed is still refused by name.
390
+ """
391
+ disagreements: list[str] = []
392
+ for field in ("deposit", "withdrawal"):
393
+ if Decimal(str(existing.get(field) or 0)) != Decimal(str(body[field])):
394
+ disagreements.append(f"{field} {existing.get(field)} -> {body[field]}")
395
+ if existing.get("bank_account") != body["bank_account"]:
396
+ disagreements.append(f"bank_account {existing.get('bank_account')!r} -> {body['bank_account']!r}")
397
+ date = str(existing.get("date") or "")[:10]
398
+ dates = {transaction.accounting_date.isoformat(), transaction.created_at.date().isoformat()}
399
+ if date not in dates:
400
+ disagreements.append(f"date {date!r} -> {body['date']!r}")
401
+ description = str(existing.get("description") or "").strip()
402
+ spellings = {
403
+ s.strip() for s in (transaction.bank_description, transaction.counterparty_name, transaction.kind) if s
404
+ }
405
+ if description not in spellings and not description.endswith(bank_text(transaction)):
406
+ disagreements.append(f"description {description!r} -> {body['description']!r}")
407
+ return disagreements
408
+
409
+
410
+ def plan(
411
+ ledger: Ledger,
412
+ doc: dict[str, Any],
413
+ transaction: Transaction,
414
+ *,
415
+ resource_version: int | None = None,
416
+ ) -> Plan:
417
+ """Decide what `upsert` would do for this document, writing nothing.
418
+
419
+ Never writes content to a document somebody has submitted. A change at
420
+ Mercury to a transaction whose row a person already committed is not ours
421
+ to rewrite -- it is theirs to know about, so it comes back `REFUSED` and
422
+ lands in the report rather than in the ledger. The one narrowing: on a
423
+ submitted Bank Transaction whose content agrees with Mercury, the Mercury
424
+ fields alone may still be written. They say where the row came from and
425
+ what a person coded at the bank, not what happened, and a note or GL code
426
+ that arrives after the person submitted is exactly what those fields are
427
+ for; a person keeps every decision about the money.
428
+
429
+ A transaction with no document of its own, carrying a trace number, looks
430
+ for a row imported by hand: same Bank Account, same number in
431
+ `reference_number`. One match is adopted. Two is not a match, it is a
432
+ question -- a statement imported twice, a number typed onto the wrong row
433
+ -- and picking either would stamp Mercury's identity on a document that
434
+ may be the wrong one, so it raises and the run reports it.
435
+
436
+ **The lookup-then-insert is not atomic**, and it guards against a re-run,
437
+ never against two runs at once. Two concurrent ingests could each find
438
+ nothing and both insert; `unique` on the transaction id makes the server
439
+ refuse the second, so the failure is loud rather than a duplicate. Left
440
+ that way deliberately: the ingest is a single-writer CLI with no scheduler
441
+ behind it. Revisit when something runs this on a timer or a webhook
442
+ consumer runs alongside it.
443
+ """
444
+ transaction_id = doc[TRANSACTION_ID_FIELD]
445
+ body = {k: v for k, v in doc.items() if k != "doctype"}
446
+ if resource_version is not None:
447
+ body[RESOURCE_VERSION_FIELD] = resource_version
448
+ existing = find_existing(ledger, transaction_id)
449
+ if existing is None:
450
+ return _plan_adoption(ledger, body, transaction)
451
+ return _plan_amendment(existing, body, transaction)
452
+
453
+
454
+ def _submitted(body: dict[str, Any]) -> dict[str, Any]:
455
+ """A new row's body, submitted on insert. The only place this module sets a docstatus."""
456
+ return {**body, "docstatus": 1}
457
+
458
+
459
+ def _plan_adoption(ledger: Ledger, body: dict[str, Any], transaction: Transaction) -> Plan:
460
+ """Adopt the one hand-imported row carrying this transaction's trace number, or plan a fresh submitted row."""
461
+ if not transaction.tracking_number:
462
+ return Plan(Outcome.SUBMITTED, BANK_TRANSACTION, body=_submitted(body))
463
+ matches = find_by_reference(ledger, body["bank_account"], transaction.tracking_number)
464
+ if len(matches) > 1:
465
+ names = ", ".join(str(row["name"]) for row in matches)
466
+ raise PostingError(
467
+ f"{len(matches)} Bank Transactions on {body['bank_account']} carry reference "
468
+ f"{transaction.tracking_number} ({names}); Mercury transaction {transaction.id} matches one of "
469
+ "them and guessing which would stamp its identity on the wrong row -- remove the duplicate "
470
+ "import and the next run adopts the survivor"
471
+ )
472
+ if matches:
473
+ return Plan(Outcome.ADOPTED, BANK_TRANSACTION, str(matches[0]["name"]), _mercury_fields(body))
474
+ return Plan(Outcome.SUBMITTED, BANK_TRANSACTION, body=_submitted(body))
475
+
476
+
477
+ def _plan_amendment(existing: dict[str, Any], body: dict[str, Any], transaction: Transaction) -> Plan:
478
+ """What to write to the Bank Transaction this transaction already has.
479
+
480
+ Its content is compared rather than overwritten, because the row may be
481
+ one a person imported: their spelling of the date and description is theirs
482
+ to keep, and on a submitted document Frappe would refuse the write anyway.
483
+ Content that agrees leaves only the Mercury fields to bring up to date;
484
+ content that disagrees is rewritten on a draft and refused, by name, on a
485
+ submitted document.
486
+ """
487
+ name = str(existing["name"])
488
+ docstatus = int(existing.get("docstatus") or 0)
489
+ disagreements = _content_disagreements(existing, body, transaction)
490
+ if disagreements:
491
+ if docstatus != 0:
492
+ reason = f"{_SUBMITTED}: Mercury now says otherwise about {'; '.join(disagreements)}"
493
+ return Plan(Outcome.REFUSED, BANK_TRANSACTION, name, reason=reason)
494
+ return Plan(Outcome.UPDATED, BANK_TRANSACTION, name, body)
495
+ if docstatus == 2:
496
+ # Cancelled. Frappe accepts no write at all, `allow_on_submit` or not.
497
+ return Plan(Outcome.REFUSED, BANK_TRANSACTION, name, reason=_SUBMITTED)
498
+ changes = {k: v for k, v in _mercury_fields(body).items() if existing.get(k) != v}
499
+ if not changes:
500
+ return Plan(Outcome.UNCHANGED, BANK_TRANSACTION, name)
501
+ return Plan(Outcome.UPDATED, BANK_TRANSACTION, name, changes)
502
+
503
+
504
+ def carry_out(ledger: Ledger, verdict: Plan) -> tuple[Outcome, str]:
505
+ """Do what the plan says, and return the outcome with the document's name."""
506
+ if verdict.outcome is Outcome.SUBMITTED:
507
+ created = ledger.insert(verdict.doctype, verdict.body)
508
+ return Outcome.SUBMITTED, str(created.get("name", "")) if isinstance(created, dict) else ""
509
+ if verdict.outcome in {Outcome.UPDATED, Outcome.ADOPTED}:
510
+ ledger.update(verdict.doctype, verdict.name, verdict.body)
511
+ return verdict.outcome, verdict.name
512
+
513
+
514
+ def upsert(
515
+ ledger: Ledger,
516
+ doc: dict[str, Any],
517
+ transaction: Transaction,
518
+ *,
519
+ resource_version: int | None = None,
520
+ ) -> tuple[Outcome, str]:
521
+ """Post the row submitted, amend the one this transaction already has, or adopt a hand-imported row. See `plan`."""
522
+ return carry_out(ledger, plan(ledger, doc, transaction, resource_version=resource_version))
523
+
524
+
525
+ def custom_field_documents() -> list[dict[str, Any]]:
526
+ """The Custom Fields the ingest needs: four on Bank Transaction, one on Journal Entry.
527
+
528
+ `mercury_transaction_id` is unique on Bank Transaction, which is the point:
529
+ idempotency stops being a property of our code and becomes one the server
530
+ enforces, so even a bug in the applier cannot post the same transaction
531
+ twice.
532
+
533
+ On Journal Entry it is **not** unique. A person corrects a submitted
534
+ voucher by cancelling and amending it, and ERPNext's Amend copies the id
535
+ onto the amendment -- `no_copy` does not stop it -- while the cancelled
536
+ original still holds it. Frappe's `unique` is a plain index that cannot
537
+ exempt cancelled rows, so it refused that save and made every voucher of
538
+ ours uncorrectable. `runningtab.mercury.vouchers.find_existing` refuses a
539
+ second live voucher instead.
540
+
541
+ The GL code and note are writable, not read-only like the identity fields:
542
+ a statement export imported by hand through Bank Statement Import maps its
543
+ `GL Code` and `Note` columns onto them, and the import mapper cannot target
544
+ a read-only custom field.
545
+
546
+ All four Bank Transaction fields are `allow_on_submit`: a hand-imported row
547
+ is submitted before the ingest ever sees it, and adopting it means writing
548
+ these onto a submitted document, which Frappe permits for no other field.
549
+
550
+ Journal Entry carries the identity field alone, and **not**
551
+ `allow_on_submit`: `runningtab.mercury.vouchers` drafts a voucher beside each
552
+ coded Bank Transaction and never writes to a submitted one, so granting
553
+ that would widen the boundary for nothing. It carries no
554
+ `mercury_resource_version` either -- a GL code edit bumps no
555
+ `resourceVersion` at Mercury, and stamping one on a voucher would claim the
556
+ log said something it did not.
557
+ """
558
+ return [
559
+ {
560
+ "doctype": "Custom Field",
561
+ "dt": JOURNAL_ENTRY,
562
+ "fieldname": TRANSACTION_ID_FIELD,
563
+ "label": "Mercury Transaction ID",
564
+ "fieldtype": "Data",
565
+ # Not unique, so a person can amend; see the docstring above.
566
+ "unique": 0,
567
+ "read_only": 1,
568
+ "no_copy": 1,
569
+ # Stated rather than left to the default so the repair pass has an
570
+ # expected value: a field silently carrying it would let a write
571
+ # reach a voucher a person had committed.
572
+ "allow_on_submit": 0,
573
+ },
574
+ {
575
+ "doctype": "Custom Field",
576
+ "dt": BANK_TRANSACTION,
577
+ "fieldname": TRANSACTION_ID_FIELD,
578
+ "label": "Mercury Transaction ID",
579
+ "fieldtype": "Data",
580
+ "unique": 1,
581
+ "read_only": 1,
582
+ "no_copy": 1,
583
+ "allow_on_submit": 1,
584
+ },
585
+ {
586
+ "doctype": "Custom Field",
587
+ "dt": BANK_TRANSACTION,
588
+ "fieldname": RESOURCE_VERSION_FIELD,
589
+ "label": "Mercury Resource Version",
590
+ "fieldtype": "Int",
591
+ # Stated rather than left to the default, so the repair pass has an
592
+ # expected value to compare against. A version field wearing
593
+ # `unique` would reject the second transaction to reach version 1,
594
+ # which is every transaction after the first.
595
+ "unique": 0,
596
+ "read_only": 1,
597
+ "no_copy": 1,
598
+ "allow_on_submit": 1,
599
+ },
600
+ {
601
+ "doctype": "Custom Field",
602
+ "dt": BANK_TRANSACTION,
603
+ "fieldname": GL_CODE_FIELD,
604
+ "label": "Mercury GL Code",
605
+ "fieldtype": "Data",
606
+ "insert_after": RESOURCE_VERSION_FIELD,
607
+ "unique": 0,
608
+ "read_only": 0,
609
+ "no_copy": 1,
610
+ "allow_on_submit": 1,
611
+ },
612
+ {
613
+ "doctype": "Custom Field",
614
+ "dt": BANK_TRANSACTION,
615
+ "fieldname": NOTE_FIELD,
616
+ "label": "Mercury Note",
617
+ "fieldtype": "Small Text",
618
+ "insert_after": GL_CODE_FIELD,
619
+ "unique": 0,
620
+ "read_only": 0,
621
+ "no_copy": 1,
622
+ "allow_on_submit": 1,
623
+ },
624
+ ]
625
+
626
+
627
+ # Properties an existing field is repaired to match. `unique` is the one that
628
+ # matters -- a field that exists without it makes the whole idempotency story
629
+ # decorative -- `allow_on_submit` is what lets a hand-imported row be adopted
630
+ # at all, and the read-only flags are here so a repaired field is the same
631
+ # field a fresh install produces. A property the spec does not state is not
632
+ # compared.
633
+ _REPAIRED_PROPERTIES = ("unique", "read_only", "no_copy", "fieldtype", "allow_on_submit")
634
+
635
+
636
+ def install_custom_fields(admin: Ledger) -> list[tuple[str, Outcome]]:
637
+ """Create the custom fields if they are absent, repair them if they are wrong.
638
+
639
+ Needs an administrator: the books bot's role does not carry Custom Field
640
+ permission, deliberately -- an ingest that can reshape doctypes is a larger
641
+ thing than an ingest that can post into them.
642
+
643
+ A field that already exists is checked rather than assumed correct. One
644
+ created by hand without `unique` looks exactly like a correct install to a
645
+ presence check, and the ingest would then tell people to run this command to
646
+ fix a duplicate it can never fix.
647
+ """
648
+ results: list[tuple[str, Outcome]] = []
649
+ for field in custom_field_documents():
650
+ body = {k: v for k, v in field.items() if k != "doctype"}
651
+ existing = admin.list(
652
+ "Custom Field",
653
+ fields=["name", *_REPAIRED_PROPERTIES],
654
+ filters=[["dt", "=", field["dt"]], ["fieldname", "=", field["fieldname"]]],
655
+ limit=1,
656
+ )
657
+ label = f"{field['dt']}.{field['fieldname']}"
658
+ if not existing:
659
+ admin.insert("Custom Field", body)
660
+ results.append((label, Outcome.CREATED))
661
+ continue
662
+ row = existing[0]
663
+ wrong = {k: body[k] for k in _REPAIRED_PROPERTIES if k in body and row.get(k) != body[k]}
664
+ if not wrong:
665
+ results.append((label, Outcome.UNCHANGED))
666
+ continue
667
+ admin.update("Custom Field", str(row["name"]), wrong)
668
+ results.append((label, Outcome.UPDATED))
669
+ return results
670
+
671
+
672
+ BANK = "Mercury"
673
+ BANK_ACCOUNT = "Bank Account"
674
+ _BANK_ACCOUNT_LINKS = ("account", "company", "is_company_account")
675
+
676
+
677
+ def bank_account_documents(context: PostingContext) -> list[dict[str, Any]]:
678
+ """One ERPNext Bank Account per Mercury account named in `[bank_accounts]`.
679
+
680
+ ERPNext names a Bank Account `"<account name> - <bank>"`, so the document
681
+ name the mapping carries is split back into the `account_name` ERPNext
682
+ wants and checked for the bank's suffix -- a name that would come out
683
+ differently is refused here rather than discovered as a dangling link on
684
+ the first Bank Transaction.
685
+
686
+ Each one links the chart account holding that Mercury account's balance
687
+ (`[accounts]`), so a Mercury account without a chart account cannot get a
688
+ Bank Account either.
689
+ """
690
+ suffix = f" - {BANK}"
691
+ documents: list[dict[str, Any]] = []
692
+ seen: set[str] = set()
693
+ for account_id, document_name in context.mapping.bank_accounts.items():
694
+ if document_name in seen:
695
+ continue
696
+ seen.add(document_name)
697
+ number = context.mapping.account_for_mercury_account(account_id)
698
+ if number is None:
699
+ raise PostingError(
700
+ f"[bank_accounts] names Mercury account {account_id!r} which has no [accounts] entry; "
701
+ "a Bank Account needs the chart account holding that balance"
702
+ )
703
+ if not document_name.endswith(suffix) or document_name == suffix:
704
+ raise PostingError(
705
+ f"[bank_accounts] {account_id!r} = {document_name!r}: ERPNext names a Bank Account "
706
+ f"'<account name>{suffix}', so the entry must end with {suffix!r}"
707
+ )
708
+ documents.append({
709
+ "account_name": document_name[: -len(suffix)],
710
+ "bank": BANK,
711
+ "account": context.link(number),
712
+ "is_company_account": 1,
713
+ "company": context.company,
714
+ })
715
+ return documents
716
+
717
+
718
+ def install_bank_accounts(admin: Ledger, context: PostingContext) -> list[tuple[str, Outcome]]:
719
+ """Create the Bank and the Bank Accounts if absent; repair a link that is wrong.
720
+
721
+ Needs an administrator: the books bot's role carries no Bank or Bank
722
+ Account permission. These are master records that a Bank Transaction merely
723
+ points at, and the ingest should not be able to redefine what our bank
724
+ accounts are.
725
+
726
+ An existing Bank Account is matched on its bank and account name rather
727
+ than its document name, so the check does not depend on ERPNext's naming
728
+ rule staying what it is; its chart account, company and company-account
729
+ flag are compared and corrected, because a Bank Account linked to the wrong
730
+ chart account files every transaction against the wrong balance.
731
+ """
732
+ results: list[tuple[str, Outcome]] = []
733
+ if admin.list("Bank", fields=["name"], filters=[["bank_name", "=", BANK]], limit=1):
734
+ results.append((f"Bank {BANK}", Outcome.UNCHANGED))
735
+ else:
736
+ admin.insert("Bank", {"bank_name": BANK})
737
+ results.append((f"Bank {BANK}", Outcome.CREATED))
738
+ for document in bank_account_documents(context):
739
+ label = f"{BANK_ACCOUNT} {document['account_name']} - {BANK}"
740
+ existing = admin.list(
741
+ BANK_ACCOUNT,
742
+ fields=["name", *_BANK_ACCOUNT_LINKS],
743
+ filters=[["bank", "=", BANK], ["account_name", "=", document["account_name"]]],
744
+ limit=1,
745
+ )
746
+ if not existing:
747
+ admin.insert(BANK_ACCOUNT, document)
748
+ results.append((label, Outcome.CREATED))
749
+ continue
750
+ row = existing[0]
751
+ wrong = {k: document[k] for k in _BANK_ACCOUNT_LINKS if row.get(k) != document[k]}
752
+ if not wrong:
753
+ results.append((label, Outcome.UNCHANGED))
754
+ continue
755
+ admin.update(BANK_ACCOUNT, str(row["name"]), wrong)
756
+ results.append((label, Outcome.UPDATED))
757
+ return results