qbd-agent-kit 0.2.0__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.
@@ -0,0 +1,130 @@
1
+ """Errors from QuickBooks and from the kit, each with the one thing a merchant can do about it."""
2
+ from __future__ import annotations
3
+
4
+ from .jsutil import num_str
5
+
6
+ # What to do for the connection errors people actually hit. QuickBooks' own message is always shown too.
7
+ HINTS = {
8
+ "0x80040154": "The QuickBooks SDK is not registered for this program. QuickBooks Desktop must be installed on this PC; "
9
+ "if it is, run `qbd doctor` to see what is missing.",
10
+ "0x800401F3": "The QuickBooks SDK is not installed on this PC. Run qbd on the PC where QuickBooks Desktop is installed.",
11
+ "0x80040401": "Open QuickBooks Desktop on this PC, open the company file, then try again.",
12
+ "0x80040403": "Check the company file path in QBD_COMPANY_FILE, or leave it empty to use the file open in QuickBooks.",
13
+ "0x80040408": "QuickBooks could not be started. Open QuickBooks Desktop and the company file yourself, then try again.",
14
+ "0x80040410": "The company file is open in a different mode than requested. Switch QuickBooks to multi-user mode "
15
+ "(File > Switch to Multi-user Mode) or close other apps connected to it.",
16
+ "0x80040414": "A window or message box is open in QuickBooks. Close it (look for a dialog behind other windows), then try again. "
17
+ "If it is the 'Application permission' dialog, answer it as described in docs/setup.md.",
18
+ "0x80040416": "No company file is open. Open the company file in QuickBooks, or set QBD_COMPANY_FILE to its full path.",
19
+ "0x80040418": "This app has not been allowed into this company file yet. Log into QuickBooks as the Admin user in single-user mode "
20
+ "and run `qbd authorize`.",
21
+ "0x8004041A": "This app is not allowed into this company file. In QuickBooks: Edit > Preferences > Integrated Applications > "
22
+ "Company Preferences, select the app and allow access.",
23
+ "0x8004041D": "This app is not allowed to log in automatically. In QuickBooks: Edit > Preferences > Integrated Applications > "
24
+ "Company Preferences > Properties, tick 'Allow this application to login automatically'.",
25
+ "0x80040420": "Access was refused in QuickBooks. Run `qbd authorize` and choose a 'Yes' option in the dialog.",
26
+ "0x80040422": "Another app holds the company file in single-user mode. Close it or switch QuickBooks to multi-user mode.",
27
+ "0x80040424": "QuickBooks is still starting up. Wait until the company file is fully open, then try again.",
28
+ }
29
+
30
+ # Connection errors only a person at the PC can clear: a dialog, QuickBooks closed, a permission.
31
+ NEEDS_PERSON = {
32
+ "0x80040401", "0x80040403", "0x80040408", "0x80040410", "0x80040414", "0x80040416", "0x80040418",
33
+ "0x8004041A", "0x8004041D", "0x80040420", "0x80040422", "0x80040424", "no-session",
34
+ }
35
+
36
+ STATUS_HINTS = {
37
+ 1030: "This edition or country version of QuickBooks does not support this request; some exist only in the US edition.",
38
+ 3100: "A record with this name already exists. Search for it and update it instead of adding a new one.",
39
+ 3120: "The record was not found. Search first and use the exact ListID, TxnID or full name it returns.",
40
+ 3140: "A referenced customer, vendor, item or account does not exist. Search for the exact name first.",
41
+ 3180: "QuickBooks could not save the record. Check that the record is not open in QuickBooks and the values are valid.",
42
+ 3200: "The record changed after it was read. Read it again and redo the change.",
43
+ 3220: "The feature is turned off in this company file, or the QuickBooks user this app logs in as may not use it. QuickBooks' message says which.",
44
+ 3250: "This feature is turned off in this company file or not available in this edition of QuickBooks.",
45
+ 3260: "The QuickBooks user this app logs in as is not allowed to do this. Change it in Integrated Applications preferences.",
46
+ }
47
+
48
+
49
+ def _hresult(code: str) -> str:
50
+ return "0x" + code[2:].upper() if code[:2].lower() == "0x" else code
51
+
52
+
53
+ class QbConnectionError(Exception):
54
+ """The QuickBooks SDK connection failed; code is the HRESULT such as 0x80040416, or a word such as timeout."""
55
+
56
+ def __init__(self, code: str, message: str, hint: str | None = None):
57
+ super().__init__(message)
58
+ self.code = _hresult(code)
59
+ self.message = message
60
+ self.hint = hint if hint is not None else HINTS.get(self.code)
61
+
62
+ @property
63
+ def needs_person(self) -> bool:
64
+ return self.code in NEEDS_PERSON
65
+
66
+ def __str__(self) -> str:
67
+ return f"{self.message}{chr(10) + 'What to do: ' + self.hint if self.hint else ''} ({self.code})"
68
+
69
+
70
+ class QbTimeoutError(QbConnectionError):
71
+ """QuickBooks did not answer in time. The request may still be running inside QuickBooks."""
72
+
73
+ def __init__(self, seconds: float, message: str | None = None):
74
+ super().__init__(
75
+ "timeout",
76
+ message or f"QuickBooks did not answer within {num_str(seconds)} s. The request may still be running inside QuickBooks.",
77
+ "Wait until QuickBooks is responsive again, then retry with a smaller date range or limit. If QuickBooks stays "
78
+ "frozen, it may have stopped working: close it (with Task Manager if needed) and open the company file again.",
79
+ )
80
+
81
+
82
+ class QbNoSessionError(QbConnectionError):
83
+ """QuickBooks did not open a session in time, most likely because a dialog is waiting for a person."""
84
+
85
+ def __init__(self, seconds: float):
86
+ super().__init__(
87
+ "no-session",
88
+ f"QuickBooks did not open a session within {num_str(seconds)} s. It is most likely waiting for someone at this "
89
+ "PC to answer a dialog, such as 'Application permission' the first time qbd connects.",
90
+ "Find the QuickBooks dialog on this PC; it may be behind other windows. For 'Application permission' choose "
91
+ "'Yes, whenever my QuickBooks company file is open' and click Continue, then run the command again. "
92
+ "`qbd authorize` waits 10 minutes for the answer.",
93
+ )
94
+
95
+
96
+ class QbStatusError(Exception):
97
+ """A qbXML status error, e.g. 3120 "object not found"."""
98
+
99
+ def __init__(self, status_code: int, message: str):
100
+ super().__init__(message)
101
+ self.status_code = status_code
102
+ self.message = message
103
+ self.hint = STATUS_HINTS.get(status_code)
104
+
105
+ def __str__(self) -> str:
106
+ return f"QuickBooks status {self.status_code}: {self.message}{chr(10) + 'What to do: ' + self.hint if self.hint else ''}"
107
+
108
+
109
+ class QbxmlInputError(Exception):
110
+ """A request that does not fit the qbXML schema."""
111
+
112
+
113
+ class QbxmlParseError(Exception):
114
+ """QuickBooks sent something that is not readable qbXML."""
115
+
116
+
117
+ class ToolError(Exception):
118
+ """A request the kit refuses, with a message that says what to change."""
119
+
120
+
121
+ class WriteUnconfirmed(Exception):
122
+ """A change was sent but QuickBooks did not confirm it, so it may or may not be saved."""
123
+
124
+
125
+ class InvalidArguments(Exception):
126
+ """Arguments that do not fit a command; issues are (path, message) pairs."""
127
+
128
+ def __init__(self, issues: list[tuple[str, str]]):
129
+ self.issues = issues
130
+ super().__init__("; ".join(f"{p or 'input'}: {m}" for p, m in issues))
@@ -0,0 +1,261 @@
1
+ """Numbers, text and JSON written the same way every time: amounts as 1,234.50, no "48.0" for
2
+ whole numbers, JSON keys in the order they were set.
3
+
4
+ MISSING stands for "no value at all": dictionary entries holding it are left out of the JSON,
5
+ while None is written as null.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import re
12
+ from decimal import ROUND_HALF_UP, Decimal
13
+ from typing import Any, Iterable
14
+
15
+
16
+ class _Missing:
17
+ __slots__ = ()
18
+
19
+ def __bool__(self) -> bool:
20
+ return False
21
+
22
+ def __repr__(self) -> str:
23
+ return "MISSING"
24
+
25
+ def __reduce__(self) -> str:
26
+ return "MISSING" # copies stay the one MISSING
27
+
28
+
29
+ MISSING: Any = _Missing()
30
+
31
+
32
+ def is_num(v: Any) -> bool:
33
+ """A real number (bool is not one, unlike in Python's own idea of int)."""
34
+ return isinstance(v, (int, float)) and not isinstance(v, bool)
35
+
36
+
37
+ def truthy(v: Any) -> bool:
38
+ """Whether a value counts as set: empty text, 0, NaN, None and MISSING do not; any dict or list does."""
39
+ if v is None or v is MISSING or v is False:
40
+ return False
41
+ if isinstance(v, float):
42
+ return v == v and v != 0
43
+ if isinstance(v, (int, str)):
44
+ return bool(v)
45
+ return True
46
+
47
+
48
+ def nz(*values: Any) -> Any:
49
+ """The first value that is neither None nor MISSING; the last one otherwise."""
50
+ for v in values[:-1]:
51
+ if v is not None and v is not MISSING:
52
+ return v
53
+ return values[-1]
54
+
55
+
56
+ def get(obj: Any, key: str) -> Any:
57
+ """obj[key] for a dict, MISSING for anything else or an absent key."""
58
+ return obj.get(key, MISSING) if isinstance(obj, dict) else MISSING
59
+
60
+
61
+ def add_up(values: Iterable[float]) -> float:
62
+ """Adds from left to right. sum() may compensate rounding on newer Pythons, which changes the last digit."""
63
+ total: float = 0
64
+ for v in values:
65
+ total = total + v
66
+ return total
67
+
68
+
69
+ def js_round(x: float) -> float:
70
+ """Rounds halves up, towards +infinity: 2.5 -> 3, -2.5 -> -2."""
71
+ if not math.isfinite(x):
72
+ return x
73
+ f = math.floor(x)
74
+ return float(f + 1 if x - f >= 0.5 else f)
75
+
76
+
77
+ def round2(x: float) -> float:
78
+ return js_round(x * 100) / 100
79
+
80
+
81
+ def _digits(x: float) -> tuple[str, int]:
82
+ """Shortest digits of x > 0 and the position of the decimal point: x = 0.DIGITS * 10**n."""
83
+ mant, _, exp = repr(x).partition("e")
84
+ whole, _, frac = mant.partition(".")
85
+ digits = whole + frac
86
+ n = len(whole) + (int(exp) if exp else 0)
87
+ stripped = digits.lstrip("0")
88
+ n -= len(digits) - len(stripped)
89
+ return stripped.rstrip("0") or "0", n
90
+
91
+
92
+ def num_str(x: Any) -> str:
93
+ """A number as text: 48 not 48.0, 0.1 not 0.1000000000000000055, 1e-7 for tiny values."""
94
+ if isinstance(x, bool):
95
+ return "true" if x else "false"
96
+ if isinstance(x, int):
97
+ return str(x)
98
+ if math.isnan(x):
99
+ return "NaN"
100
+ if math.isinf(x):
101
+ return "Infinity" if x > 0 else "-Infinity"
102
+ if x == 0:
103
+ return "0"
104
+ if x < 0:
105
+ return "-" + num_str(-x)
106
+ digits, n = _digits(x)
107
+ k = len(digits)
108
+ if k <= n <= 21:
109
+ return digits + "0" * (n - k)
110
+ if 0 < n <= 21:
111
+ return f"{digits[:n]}.{digits[n:]}"
112
+ if -6 < n <= 0:
113
+ return "0." + "0" * -n + digits
114
+ e = n - 1
115
+ mantissa = digits[0] + (f".{digits[1:]}" if k > 1 else "")
116
+ return f"{mantissa}e{'+' if e >= 0 else '-'}{abs(e)}"
117
+
118
+
119
+ def to_fixed(x: float, places: int) -> str:
120
+ """Rounds the exact stored value, halves away from zero, so 1.005 (stored as 1.00499...) gives 1.00."""
121
+ if isinstance(x, float) and (math.isnan(x) or abs(x) >= 1e21):
122
+ return num_str(x)
123
+ if x < 0:
124
+ return "-" + to_fixed(-x, places)
125
+ q = Decimal(float(x) if x else 0.0).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP)
126
+ return format(q, "f")
127
+
128
+
129
+ def money(x: float) -> str:
130
+ """1234.5 -> '1,234.50'. Rounds the shortest decimal form, so 1.005 shows as 1.01."""
131
+ if isinstance(x, float) and not math.isfinite(x):
132
+ return num_str(x).replace("Infinity", "∞")
133
+ q = Decimal(repr(float(x))).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
134
+ return format(q, ",f")
135
+
136
+
137
+ _WS = " \t\n\v\f\r             

   "
