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,130 @@
|
|
|
1
|
+
"""What each Mercury account is in our books.
|
|
2
|
+
|
|
3
|
+
Checked in beside the chart, as `mercury.toml`, because it is our data and not
|
|
4
|
+
the client's. Two tables:
|
|
5
|
+
|
|
6
|
+
- `[accounts]` -- a Mercury account, by the id or the name Mercury reports for
|
|
7
|
+
it, to the chart account holding its balance. Read by
|
|
8
|
+
`runningtab mercury install-bank-accounts`, which links each Bank Account to it.
|
|
9
|
+
- `[bank_accounts]` -- a Mercury account to the ERPNext Bank Account document
|
|
10
|
+
its Bank Transactions attach to. Keyed the same way `[accounts]` is.
|
|
11
|
+
|
|
12
|
+
There is no category table. Every settled transaction becomes a Bank
|
|
13
|
+
Transaction on its own account, and the person reconciling it picks the
|
|
14
|
+
account; Mercury's GL code rides along as a hint. A file that still carries
|
|
15
|
+
`[categories]` or `[posting]` is refused rather than read past, because a table
|
|
16
|
+
that reads as configured while doing nothing is exactly the silence a person
|
|
17
|
+
editing it cannot see.
|
|
18
|
+
|
|
19
|
+
Account entries may be keyed on the name Mercury reports as well as the id, and
|
|
20
|
+
the id wins when both are present. Names stood in while the real ids were
|
|
21
|
+
unknown; the ids are the durable key.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import dataclasses
|
|
27
|
+
import pathlib
|
|
28
|
+
import tomllib
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MappingError(ValueError):
|
|
32
|
+
"""`mercury.toml` is not a mapping we can post with."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _normalise(name: str) -> str:
|
|
36
|
+
"""Account names are compared case-insensitively with runs of whitespace collapsed."""
|
|
37
|
+
return " ".join(name.split()).casefold()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _by_id_or_name(table: dict[str, str], account_id: str, account_name: str | None) -> str | None:
|
|
41
|
+
"""A Mercury account's entry in a table keyed on either its id or its name.
|
|
42
|
+
|
|
43
|
+
The id is looked up first, so filling real ids in later takes precedence
|
|
44
|
+
over the names standing in for them today.
|
|
45
|
+
"""
|
|
46
|
+
by_id = table.get(account_id)
|
|
47
|
+
if by_id is not None:
|
|
48
|
+
return by_id
|
|
49
|
+
if account_name:
|
|
50
|
+
return table.get(_normalise(account_name))
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass(frozen=True)
|
|
55
|
+
class Mapping:
|
|
56
|
+
"""What each Mercury account means in our chart and in ERPNext."""
|
|
57
|
+
|
|
58
|
+
accounts: dict[str, str]
|
|
59
|
+
bank_accounts: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
def account_for_mercury_account(self, account_id: str, account_name: str | None = None) -> str | None:
|
|
62
|
+
"""The chart account holding a Mercury account's balance."""
|
|
63
|
+
return _by_id_or_name(self.accounts, account_id, account_name)
|
|
64
|
+
|
|
65
|
+
def bank_account_for(self, account_id: str, account_name: str | None = None) -> str | None:
|
|
66
|
+
"""The ERPNext Bank Account document a Mercury account's activity attaches to.
|
|
67
|
+
|
|
68
|
+
One per Mercury account, never one for all of them: the ingest reads
|
|
69
|
+
checking, savings and the card, and a Bank Transaction posted against
|
|
70
|
+
another account's Bank Account is both wrong in the ledger and invisible
|
|
71
|
+
to the reconciliation the doctype exists for.
|
|
72
|
+
"""
|
|
73
|
+
return _by_id_or_name(self.bank_accounts, account_id, account_name)
|
|
74
|
+
|
|
75
|
+
def knows_account(self, account_id: str, account_name: str | None = None) -> bool:
|
|
76
|
+
"""Whether this Mercury account has a chart account. An unknown one is never guessed at."""
|
|
77
|
+
return self.account_for_mercury_account(account_id, account_name) is not None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _table(data: dict[str, object], key: str) -> dict[str, str]:
|
|
81
|
+
value = data.get(key, {})
|
|
82
|
+
if not isinstance(value, dict):
|
|
83
|
+
raise MappingError(f"[{key}] must be a table of string values")
|
|
84
|
+
out: dict[str, str] = {}
|
|
85
|
+
for name, number in value.items():
|
|
86
|
+
if not isinstance(number, str):
|
|
87
|
+
raise MappingError(f"[{key}] {name!r} must map to an account number as a string, got {number!r}")
|
|
88
|
+
out[str(name)] = number
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _account_keys(table: dict[str, str]) -> dict[str, str]:
|
|
93
|
+
"""A Mercury-account-keyed table, readable by both id and normalised name.
|
|
94
|
+
|
|
95
|
+
Keys are left as written as well as normalised, because one of them is a
|
|
96
|
+
Mercury account id -- opaque, and it must match exactly.
|
|
97
|
+
"""
|
|
98
|
+
return {**table, **{_normalise(name): value for name, value in table.items()}}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_mapping(text: str) -> Mapping:
|
|
102
|
+
"""Parse `mercury.toml` text. Account numbers are strings, matching the chart."""
|
|
103
|
+
data = tomllib.loads(text)
|
|
104
|
+
if "categories" in data:
|
|
105
|
+
raise MappingError(
|
|
106
|
+
"[categories] is no longer read: every settled transaction becomes a Bank Transaction on its "
|
|
107
|
+
"own account, with Mercury's GL code carried as a hint, and the person reconciling picks the "
|
|
108
|
+
"account; remove the table"
|
|
109
|
+
)
|
|
110
|
+
if "posting" in data:
|
|
111
|
+
raise MappingError("[posting] is no longer read: nothing posts to suspense; remove the table")
|
|
112
|
+
return Mapping(
|
|
113
|
+
accounts=_account_keys(_table(data, "accounts")),
|
|
114
|
+
bank_accounts=_account_keys(_table(data, "bank_accounts")),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def load_mapping(path: pathlib.Path) -> Mapping:
|
|
119
|
+
"""Load the mapping at `path`: the books directory's `mercury.toml`, or a `--mercury-mapping` file."""
|
|
120
|
+
return parse_mapping(path.read_text())
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate_against_chart(mapping: Mapping, chart_numbers: set[str]) -> list[str]:
|
|
124
|
+
"""Every account number the mapping names that the chart does not have.
|
|
125
|
+
|
|
126
|
+
Cheap and worth doing at load: a typo'd account number would otherwise
|
|
127
|
+
surface as an ERPNext link error when the Bank Account is installed, naming
|
|
128
|
+
a document rather than the line of `mercury.toml` that is wrong.
|
|
129
|
+
"""
|
|
130
|
+
return sorted(set(mapping.accounts.values()) - chart_numbers)
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Mercury payloads as typed records.
|
|
2
|
+
|
|
3
|
+
Modelled from observed payloads rather than from the published reference: the
|
|
4
|
+
reference describes `transactionCategoryData` while responses carry
|
|
5
|
+
`categoryData` and `mercuryCategory`. Where a field name here is an assumption
|
|
6
|
+
rather than something the investigation recorded, it says so in a comment and
|
|
7
|
+
the parser accepts every spelling it might have -- `tests/capture_mercury_fixtures.py`
|
|
8
|
+
settles which is real.
|
|
9
|
+
|
|
10
|
+
Money is inconsistent at the source: transactions carry floats in dollars,
|
|
11
|
+
card budgets carry integers in cents. Everything becomes `Decimal` here, at the
|
|
12
|
+
parse boundary, and via `str()` -- `Decimal(0.1)` is not `0.1`. No float reaches
|
|
13
|
+
the ledger.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import datetime as dt
|
|
20
|
+
import enum
|
|
21
|
+
import re
|
|
22
|
+
import urllib.parse
|
|
23
|
+
from decimal import Decimal
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MercuryPayloadError(ValueError):
|
|
28
|
+
"""A payload is missing something the ingest cannot proceed without."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Status(enum.StrEnum):
|
|
32
|
+
"""Mercury's transaction status enum.
|
|
33
|
+
|
|
34
|
+
Wider than the four the sandbox exhibited; `REVERSED` is the one that needs
|
|
35
|
+
handling rather than a skip.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
PENDING = "pending"
|
|
39
|
+
SENT = "sent"
|
|
40
|
+
CANCELLED = "cancelled"
|
|
41
|
+
FAILED = "failed"
|
|
42
|
+
REVERSED = "reversed"
|
|
43
|
+
BLOCKED = "blocked"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Statuses `currentBalance` leaves out. Measured exactly on two sandbox
|
|
47
|
+
# accounts: one off by 80.00 with a failed -70.00 and a pending -10.00, another
|
|
48
|
+
# off by 3.50 with a pending -3.50. `cancelled`, `reversed`, and `blocked` never
|
|
49
|
+
# occurred there, so which side of this line they fall on is unverified -- they
|
|
50
|
+
# are counted, which is what "the balance is the bank's number" implies, but a
|
|
51
|
+
# real capture is what settles it.
|
|
52
|
+
UNBALANCED_STATUSES: frozenset[Status] = frozenset({Status.PENDING, Status.FAILED})
|
|
53
|
+
|
|
54
|
+
# The only status that posts. Everything unsettled would put the ledger out of
|
|
55
|
+
# step with `currentBalance` by construction.
|
|
56
|
+
POSTABLE_STATUSES: frozenset[Status] = frozenset({Status.SENT})
|
|
57
|
+
|
|
58
|
+
# Every status string somebody has decided the accounting meaning of. A status
|
|
59
|
+
# outside this set is not merely unpostable: which side of the balance line it
|
|
60
|
+
# falls on and whether it should reach the ledger are both undecided, so it
|
|
61
|
+
# needs a person rather than a skip.
|
|
62
|
+
KNOWN_STATUSES: frozenset[str] = frozenset(status.value for status in Status)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CategorySource(enum.StrEnum):
|
|
66
|
+
"""Which of Mercury's two category fields supplied the category.
|
|
67
|
+
|
|
68
|
+
The precedence is a person's choice first, then Mercury's automatic
|
|
69
|
+
assignment. A draft records which one it came from, so a reviewer can see at
|
|
70
|
+
a glance which entries had no human judgement behind them.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
CUSTOM = "custom"
|
|
74
|
+
MERCURY = "mercury"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclasses.dataclass(frozen=True)
|
|
78
|
+
class Allocation:
|
|
79
|
+
"""One `glAllocations` row: what Mercury's own rule engine derived.
|
|
80
|
+
|
|
81
|
+
Never the classification key -- GL fields emit no event -- but worth reading
|
|
82
|
+
as corroboration once the GL code list is our account names.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
amount: Decimal
|
|
86
|
+
gl_code_name: str | None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# The only stable name an attachment has. Mercury's presigned URL ends
|
|
90
|
+
# `attachment/<uuid>.<ext>`; the query string carries the signature and changes
|
|
91
|
+
# on every read, and the row carries no id field of its own. Matched on the
|
|
92
|
+
# UUID shape rather than on the `attachment/` segment before it, because the
|
|
93
|
+
# shape is what makes the name a name -- a path that reorganises around it
|
|
94
|
+
# still identifies the same file.
|
|
95
|
+
_ATTACHMENT_ID = re.compile(
|
|
96
|
+
r"^(?P<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\.[^.]+)?$",
|
|
97
|
+
re.IGNORECASE,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclasses.dataclass(frozen=True)
|
|
102
|
+
class Attachment:
|
|
103
|
+
"""One receipt a person uploaded to Mercury for this transaction.
|
|
104
|
+
|
|
105
|
+
The URL is presigned and expires twelve hours after Mercury minted it, so
|
|
106
|
+
it is a thing to use inside the run that read it and never a thing to
|
|
107
|
+
store. It is kept out of the repr for the reason the bank token is: this
|
|
108
|
+
record turns up in log lines and pytest assertion output, and the signature
|
|
109
|
+
in that query string is a credential for the file.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
file_name: str
|
|
113
|
+
url: str = dataclasses.field(repr=False)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def attachment_id(self) -> str | None:
|
|
117
|
+
"""The UUID naming this attachment, or None when the URL path carries none.
|
|
118
|
+
|
|
119
|
+
The identity a run compares against what is already attached in the
|
|
120
|
+
ledger. None is not an error here -- it is a fact about the URL that
|
|
121
|
+
the caller reports, because an attachment with no identity would be
|
|
122
|
+
uploaded again by every later run.
|
|
123
|
+
"""
|
|
124
|
+
last = urllib.parse.urlsplit(self.url).path.rsplit("/", 1)[-1]
|
|
125
|
+
match = _ATTACHMENT_ID.match(urllib.parse.unquote(last))
|
|
126
|
+
return match["id"].lower() if match else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclasses.dataclass(frozen=True)
|
|
130
|
+
class Category:
|
|
131
|
+
"""One row of `GET /categories`: a UUID that survives renames, a name, three visibility flags.
|
|
132
|
+
|
|
133
|
+
The flags decide which pickers Mercury offers the category in -- a card
|
|
134
|
+
charge, an expense reimbursement, and everything else (wires, ACH,
|
|
135
|
+
transfers). All three are required by the API's own schema and all three
|
|
136
|
+
are required here: a flag guessed rather than read would make
|
|
137
|
+
`runningtab.mercury.categories` propose an edit that changes nothing, or miss
|
|
138
|
+
one that matters.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
id: str
|
|
142
|
+
name: str
|
|
143
|
+
visible_for_card_spend: bool
|
|
144
|
+
visible_for_reimbursements: bool
|
|
145
|
+
visible_for_other: bool
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclasses.dataclass(frozen=True)
|
|
149
|
+
class Account:
|
|
150
|
+
"""A deposit account from `GET /accounts`.
|
|
151
|
+
|
|
152
|
+
Credit accounts are not here: `/accounts` omits them entirely and `GET
|
|
153
|
+
/credit` returns them with a different, much smaller schema. Two account
|
|
154
|
+
models, not one with optional fields -- `CreditAccount` is the other.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
id: str
|
|
158
|
+
name: str
|
|
159
|
+
current_balance: Decimal
|
|
160
|
+
kind: str | None = None
|
|
161
|
+
# What a full transaction read starts from. The endpoint's own default is
|
|
162
|
+
# the last 30 days, so "every transaction on this account" is only sayable
|
|
163
|
+
# with a date, and this is the only one the account itself supplies.
|
|
164
|
+
created_at: dt.datetime | None = None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def label(self) -> str:
|
|
168
|
+
"""What a person reading a report calls this account."""
|
|
169
|
+
return self.name or self.id
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclasses.dataclass(frozen=True)
|
|
173
|
+
class CreditAccount:
|
|
174
|
+
"""A credit account from `GET /credit`. Has no name; the IO card is one of these."""
|
|
175
|
+
|
|
176
|
+
id: str
|
|
177
|
+
status: str | None
|
|
178
|
+
current_balance: Decimal
|
|
179
|
+
available_balance: Decimal | None = None
|
|
180
|
+
created_at: dt.datetime | None = None
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def label(self) -> str:
|
|
184
|
+
"""What a person reading a report calls this account. It has no name to use."""
|
|
185
|
+
return f"credit account {self.id[:8]}"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclasses.dataclass(frozen=True)
|
|
189
|
+
class Transaction:
|
|
190
|
+
"""One transaction, with the account it was read from attached.
|
|
191
|
+
|
|
192
|
+
`account_id` is not part of the payload. It comes from the request that
|
|
193
|
+
fetched the row, because pairing the two halves of an internal transfer
|
|
194
|
+
needs to know which side is which and `counterpartyId` alone cannot say.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
id: str
|
|
198
|
+
account_id: str
|
|
199
|
+
amount: Decimal
|
|
200
|
+
status: Status
|
|
201
|
+
raw_status: str
|
|
202
|
+
kind: str
|
|
203
|
+
created_at: dt.datetime
|
|
204
|
+
posted_at: dt.datetime | None = None
|
|
205
|
+
counterparty_id: str | None = None
|
|
206
|
+
counterparty_name: str | None = None
|
|
207
|
+
bank_description: str | None = None
|
|
208
|
+
external_memo: str | None = None
|
|
209
|
+
note: str | None = None
|
|
210
|
+
dashboard_link: str | None = None
|
|
211
|
+
custom_category: str | None = None
|
|
212
|
+
# The custom category's UUID, which is what `GET /categories` keys on. The
|
|
213
|
+
# name would be the obvious handle and is the wrong one: two categories can
|
|
214
|
+
# wear one name, and a rename moves the name without moving the row. Only
|
|
215
|
+
# this answers "does any transaction carry that category", which is the
|
|
216
|
+
# question `runningtab mercury categories apply --prune` must get right before
|
|
217
|
+
# it deletes anything.
|
|
218
|
+
custom_category_id: str | None = None
|
|
219
|
+
mercury_category: str | None = None
|
|
220
|
+
gl_code_name: str | None = None
|
|
221
|
+
allocations: tuple[Allocation, ...] = ()
|
|
222
|
+
# The receipts a person uploaded against this transaction. Read on every
|
|
223
|
+
# run rather than in response to an event: the attachment list emits none,
|
|
224
|
+
# for the same reason the GL code and the note do not.
|
|
225
|
+
attachments: tuple[Attachment, ...] = ()
|
|
226
|
+
# The ACH trace number, which Mercury's statement export carries as
|
|
227
|
+
# `Tracking ID`. Absent on anything that never went through the ACH
|
|
228
|
+
# network -- a card charge has none. Observed as a string on a real
|
|
229
|
+
# production row; never seen on a sandbox one.
|
|
230
|
+
tracking_number: str | None = None
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def category(self) -> str | None:
|
|
234
|
+
"""A person's choice, else Mercury's automatic one. Parsed as a fact about the row; nothing routes on it."""
|
|
235
|
+
return self.custom_category or self.mercury_category
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def gl_code(self) -> str | None:
|
|
239
|
+
"""The GL code a person chose for this row: Mercury's GL code, else their custom category.
|
|
240
|
+
|
|
241
|
+
The custom categories are the chart's GL-code labels word for word
|
|
242
|
+
(`runningtab mercury categories apply` mirrors them), and Mercury's dropdown
|
|
243
|
+
offers nothing else, so picking a category is coding the row. Coding at
|
|
244
|
+
the card reader sets the category and leaves `generalLedgerCodeName`
|
|
245
|
+
empty. Mercury's automatic category is not a GL code: nobody chose it,
|
|
246
|
+
and it names Mercury's taxonomy rather than the chart.
|
|
247
|
+
"""
|
|
248
|
+
return self.gl_code_name or self.custom_category
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def category_source(self) -> CategorySource | None:
|
|
252
|
+
"""Which field the category came from, or None when the transaction carries neither."""
|
|
253
|
+
if self.custom_category:
|
|
254
|
+
return CategorySource.CUSTOM
|
|
255
|
+
if self.mercury_category:
|
|
256
|
+
return CategorySource.MERCURY
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
@property
|
|
260
|
+
def status_recognised(self) -> bool:
|
|
261
|
+
"""Whether the status is one this ingest has accounting semantics for.
|
|
262
|
+
|
|
263
|
+
Asked of `raw_status` rather than of `status`: an unrecognised status
|
|
264
|
+
parses to `BLOCKED`, which makes it indistinguishable from a real one
|
|
265
|
+
at the enum and would let it be handled as though somebody had decided
|
|
266
|
+
what it means.
|
|
267
|
+
"""
|
|
268
|
+
return self.raw_status in KNOWN_STATUSES
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def counts_toward_balance(self) -> bool:
|
|
272
|
+
"""Whether `currentBalance` includes this row."""
|
|
273
|
+
return self.status not in UNBALANCED_STATUSES
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def is_postable(self) -> bool:
|
|
277
|
+
"""Whether this row is settled enough to reach the ledger at all."""
|
|
278
|
+
return self.status in POSTABLE_STATUSES
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def accounting_date(self) -> dt.date:
|
|
282
|
+
"""The date a posting carries.
|
|
283
|
+
|
|
284
|
+
`postedAt` when present, else `createdAt`. The design left the choice
|
|
285
|
+
open -- the two differ by a month on some sandbox rows -- and this picks
|
|
286
|
+
`postedAt` because the ledger is describing settled activity, which is
|
|
287
|
+
what `currentBalance` counts.
|
|
288
|
+
"""
|
|
289
|
+
return (self.posted_at or self.created_at).date()
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def allocated_total(self) -> Decimal:
|
|
293
|
+
"""The sum of the GL allocations. Compared against `amount`, never assumed equal to it."""
|
|
294
|
+
return sum((a.amount for a in self.allocations), Decimal(0))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Operation(enum.StrEnum):
|
|
298
|
+
"""What an event says happened to its resource."""
|
|
299
|
+
|
|
300
|
+
CREATE = "create"
|
|
301
|
+
UPDATE = "update"
|
|
302
|
+
DELETE = "delete"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclasses.dataclass(frozen=True)
|
|
306
|
+
class Event:
|
|
307
|
+
"""One row of the event log, which is also exactly what a webhook delivers.
|
|
308
|
+
|
|
309
|
+
The applier reads only the envelope -- which resource changed, at which
|
|
310
|
+
version -- and re-reads the resource itself from the transactions endpoint.
|
|
311
|
+
That is deliberate: the event body is a JSON Merge Patch whose exact
|
|
312
|
+
envelope key the investigation never recorded, and re-reading is both
|
|
313
|
+
cheaper to be sure of and correct for a pending charge that settled.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
id: str
|
|
317
|
+
resource_type: str
|
|
318
|
+
resource_id: str
|
|
319
|
+
operation: Operation
|
|
320
|
+
resource_version: int | None = None
|
|
321
|
+
changed_paths: tuple[str, ...] = ()
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def is_transaction(self) -> bool:
|
|
325
|
+
"""Whether this event is about a transaction rather than an account balance."""
|
|
326
|
+
return self.resource_type.lower() == "transaction"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _require(payload: dict[str, Any], *names: str) -> Any:
|
|
330
|
+
"""The first present, non-null value among `names`, or a loud failure.
|
|
331
|
+
|
|
332
|
+
Several field names in this file are recorded observations and several are
|
|
333
|
+
assumptions. Rather than guess silently and produce a wrong record, an
|
|
334
|
+
absent required field raises and names both what was wanted and what the
|
|
335
|
+
payload actually carried.
|
|
336
|
+
"""
|
|
337
|
+
for name in names:
|
|
338
|
+
value = payload.get(name)
|
|
339
|
+
if value is not None:
|
|
340
|
+
return value
|
|
341
|
+
wanted = " / ".join(names)
|
|
342
|
+
raise MercuryPayloadError(f"payload has no {wanted}; keys present: {sorted(payload)}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _optional(payload: dict[str, Any], *names: str) -> Any:
|
|
346
|
+
for name in names:
|
|
347
|
+
value = payload.get(name)
|
|
348
|
+
if value is not None:
|
|
349
|
+
return value
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def money(value: Any) -> Decimal:
|
|
354
|
+
"""A Mercury amount as an exact `Decimal`.
|
|
355
|
+
|
|
356
|
+
Goes through `str()` because the wire value is a JSON float: `Decimal(0.1)`
|
|
357
|
+
carries the binary approximation and would drift a cent at a time through a
|
|
358
|
+
ledger.
|
|
359
|
+
"""
|
|
360
|
+
if isinstance(value, Decimal):
|
|
361
|
+
return value
|
|
362
|
+
return Decimal(str(value))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def timestamp(value: Any) -> dt.datetime:
|
|
366
|
+
"""An ISO 8601 instant as an aware UTC datetime.
|
|
367
|
+
|
|
368
|
+
Mercury sends `Z`; `datetime.fromisoformat` accepts it from 3.11 on. A naive
|
|
369
|
+
value is read as UTC rather than as local time, because a bank timestamp is
|
|
370
|
+
never local.
|
|
371
|
+
"""
|
|
372
|
+
parsed = value if isinstance(value, dt.datetime) else dt.datetime.fromisoformat(str(value))
|
|
373
|
+
if parsed.tzinfo is None:
|
|
374
|
+
return parsed.replace(tzinfo=dt.UTC)
|
|
375
|
+
return parsed.astimezone(dt.UTC)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _optional_timestamp(payload: dict[str, Any], *names: str) -> dt.datetime | None:
|
|
379
|
+
value = _optional(payload, *names)
|
|
380
|
+
return None if value is None else timestamp(value)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def parse_status(value: Any) -> tuple[Status, str]:
|
|
384
|
+
"""A status string as `(Status, raw)`.
|
|
385
|
+
|
|
386
|
+
An unrecognised status is not an error -- Mercury may add one -- but it is
|
|
387
|
+
never postable: it falls back to `BLOCKED`, whose handling is "post
|
|
388
|
+
nothing", and the raw string is kept so a report can name it.
|
|
389
|
+
"""
|
|
390
|
+
raw = str(value)
|
|
391
|
+
try:
|
|
392
|
+
return Status(raw), raw
|
|
393
|
+
except ValueError:
|
|
394
|
+
return Status.BLOCKED, raw
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def parse_allocations(payload: dict[str, Any]) -> tuple[Allocation, ...]:
|
|
398
|
+
rows = payload.get("glAllocations") or ()
|
|
399
|
+
return tuple(Allocation(amount=money(row.get("amount", 0)), gl_code_name=row.get("glCodeName")) for row in rows)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def parse_attachments(payload: dict[str, Any]) -> tuple[Attachment, ...]:
|
|
403
|
+
"""The `attachments` rows as records.
|
|
404
|
+
|
|
405
|
+
`url` goes through `_require`: an attachment row without one names a
|
|
406
|
+
receipt nothing can fetch, and reading past it would drop a person's
|
|
407
|
+
document without saying so. Raising here surfaces as a
|
|
408
|
+
`MalformedResponseError` naming the endpoint, which is the loud failure
|
|
409
|
+
this would otherwise be the silent version of.
|
|
410
|
+
|
|
411
|
+
`fileName` falls back to the last path segment, which is the file's own
|
|
412
|
+
`<uuid>.<ext>` -- a poor name for a person to read and a real one.
|
|
413
|
+
"""
|
|
414
|
+
rows = payload.get("attachments") or ()
|
|
415
|
+
attachments = []
|
|
416
|
+
for row in rows:
|
|
417
|
+
url = str(_require(row, "url"))
|
|
418
|
+
name = _optional(row, "fileName") or urllib.parse.urlsplit(url).path.rsplit("/", 1)[-1]
|
|
419
|
+
attachments.append(Attachment(file_name=str(name), url=url))
|
|
420
|
+
return tuple(attachments)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _parse_categories(payload: dict[str, Any]) -> tuple[str | None, str | None, str | None]:
|
|
424
|
+
"""`(customCategory name, customCategory id, mercuryCategory name)`.
|
|
425
|
+
|
|
426
|
+
Two shapes are accepted for the automatic category because the record is
|
|
427
|
+
ambiguous: the sandbox survey counted `mercuryCategory` as a top-level field
|
|
428
|
+
while the Events field set names it `categoryData.mercuryCategory`. Both are
|
|
429
|
+
read; a live capture settles which one a real account sends.
|
|
430
|
+
|
|
431
|
+
The custom category is **flat**: `categoryData` is the category object
|
|
432
|
+
itself, carrying `id`, `name` and the three visibility flags -- measured
|
|
433
|
+
against the real organisation on 2026-09-21, and the reason the nested
|
|
434
|
+
`categoryData.customCategory` the ingest fixtures were built around is read
|
|
435
|
+
as a fallback rather than as the shape. Reading the id wrongly would be
|
|
436
|
+
worse than reading it not at all: `--prune` deletes a category no
|
|
437
|
+
transaction carries, so an id that never parses makes every category look
|
|
438
|
+
uncarried. `runningtab.mercury.categories` guards that separately, by refusing to
|
|
439
|
+
prune at all when transactions name a category and none yields an id.
|
|
440
|
+
"""
|
|
441
|
+
category_data = payload.get("categoryData") or {}
|
|
442
|
+
nested = category_data.get("customCategory") or {}
|
|
443
|
+
custom = nested.get("name") or category_data.get("name")
|
|
444
|
+
custom_id = nested.get("id") or category_data.get("id")
|
|
445
|
+
mercury = category_data.get("mercuryCategory") or payload.get("mercuryCategory")
|
|
446
|
+
if isinstance(mercury, dict):
|
|
447
|
+
mercury = mercury.get("name")
|
|
448
|
+
return custom, None if custom_id is None else str(custom_id), mercury
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def parse_transaction(payload: dict[str, Any], account_id: str) -> Transaction:
|
|
452
|
+
"""One transaction payload, with the account it was read from."""
|
|
453
|
+
status, raw_status = parse_status(_require(payload, "status"))
|
|
454
|
+
custom_category, custom_category_id, mercury_category = _parse_categories(payload)
|
|
455
|
+
return Transaction(
|
|
456
|
+
id=str(_require(payload, "id")),
|
|
457
|
+
account_id=account_id,
|
|
458
|
+
amount=money(_require(payload, "amount")),
|
|
459
|
+
status=status,
|
|
460
|
+
raw_status=raw_status,
|
|
461
|
+
kind=str(_optional(payload, "kind") or "unknown"),
|
|
462
|
+
created_at=timestamp(_require(payload, "createdAt")),
|
|
463
|
+
posted_at=_optional_timestamp(payload, "postedAt"),
|
|
464
|
+
counterparty_id=_optional(payload, "counterpartyId"),
|
|
465
|
+
counterparty_name=_optional(payload, "counterpartyName"),
|
|
466
|
+
bank_description=_optional(payload, "bankDescription"),
|
|
467
|
+
external_memo=_optional(payload, "externalMemo"),
|
|
468
|
+
note=_optional(payload, "note"),
|
|
469
|
+
dashboard_link=_optional(payload, "dashboardLink"),
|
|
470
|
+
custom_category=custom_category,
|
|
471
|
+
custom_category_id=custom_category_id,
|
|
472
|
+
mercury_category=mercury_category,
|
|
473
|
+
gl_code_name=_optional(payload, "generalLedgerCodeName"),
|
|
474
|
+
allocations=parse_allocations(payload),
|
|
475
|
+
attachments=parse_attachments(payload),
|
|
476
|
+
tracking_number=_optional_string(payload, "trackingNumber"),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _optional_string(payload: dict[str, Any], *names: str) -> str | None:
|
|
481
|
+
"""An optional field as a string, or None. Kept as text: a trace number with a leading zero is not a number."""
|
|
482
|
+
value = _optional(payload, *names)
|
|
483
|
+
return None if value is None else str(value)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def parse_account(payload: dict[str, Any]) -> Account:
|
|
487
|
+
return Account(
|
|
488
|
+
id=str(_require(payload, "id")),
|
|
489
|
+
name=str(_optional(payload, "name", "nickname") or ""),
|
|
490
|
+
current_balance=money(_require(payload, "currentBalance")),
|
|
491
|
+
kind=_optional(payload, "kind", "type"),
|
|
492
|
+
created_at=_optional_timestamp(payload, "createdAt"),
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def parse_credit_account(payload: dict[str, Any]) -> CreditAccount:
|
|
497
|
+
available = _optional(payload, "availableBalance")
|
|
498
|
+
return CreditAccount(
|
|
499
|
+
id=str(_require(payload, "id")),
|
|
500
|
+
status=_optional(payload, "status"),
|
|
501
|
+
current_balance=money(_require(payload, "currentBalance")),
|
|
502
|
+
available_balance=None if available is None else money(available),
|
|
503
|
+
created_at=_optional_timestamp(payload, "createdAt"),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def parse_category(payload: dict[str, Any]) -> Category:
|
|
508
|
+
"""One category payload. Every visibility flag is required, never defaulted.
|
|
509
|
+
|
|
510
|
+
`_require` rejects a missing or null flag but accepts `False`, which is a
|
|
511
|
+
real answer. Defaulting an absent flag would silently decide something the
|
|
512
|
+
bank was never asked.
|
|
513
|
+
"""
|
|
514
|
+
return Category(
|
|
515
|
+
id=str(_require(payload, "id")),
|
|
516
|
+
name=str(_require(payload, "name")),
|
|
517
|
+
visible_for_card_spend=bool(_require(payload, "visibleForCardSpend")),
|
|
518
|
+
visible_for_reimbursements=bool(_require(payload, "visibleForReimbursements")),
|
|
519
|
+
visible_for_other=bool(_require(payload, "visibleForOther")),
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def parse_event(payload: dict[str, Any]) -> Event:
|
|
524
|
+
"""One event envelope.
|
|
525
|
+
|
|
526
|
+
`resourceVersion` is optional here, not because Mercury omits it -- it is
|
|
527
|
+
documented as an int64 starting at 1 -- but because an event that arrives
|
|
528
|
+
without one must still be applied exactly once rather than dropped. Dedup on
|
|
529
|
+
the event id is what makes that safe.
|
|
530
|
+
"""
|
|
531
|
+
version = _optional(payload, "resourceVersion")
|
|
532
|
+
operation = str(_require(payload, "operationType")).lower()
|
|
533
|
+
try:
|
|
534
|
+
parsed_operation = Operation(operation)
|
|
535
|
+
except ValueError as exc:
|
|
536
|
+
raise MercuryPayloadError(f"unknown operationType {operation!r} on event {payload.get('id')!r}") from exc
|
|
537
|
+
return Event(
|
|
538
|
+
id=str(_require(payload, "id")),
|
|
539
|
+
resource_type=str(_require(payload, "resourceType")),
|
|
540
|
+
resource_id=str(_require(payload, "resourceId")),
|
|
541
|
+
operation=parsed_operation,
|
|
542
|
+
resource_version=None if version is None else int(version),
|
|
543
|
+
changed_paths=tuple(payload.get("changedPaths") or ()),
|
|
544
|
+
)
|