runningtab 0.1.0.dev3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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
|