138
+ _DECIMAL = re.compile(r"[+-]?(?:[0-9]+\.?[0-9]*(?:[eE][+-]?[0-9]+)?|\.[0-9]+(?:[eE][+-]?[0-9]+)?)\Z")
139
+ _RADIX = re.compile(r"0([xXoObB])([0-9a-fA-F]+)\Z")
140
+
141
+
142
+ def to_number(s: str) -> float:
143
+ """A strict reading of text as a number: '12.50' -> 12.5, '' -> 0, 'abc' or '1_000' -> NaN."""
144
+ s = s.strip(_WS)
145
+ if not s:
146
+ return 0.0
147
+ if _DECIMAL.match(s):
148
+ return float(s)
149
+ if s in ("Infinity", "+Infinity"):
150
+ return math.inf
151
+ if s == "-Infinity":
152
+ return -math.inf
153
+ m = _RADIX.match(s)
154
+ if m:
155
+ try:
156
+ return float(int(m.group(2), {"x": 16, "o": 8, "b": 2}[m.group(1).lower()]))
157
+ except ValueError:
158
+ return math.nan
159
+ return math.nan
160
+
161
+
162
+ def to_num(v: Any) -> float:
163
+ """Any value as a number: text is read strictly, None counts as 0, anything unreadable is NaN."""
164
+ if v is MISSING:
165
+ return math.nan
166
+ if v is None:
167
+ return 0.0
168
+ if isinstance(v, bool):
169
+ return 1.0 if v else 0.0
170
+ if isinstance(v, (int, float)):
171
+ return v
172
+ if isinstance(v, str):
173
+ return to_number(v)
174
+ if isinstance(v, list) and len(v) <= 1:
175
+ return to_number(text(v[0])) if v else 0.0
176
+ return math.nan
177
+
178
+
179
+ def finite(v: Any) -> bool:
180
+ return is_num(v) and math.isfinite(v)
181
+
182
+
183
+ _INT_PREFIX = re.compile(r"[+-]?[0-9]+")
184
+
185
+
186
+ def parse_int(s: str) -> float:
187
+ """The leading whole number of a text: '12abc' -> 12, '3.7' -> 3, 'x' -> NaN."""
188
+ m = _INT_PREFIX.match(s.lstrip(_WS))
189
+ return int(m.group(0)) if m else math.nan
190
+
191
+
192
+ def text(v: Any) -> str:
193
+ """Any value as text, the way it would print in a message."""
194
+ if v is None:
195
+ return "null"
196
+ if v is MISSING:
197
+ return "undefined"
198
+ if isinstance(v, str):
199
+ return v
200
+ if isinstance(v, (bool, int, float)):
201
+ return num_str(v)
202
+ if isinstance(v, (list, tuple)):
203
+ return ",".join("" if x is None or x is MISSING else text(x) for x in v)
204
+ return "[object Object]"
205
+
206
+
207
+ def _write(v: Any, out: list[str], indent: str | None, level: int) -> None:
208
+ if v is None or v is MISSING:
209
+ out.append("null")
210
+ elif v is True:
211
+ out.append("true")
212
+ elif v is False:
213
+ out.append("false")
214
+ elif isinstance(v, str):
215
+ out.append(json.dumps(v, ensure_ascii=False))
216
+ elif isinstance(v, int):
217
+ out.append(str(v))
218
+ elif isinstance(v, float):
219
+ out.append(num_str(v) if math.isfinite(v) else "null")
220
+ elif isinstance(v, dict):
221
+ items = [(k, x) for k, x in v.items() if x is not MISSING]
222
+ if not items:
223
+ out.append("{}")
224
+ return
225
+ inner = None if indent is None else "\n" + indent * (level + 1)
226
+ out.append("{")
227
+ for i, (k, x) in enumerate(items):
228
+ if i:
229
+ out.append(",")
230
+ if inner:
231
+ out.append(inner)
232
+ out.append(json.dumps(str(k), ensure_ascii=False))
233
+ out.append(":" if indent is None else ": ")
234
+ _write(x, out, indent, level + 1)
235
+ if indent is not None:
236
+ out.append("\n" + indent * level)
237
+ out.append("}")
238
+ elif isinstance(v, (list, tuple)):
239
+ if not v:
240
+ out.append("[]")
241
+ return
242
+ inner = None if indent is None else "\n" + indent * (level + 1)
243
+ out.append("[")
244
+ for i, x in enumerate(v):
245
+ if i:
246
+ out.append(",")
247
+ if inner:
248
+ out.append(inner)
249
+ _write(x, out, indent, level + 1)
250
+ if indent is not None:
251
+ out.append("\n" + indent * level)
252
+ out.append("]")
253
+ else:
254
+ raise TypeError(f"cannot write {type(v).__name__} as JSON")
255
+
256
+
257
+ def dumps(v: Any, pretty: bool = False) -> str:
258
+ """JSON with numbers as num_str writes them; entries holding MISSING are left out."""
259
+ out: list[str] = []
260
+ _write(v, out, " " if pretty else None, 0)
261
+ return "".join(out)
File without changes
@@ -0,0 +1,164 @@
1
+ """The records an agent can search, under plain names. Everything else (which filters a query takes,
2
+ which fields a record has) is read from the schema, so the catalog stays a list of names."""
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+ from typing import Any
8
+
9
+ from ..errors import ToolError
10
+ from ..jsutil import MISSING, is_num, nz
11
+ from ..qbxml.schema import message_model, model
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Entity:
16
+ key: str # "invoice"
17
+ base: str # qbXML name, "Invoice"
18
+ kind: str # "list" or "txn"
19
+ label: str
20
+ query: str # "InvoiceQueryRq"
21
+ fields: tuple # every field the record can have, in schema order
22
+
23
+
24
+ LISTS = [
25
+ ("customer", "Customer", "customers and jobs"),
26
+ ("vendor", "Vendor", "vendors"),
27
+ ("employee", "Employee", "employees (no payroll data)"),
28
+ ("other_name", "OtherName", "other names"),
29
+ ("item", "Item", "all items of every type"),
30
+ ("item_inventory", "ItemInventory", "inventory parts"),
31
+ ("item_assembly", "ItemInventoryAssembly", "inventory assemblies"),
32
+ ("item_non_inventory", "ItemNonInventory", "non-inventory parts"),
33
+ ("item_service", "ItemService", "service items"),
34
+ ("item_other_charge", "ItemOtherCharge", "other charges"),
35
+ ("item_group", "ItemGroup", "item groups"),
36
+ ("item_discount", "ItemDiscount", "discount items"),
37
+ ("account", "Account", "chart of accounts with balances"),
38
+ ("class", "Class", "classes"),
39
+ ("terms", "Terms", "payment terms"),
40
+ ("payment_method", "PaymentMethod", "payment methods"),
41
+ ("sales_rep", "SalesRep", "sales reps"),
42
+ ("sales_tax_code", "SalesTaxCode", "sales tax codes"),
43
+ ("price_level", "PriceLevel", "price levels"),
44
+ ("inventory_site", "InventorySite", "inventory sites (Enterprise)"),
45
+ ("ship_method", "ShipMethod", "shipping methods"),
46
+ ("customer_type", "CustomerType", "customer types"),
47
+ ("vendor_type", "VendorType", "vendor types"),
48
+ ("currency", "Currency", "currencies"),
49
+ ]
50
+
51
+ TXNS = [
52
+ ("invoice", "Invoice", "invoices"),
53
+ ("estimate", "Estimate", "estimates / quotes"),
54
+ ("sales_order", "SalesOrder", "sales orders"),
55
+ ("sales_receipt", "SalesReceipt", "sales receipts"),
56
+ ("credit_memo", "CreditMemo", "credit memos"),
57
+ ("receive_payment", "ReceivePayment", "customer payments"),
58
+ ("deposit", "Deposit", "bank deposits"),
59
+ ("charge", "Charge", "statement charges"),
60
+ ("bill", "Bill", "vendor bills"),
61
+ ("vendor_credit", "VendorCredit", "vendor credits"),
62
+ ("bill_payment_check", "BillPaymentCheck", "bill payments by check"),
63
+ ("bill_payment_credit_card", "BillPaymentCreditCard", "bill payments by credit card"),
64
+ ("check", "Check", "checks / expenses"),
65
+ ("credit_card_charge", "CreditCardCharge", "credit card charges"),
66
+ ("credit_card_credit", "CreditCardCredit", "credit card credits"),
67
+ ("purchase_order", "PurchaseOrder", "purchase orders"),
68
+ ("item_receipt", "ItemReceipt", "item receipts (received inventory)"),
69
+ ("journal_entry", "JournalEntry", "journal entries"),
70
+ ("inventory_adjustment", "InventoryAdjustment", "inventory adjustments"),
71
+ ("build_assembly", "BuildAssembly", "assembly builds"),
72
+ ("transfer", "Transfer", "transfers between accounts"),
73
+ ("transfer_inventory", "TransferInventory", "inventory transfers between sites"),
74
+ ("sales_tax_payment", "SalesTaxPaymentCheck", "sales tax payments"),
75
+ ("time_tracking", "TimeTracking", "time entries"),
76
+ ("transaction", "Transaction", "any transaction type (summary only)"),
77
+ ]
78
+
79
+
80
+ def _make(kind: str, key: str, base: str, label: str) -> Entity | None:
81
+ query = f"{base}QueryRq"
82
+ rs = message_model(f"{base}QueryRs")
83
+ if message_model(query) is None or rs is None:
84
+ return None
85
+ fields: list[str] = []
86
+ for name in rs.o:
87
+ if not name.endswith("Ret"):
88
+ continue
89
+ m = model(rs.c[name][0])
90
+ for f in m.o if m else []:
91
+ if f not in fields:
92
+ fields.append(f)
93
+ return Entity(key, base, kind, label, query, tuple(fields))
94
+
95
+
96
+ @lru_cache(maxsize=None)
97
+ def entities() -> tuple[Entity, ...]:
98
+ made = [_make("list", *e) for e in LISTS] + [_make("txn", *e) for e in TXNS]
99
+ return tuple(e for e in made if e is not None)
100
+
101
+
102
+ def entity(key: str) -> Entity:
103
+ for e in entities():
104
+ if e.key == key:
105
+ return e
106
+ raise ToolError(f'Unknown record type "{key}". Use one of: {", ".join(x.key for x in entities())}')
107
+
108
+
109
+ # Fields shown in search results, in this order when the record has them.
110
+ SUMMARY = [
111
+ "_type", "ListID", "TxnID", "TxnType", "FullName", "Name", "CompanyName", "AccountType", "AccountNumber",
112
+ "RefNumber", "TxnDate", "DueDate", "CustomerRef", "VendorRef", "PayeeEntityRef", "EntityRef",
113
+ "AccountRef", "DepositToAccountRef", "BankAccountRef", "CreditCardAccountRef", "Email", "Phone", "Contact",
114
+ "Balance", "TotalBalance", "Subtotal", "SalesTaxTotal", "TotalAmount", "Amount", "AmountDue", "DepositTotal",
115
+ "OpenAmount", "BalanceRemaining", "AppliedAmount", "UnusedPayment", "UnusedCredits", "CreditRemaining",
116
+ "IsPaid", "IsPending", "IsManuallyClosed", "IsFullyInvoiced", "IsFullyReceived", "IsActive",
117
+ "QuantityOnHand", "QuantityOnOrder", "QuantityOnSalesOrder", "ReorderPoint", "Max", "SalesPrice",
118
+ "PurchaseCost", "AverageCost", "PrefVendorRef", "SalesOrPurchase", "SalesAndPurchase", "Memo",
119
+ ]
120
+
121
+
122
+ def summary_fields(e: Entity) -> list[str]:
123
+ has = set(e.fields)
124
+ # QuickBooks can fill a bill's OpenAmount with the vendor's whole open balance, which reads like the bill's own.
125
+ return [f for f in SUMMARY if f in has and not (f == "Name" and "FullName" in has) and not (f == "OpenAmount" and e.base == "Bill")]
126
+
127
+
128
+ def party_field(e: Entity) -> str | None:
129
+ """The party a transaction belongs to (customer, vendor, payee), as a field name."""
130
+ return next((f for f in ("CustomerRef", "VendorRef", "PayeeEntityRef", "EntityRef") if f in e.fields), None)
131
+
132
+
133
+ def amount_of(row: dict) -> Any:
134
+ """A transaction's total, whichever field holds it for this type; MISSING when it has none."""
135
+ for f in ("TotalAmount", "AmountDue", "Amount", "DepositTotal"):
136
+ if is_num(row.get(f)):
137
+ return row[f]
138
+ if is_num(row.get("Subtotal")):
139
+ tax = row.get("SalesTaxTotal")
140
+ return row["Subtotal"] + (tax if is_num(tax) and tax == tax else 0)
141
+ return MISSING
142
+
143
+
144
+ def ref_value(ref: dict) -> Any:
145
+ return nz(ref.get("FullName", MISSING), ref.get("ListID", MISSING), ref.get("TxnID", MISSING))
146
+
147
+
148
+ def compact(row: dict, fields: list[str] | None = None) -> dict:
149
+ """{ListID, FullName} references read as their name; everything else is kept."""
150
+ out = {}
151
+ for k, v in row.items():
152
+ if fields is not None and k not in fields:
153
+ continue
154
+ if isinstance(v, dict) and k.endswith("Ref"):
155
+ out[k[:-3]] = ref_value(v)
156
+ else:
157
+ out[k] = v
158
+ return out
159
+
160
+
161
+ def ref_name(v: Any) -> Any:
162
+ if isinstance(v, (dict, list)):
163
+ return nz(v.get("FullName", MISSING), v.get("ListID", MISSING)) if isinstance(v, dict) else MISSING
164
+ return v if isinstance(v, str) else MISSING