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,917 @@
1
+ """Answers qbXML like QuickBooks would, from the sample company in memory, so every command can be
2
+ tried (and tested) without QuickBooks. It covers the queries, reports and writes the tools use."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import Any
8
+
9
+ from ..jsutil import MISSING, add_up, get, is_num, js_round, num_str, nz, round2 as money, text, to_fixed, to_num, truthy
10
+ from ..qb.dates import add_days, days_between, macro_range
11
+ from ..qbxml import xml
12
+ from ..qbxml.build import escape_xml
13
+ from .company import Rec, create_demo_company, new_list_id, new_txn_id, recompute_balances
14
+
15
+ RAW = "#raw" # pre-built XML, for report rows whose order matters
16
+ ITEM_TYPES = ["ItemService", "ItemNonInventory", "ItemOtherCharge", "ItemInventory", "ItemInventoryAssembly", "ItemGroup", "ItemDiscount"]
17
+ LIST_BASES = [
18
+ "Customer", "Vendor", "Employee", "OtherName", "Account", "Class", "PaymentMethod", "SalesRep", "SalesTaxCode", "PriceLevel",
19
+ "ShipMethod", "CustomerType", "VendorType", "Currency", "InventorySite", "StandardTerms", *ITEM_TYPES,
20
+ ]
21
+ BUCKETS = ["Current", "1 - 30", "31 - 60", "61 - 90", "> 90"]
22
+ COMPANY_FILE = "C:\\Users\\Public\\Documents\\Intuit\\QuickBooks\\Company Files\\Bayside Hardware (sample).qbw"
23
+
24
+
25
+ class Status(Exception):
26
+ def __init__(self, code: int, message: str):
27
+ super().__init__(message)
28
+ self.code = code
29
+ self.message = message
30
+
31
+
32
+ def _arr(v: Any) -> list:
33
+ if v is None or v is MISSING:
34
+ return []
35
+ return v if isinstance(v, list) else [v]
36
+
37
+
38
+ def _str(v: Any) -> Any:
39
+ return MISSING if v is None or v is MISSING else text(v)
40
+
41
+
42
+ def _dict(v: Any) -> dict:
43
+ return v if isinstance(v, dict) else {}
44
+
45
+
46
+ def _end(n: float) -> int:
47
+ """A list slice end from any number, as a count: NaN is 0, fractions are cut."""
48
+ return 0 if n != n else int(n) if math.isfinite(n) else (1 << 62 if n > 0 else 0)
49
+
50
+
51
+ def to_xml(name: str, value: Any) -> str:
52
+ if isinstance(value, list):
53
+ return "".join(to_xml(name, v) for v in value)
54
+ if value is None or value is MISSING:
55
+ return ""
56
+ if isinstance(value, dict):
57
+ attrs = []
58
+ body = []
59
+ for k, v in value.items():
60
+ if k.startswith("@"):
61
+ attrs.append(f' {k[1:]}="{escape_xml(text(v))}"')
62
+ elif k == RAW:
63
+ body.append(text(v))
64
+ else:
65
+ body.append(to_xml(k, v))
66
+ return f"<{name}{''.join(attrs)}>{''.join(body)}</{name}>"
67
+ t = num_str(js_round(value * 100000) / 100000) if is_num(value) else text(value)
68
+ return f"<{name}>{escape_xml(t)}</{name}>"
69
+
70
+
71
+ def amount_of(r: Rec) -> Any:
72
+ for f in ("TotalAmount", "AmountDue", "Amount", "DepositTotal", "CreditAmount"):
73
+ if is_num(r.get(f)):
74
+ return r[f]
75
+ return r["Subtotal"] if is_num(r.get("Subtotal")) else MISSING
76
+
77
+
78
+ def _text_match(name: str, needle: str, how: str) -> bool:
79
+ a, b = name.lower(), needle.lower()
80
+ return a.startswith(b) if how == "StartsWith" else a.endswith(b) if how == "EndsWith" else b in a
81
+
82
+
83
+ def _bucket(days_late: float) -> int:
84
+ return 0 if days_late <= 0 else 1 if days_late <= 30 else 2 if days_late <= 60 else 3 if days_late <= 90 else 4
85
+
86
+
87
+ def _js_min(*xs: float) -> float:
88
+ return math.nan if any(x != x for x in xs) else min(xs)
89
+
90
+
91
+ def _report_rows(rows: list[dict]) -> dict:
92
+ """ReportData mixes row types in order, which the dict form cannot express, so build it as XML."""
93
+ out = []
94
+ for n, r in enumerate(rows, 1):
95
+ if r["kind"] == "text":
96
+ out.append(f'<TextRow rowNumber="{n}" value="{escape_xml(r["label"])}"/>')
97
+ continue
98
+ tag = "DataRow" if r["kind"] == "data" else "SubtotalRow" if r["kind"] == "subtotal" else "TotalRow"
99
+ row_data = f'<RowData rowType="{r["rowType"]}" value="{escape_xml(r["label"])}"/>' if r.get("rowType") else ""
100
+ cols = [f'<ColData colID="1" value="{escape_xml(r["label"])}"/>']
101
+ cols += [f'<ColData colID="{i + 2}" value="{escape_xml(to_fixed(v, 2) if is_num(v) else v)}"/>' for i, v in enumerate(r.get("values") or [])]
102
+ out.append(f'<{tag} rowNumber="{n}">{row_data}{"".join(cols)}</{tag}>')
103
+ return {RAW: "".join(out)}
104
+
105
+
106
+ class DemoTransport:
107
+ kind = "demo"
108
+
109
+ def __init__(self, today: str):
110
+ self.company = create_demo_company(today)
111
+ self._iterators: dict[str, dict] = {}
112
+ self._next_iterator = 1
113
+ self.deleted: dict[str, list[Rec]] = {"TxnDeletedRet": [], "ListDeletedRet": []}
114
+
115
+ def open(self) -> dict[str, Any]:
116
+ return {"companyFile": COMPANY_FILE}
117
+
118
+ def close(self) -> None:
119
+ pass
120
+
121
+ def process(self, qbxml: str) -> str:
122
+ doc = xml.parse(qbxml)
123
+ rq = get(doc.get("QBXML"), "QBXMLMsgsRq")
124
+ out = []
125
+ for message, value in (rq if isinstance(rq, dict) else {}).items():
126
+ if message.startswith("@"):
127
+ continue
128
+ for item in _arr(value):
129
+ out.append(self._answer(message, _dict(item)))
130
+ return f'<?xml version="1.0" ?>\n<QBXML><QBXMLMsgsRs>{"".join(out)}</QBXMLMsgsRs></QBXML>'
131
+
132
+ def _answer(self, message: str, rq: Rec) -> str:
133
+ rs_name = message[:-2] + "Rs" if message.endswith("Rq") else message
134
+ attrs = {"@requestID": rq.get("@requestID", MISSING)}
135
+ try:
136
+ body, extra = self._handle(message, rq)
137
+ empty = all(isinstance(v, list) and not v for v in body.values())
138
+ if empty and message.endswith("QueryRq") and "Report" not in message:
139
+ ok = {"@statusCode": "1", "@statusSeverity": "Info", "@statusMessage": "A query request did not find a matching object in QuickBooks"}
140
+ else:
141
+ ok = {"@statusCode": "0", "@statusSeverity": "Info", "@statusMessage": "Status OK"}
142
+ return to_xml(rs_name, {**attrs, **ok, **(extra or {}), **body})
143
+ except Exception as e:
144
+ code = e.code if isinstance(e, Status) else 3000
145
+ return to_xml(rs_name, {**attrs, "@statusCode": str(code), "@statusSeverity": "Error", "@statusMessage": str(e)})
146
+
147
+ def _handle(self, message: str, rq: Rec) -> tuple[dict, dict | None]:
148
+ c = self.company
149
+ if message == "HostQueryRq":
150
+ ret = {"ProductName": "QuickBooks Desktop sample company (qbd-agent-kit demo)", "MajorVersion": "34", "MinorVersion": "0", "Country": "US", "SupportedQBXMLVersion": ["13.0", "14.0", "15.0", "16.0"], "IsAutomaticLogin": "false", "QBFileMode": "MultiUser"}
151
+ if truthy(get(rq, "IncludeListMetaData")):
152
+ # Made-up limits for the sample company, in the shape QuickBooks reports them.
153
+ ret["ListMetaData"] = {f"{k}MetaData": {"MaxCapacity": str(n)} for k, n in (("Account", 10000), ("Class", 10000), ("Entity", 14500), ("Item", 14500), ("PaymentMethod", 10000), ("PriceLevel", 100), ("SalesRep", 10000), ("SalesTaxCode", 10000), ("Terms", 10000))}
154
+ return {"HostRet": ret}, None
155
+ if message == "CompanyActivityQueryRq":
156
+ never = "1969-12-31T16:00:00-08:00"
157
+ return {"CompanyActivityRet": {"LastRestoreTime": never, "LastCondenseTime": never}}, None
158
+ if message in ("TxnDeletedQueryRq", "ListDeletedQueryRq"):
159
+ ret = message.replace("QueryRq", "Ret")
160
+ key = "TxnDelType" if ret == "TxnDeletedRet" else "ListDelType"
161
+ types = [text(x) for x in _arr(rq.get(key))]
162
+ since = text(nz(get(get(rq, "DeletedDateRangeFilter"), "FromDeletedDate"), ""))
163
+ return {ret: [r for r in self.deleted[ret] if r[key] in types and text(r["TimeDeleted"]) >= since]}, None
164
+ if message == "DataExtDefQueryRq":
165
+ return {"DataExtDefRet": [
166
+ {"OwnerID": "0", "DataExtID": "1", "DataExtName": "Sales Region", "DataExtType": "STR255TYPE", "AssignToObject": ["Customer"]},
167
+ {"OwnerID": "0", "DataExtID": "2", "DataExtName": "Bin Location", "DataExtType": "STR255TYPE", "AssignToObject": ["Item"]},
168
+ ]}, None
169
+ if message == "CompanyQueryRq":
170
+ return {"CompanyRet": {"IsSampleCompany": "true", "CompanyName": c.name, "LegalCompanyName": c.name, "Phone": "555-0100", "Email": "office@bayside-hardware.example", "FirstMonthFiscalYear": "January", "FirstMonthIncomeTaxYear": "January", "TaxForm": "Form1120S"}}, None
171
+ if message == "PreferencesQueryRq":
172
+ return {"PreferencesRet": {"AccountingPreferences": {"IsUsingAccountNumbers": "true", "IsRequiringAccounts": "true", "IsUsingClassTracking": "false", "IsUsingAuditTrail": "true", "IsAssigningJournalEntryNumbers": "true", "ClosingDate": c.closing_date}, "JobsAndEstimatesPreferences": {"IsUsingEstimates": "true", "IsUsingProgressInvoicing": "false", "IsPrintingItemsWithZeroAmounts": "false"}, "MultiCurrencyPreferences": {"IsMultiCurrencyOn": "true", "HomeCurrencyRef": {"FullName": c.home_currency}} if c.home_currency else {"IsMultiCurrencyOn": "false"}, "MultiLocationInventoryPreferences": {"IsMultiLocationInventoryAvailable": "true", "IsMultiLocationInventoryEnabled": "true"}, "PurchasesAndVendorsPreferences": {"IsUsingInventory": "true", "DaysBillsAreDue": "10", "IsAutomaticallyUsingDiscounts": "false"}, "CurrentAppAccessRights": {"IsAutomaticLoginAllowed": "false", "IsPersonalDataAccessAllowed": "true" if c.personal_data else "false"}}}, None
173
+ if message == "Form1099CategoryAccountMappingQueryRq":
174
+ return {"Form1099CategoryAccountMappingRet": c.form1099}, None
175
+ if message == "ReceivePaymentToDepositQueryRq":
176
+ return {"ReceivePaymentToDepositRet": c.undeposited}, None
177
+ if message == "ItemQueryRq":
178
+ return self._query(rq, [(f"{t}Ret", r) for t in ITEM_TYPES for r in c.lists.get(t, [])], "list")
179
+ if message == "ItemSitesQueryRq":
180
+ return self._query(rq, [("ItemSitesRet", r) for r in self._item_sites(rq)], "list")
181
+ if message == "TermsQueryRq":
182
+ return self._query(rq, [("StandardTermsRet", r) for r in c.lists.get("StandardTerms", [])], "list")
183
+ if message == "TransactionQueryRq":
184
+ return self._query(rq, self._all_transactions(), "transaction")
185
+ if message in ("GeneralSummaryReportQueryRq", "GeneralDetailReportQueryRq", "AgingReportQueryRq"):
186
+ return {"ReportRet": self._report(message, rq)}, None
187
+ if message in ("TxnDelRq", "TxnVoidRq"):
188
+ return self._delete_txn(message, rq), None
189
+ if message == "ListDelRq":
190
+ return self._delete_list(rq), None
191
+ base = re.sub(r"(Query|Add|Mod)Rq\Z", "", message)
192
+ if message.endswith("QueryRq"):
193
+ if base in c.lists or base in LIST_BASES:
194
+ return self._query(rq, [(f"{base}Ret", r) for r in c.lists.get(base, [])], "list")
195
+ return self._query(rq, [(f"{base}Ret", r) for r in c.txns.get(base, [])], "txn")
196
+ if message.endswith("AddRq"):
197
+ return {f"{base}Ret": self._add(base, _dict(nz(rq.get(f"{base}Add", MISSING), {})))}, None
198
+ if message.endswith("ModRq"):
199
+ return {f"{base}Ret": self._mod(base, _dict(nz(rq.get(f"{base}Mod", MISSING), {})))}, None
200
+ raise Status(3250, f"The sample company does not support {message}.")
201
+
202
+ # ---------------------------------------------------------------- queries
203
+
204
+ def _query(self, rq: Rec, all_rows: list[tuple[str, Rec]], kind: str) -> tuple[dict, dict | None]:
205
+ iterator = _str(rq.get("@iterator", MISSING))
206
+ if iterator in ("Continue", "Stop"):
207
+ id_ = nz(_str(rq.get("@iteratorID", MISSING)), "")
208
+ it = self._iterators.get(id_)
209
+ if not it:
210
+ raise Status(3170, f"The iterator {id_} is not valid or has expired.")
211
+ if iterator == "Stop":
212
+ del self._iterators[id_]
213
+ return {}, {"@iteratorRemainingCount": "0", "@iteratorID": id_}
214
+ size = _end(to_num(nz(rq.get("MaxReturned", MISSING), 100)))
215
+ page = it["rows"][it["pos"]:it["pos"] + size]
216
+ it["pos"] += len(page)
217
+ return self._group(page, rq), {"@iteratorRemainingCount": str(len(it["rows"]) - it["pos"]), "@iteratorID": id_}
218
+ rows = [x for x in all_rows if self._matches(x[1], rq, kind)]
219
+ if kind != "list":
220
+ rows.sort(key=lambda x: text(get(x[1], "TxnDate")))
221
+ size = _end(to_num(rq["MaxReturned"])) if "MaxReturned" in rq else len(rows)
222
+ meta = _str(rq.get("@metaData", MISSING))
223
+ if meta == "MetaDataOnly":
224
+ return {}, {"@retCount": str(len(rows))}
225
+ if meta == "MetaDataAndResponseData":
226
+ # QuickBooks Enterprise 24 counts what it returns here, not every match.
227
+ return self._group(rows[:size], rq), {"@retCount": str(len(rows[:size]))}
228
+ if iterator == "Start":
229
+ id_ = f"{{demo-{self._next_iterator}}}"
230
+ self._next_iterator += 1
231
+ page = rows[:size]
232
+ self._iterators[id_] = {"rows": rows, "pos": len(page)}
233
+ return self._group(page, rq), {"@iteratorRemainingCount": str(len(rows) - len(page)), "@iteratorID": id_}
234
+ return self._group(rows[:size], rq), None
235
+
236
+ def _group(self, rows: list[tuple[str, Rec]], rq: Rec) -> dict:
237
+ include = [text(x) for x in _arr(rq.get("IncludeRetElement"))]
238
+ lines = rq.get("IncludeLineItems") == "true"
239
+ linked = rq.get("IncludeLinkedTxns") == "true" and (not include or "LinkedTxn" in include)
240
+ body: dict[str, list] = {}
241
+ for ret, r in rows:
242
+ rec = r
243
+ if include:
244
+ rec = {k: v for k, v in r.items() if k in include}
245
+ elif not lines:
246
+ rec = {k: v for k, v in r.items() if not re.search(r"Line(Group)?Ret\Z", k)}
247
+ # Like QuickBooks, an open bill reports its vendor's whole open balance as its OpenAmount.
248
+ if ret == "BillRet" and "OpenAmount" in rec and r.get("IsPaid") is False:
249
+ vendor = next((v for v in self.company.lists.get("Vendor", []) if v.get("Name") == get(get(r, "VendorRef"), "FullName")), None)
250
+ rec = {**rec, "OpenAmount": nz(vendor.get("Balance", MISSING) if vendor else MISSING, r.get("OpenAmount", MISSING))}
251
+ if linked and "TxnID" in r:
252
+ rec = {**rec, **({"LinkedTxn": links} if (links := self._linked(r)) else {})}
253
+ body.setdefault(ret, []).append(rec)
254
+ return body
255
+
256
+ def _linked(self, r: Rec) -> list[Rec]:
257
+ """LinkedTxn as QuickBooks reports it: payments and credits applied to this transaction (AMTTYPE), and the orders
258
+ it was made from or into with LinkToTxnID (QUANTYPE)."""
259
+ me, out = r.get("TxnID"), []
260
+ for base, rows in self.company.txns.items():
261
+ for t in rows:
262
+ applied = [x for x in _arr(t.get("AppliedToTxnRet")) if get(x, "TxnID") == me]
263
+ made = me in [text(x) for x in _arr(t.get("LinkToTxnID"))] or t.get("TxnID") in [text(x) for x in _arr(r.get("LinkToTxnID"))]
264
+ if applied or made:
265
+ out.append({"TxnID": t["TxnID"], "TxnType": base, "TxnDate": t.get("TxnDate"), "RefNumber": t.get("RefNumber"),
266
+ "LinkType": "AMTTYPE" if applied else "QUANTYPE",
267
+ "Amount": -add_up(to_num(get(x, "Amount")) for x in applied) if applied else amount_of(t)})
268
+ return out
269
+
270
+ def _matches(self, r: Rec, rq: Rec, kind: str) -> bool:
271
+ name = nz(get(r, "FullName"), get(r, "Name"))
272
+ ids = [text(x) for x in _arr(rq.get("ListID"))]
273
+ if ids:
274
+ return r.get("ListID") in ids
275
+ names = [text(x) for x in _arr(rq.get("FullName"))]
276
+ if names:
277
+ return isinstance(name, str) and any(n.lower() == name.lower() for n in names)
278
+ txn_ids = [text(x) for x in _arr(rq.get("TxnID"))]
279
+ if txn_ids:
280
+ return r.get("TxnID") in txn_ids
281
+ refs = [text(x) for x in _arr(rq.get("RefNumber"))]
282
+ if refs:
283
+ return r.get("RefNumber") in refs
284
+ currencies = [text(x).lower() for x in _arr(get(get(rq, "CurrencyFilter"), "FullName"))]
285
+ if currencies and text(get(get(r, "CurrencyRef"), "FullName")).lower() not in currencies:
286
+ return False
287
+
288
+ if kind == "list":
289
+ active = nz(_str(rq.get("ActiveStatus", MISSING)), "ActiveOnly")
290
+ if active == "ActiveOnly" and r.get("IsActive") is False:
291
+ return False
292
+ if active == "InactiveOnly" and r.get("IsActive") is not False:
293
+ return False
294
+ nf = rq.get("NameFilter", MISSING)
295
+ if truthy(nf) and not _text_match(nz(name, ""), text(nz(get(nf, "Name"), "")), text(nz(get(nf, "MatchCriterion"), "Contains"))):
296
+ return False
297
+ if truthy(rq.get("AccountType")) and r.get("AccountType") != rq["AccountType"]:
298
+ return False
299
+ if truthy(rq.get("FromModifiedDate")) and text(get(r, "TimeModified")) < text(rq["FromModifiedDate"]):
300
+ return False
301
+ return True
302
+ rng = self._range(nz(get(rq, "TxnDateRangeFilter"), get(rq, "TransactionDateRangeFilter")))
303
+ if rng and (text(get(r, "TxnDate")) < rng["from"] or text(get(r, "TxnDate")) > rng["to"]):
304
+ return False
305
+ mod = nz(get(rq, "ModifiedDateRangeFilter"), get(rq, "TransactionModifiedDateRangeFilter"))
306
+ if truthy(get(mod, "FromModifiedDate")) and text(get(r, "TimeModified")) < text(get(mod, "FromModifiedDate")):
307
+ return False
308
+ ef = nz(get(rq, "EntityFilter"), get(rq, "TransactionEntityFilter"))
309
+ if truthy(ef):
310
+ party = nz(get(r, "CustomerRef"), get(r, "VendorRef"), get(r, "PayeeEntityRef"), get(r, "EntityRef"))
311
+ wanted = [text(x).lower() for k in ("FullName", "FullNameWithChildren", "ListID", "ListIDWithChildren") for x in _arr(get(ef, k))]
312
+ own = [text(x).lower() for x in (get(party, "FullName"), get(party, "ListID")) if truthy(x)]
313
+ if not any(o == w or o.startswith(f"{w}:") for w in wanted for o in own):
314
+ return False
315
+ paid = _str(get(rq, "PaidStatus"))
316
+ if paid == "NotPaidOnly" and r.get("IsPaid") is not False:
317
+ return False
318
+ if paid == "PaidOnly" and r.get("IsPaid") is not True:
319
+ return False
320
+ types = [text(x) for x in _arr(get(get(rq, "TransactionTypeFilter"), "TxnTypeFilter"))]
321
+ if types and "All" not in types and text(get(r, "TxnType")) not in types:
322
+ return False
323
+ return True
324
+
325
+ def _item_sites(self, rq: Rec) -> list[Rec]:
326
+ """Stock per site: the warehouse holds 40% of what is on hand and receives the purchase orders, and sales
327
+ orders ship from the store."""
328
+ f = get(rq, "ItemSiteFilter")
329
+ items = {text(x).lower() for x in _arr(get(get(f, "ItemFilter"), "FullName"))}
330
+ sites = {text(x).lower() for x in _arr(get(get(f, "SiteFilter"), "FullName"))}
331
+ store, warehouse = self.company.lists["InventorySite"]
332
+ out = []
333
+ for item in self.company.lists.get("ItemInventory", []):
334
+ if items and text(item["FullName"]).lower() not in items:
335
+ continue
336
+ back = math.floor(max(0, item["QuantityOnHand"]) * 0.4)
337
+ split = ((store, item["QuantityOnHand"] - back, item["QuantityOnSalesOrder"], 0), (warehouse, back, 0, item["QuantityOnOrder"]))
338
+ for i, (site, on_hand, on_so, on_po) in enumerate(split, 1):
339
+ if sites and site["Name"].lower() not in sites:
340
+ continue
341
+ out.append({
342
+ "ListID": f"{item['ListID']}-S{i}", "ItemInventoryRef": {"ListID": item["ListID"], "FullName": item["FullName"]},
343
+ "InventorySiteRef": {"ListID": site["ListID"], "FullName": site["Name"]},
344
+ **({"ReorderLevel": item["ReorderPoint"]} if site is store and item.get("ReorderPoint") else {}),
345
+ "QuantityOnHand": on_hand, "QuantityOnPurchaseOrders": on_po, "QuantityOnSalesOrders": on_so,
346
+ "QuantityToBeBuiltByPendingBuildTxns": 0, "QuantityRequiredByPendingBuildTxns": 0, "QuantityOnPendingTransfers": 0,
347
+ })
348
+ return out
349
+
350
+ def _range(self, f: Any) -> dict[str, str] | None:
351
+ if not truthy(f):
352
+ return None
353
+ if truthy(get(f, "DateMacro")):
354
+ return macro_range(text(f["DateMacro"]), self.company.today)
355
+ return {"from": nz(_str(get(f, "FromTxnDate")), "1900-01-01"), "to": nz(_str(get(f, "ToTxnDate")), "2999-12-31")}
356
+
357
+ def _all_transactions(self) -> list[tuple[str, Rec]]:
358
+ out = []
359
+ for base, rows in self.company.txns.items():
360
+ for r in rows:
361
+ # Like QuickBooks, a bill's amount comes back negative here.
362
+ amount = -amount_of(r) if base == "Bill" and truthy(amount_of(r)) else amount_of(r)
363
+ out.append(("TransactionRet", {
364
+ "TxnType": base, "TxnID": get(r, "TxnID"), "TimeCreated": get(r, "TimeCreated"), "TimeModified": get(r, "TimeModified"), "TxnDate": get(r, "TxnDate"),
365
+ "RefNumber": get(r, "RefNumber"), "EntityRef": nz(get(r, "CustomerRef"), get(r, "VendorRef"), get(r, "PayeeEntityRef")),
366
+ "Amount": amount, "Memo": get(r, "Memo"),
367
+ # In a multicurrency file the amount is in the transaction's currency, and its home amount at its rate.
368
+ **({"CurrencyRef": r["CurrencyRef"], "ExchangeRate": r["ExchangeRate"], "AmountInHomeCurrency": money(amount * r["ExchangeRate"])}
369
+ if "ExchangeRate" in r else {}),
370
+ }))
371
+ return out
372
+
373
+ # ---------------------------------------------------------------- reports
374
+
375
+ def _report(self, message: str, rq: Rec) -> dict:
376
+ c = self.company
377
+ type_ = text(nz(get(rq, "GeneralSummaryReportType"), get(rq, "GeneralDetailReportType"), get(rq, "AgingReportType")))
378
+ period = get(rq, "ReportPeriod")
379
+ if truthy(get(rq, "ReportDateMacro")):
380
+ rng = macro_range(text(rq["ReportDateMacro"]), c.today) or {"from": c.today, "to": c.today}
381
+ else:
382
+ rng = {"from": nz(_str(get(period, "FromReportDate")), add_days(c.today, -30)), "to": nz(_str(get(period, "ToReportDate")), c.today)}
383
+ basis = nz(_str(get(rq, "ReportBasis")), "Accrual")
384
+ if message == "AgingReportQueryRq" and type_ in ("ARAgingSummary", "APAgingSummary"):
385
+ return self._aging(type_, rng["to"])
386
+ if message == "AgingReportQueryRq" and type_ in ("ARAgingDetail", "APAgingDetail"):
387
+ f = get(rq, "ReportEntityFilter")
388
+ return self._aging_detail(type_, rng["to"], _str(nz(get(f, "FullNameWithChildren"), get(f, "FullName"))))
389
+ if type_ == "ProfitAndLossStandard":
390
+ return self._profit_and_loss(rng, basis)
391
+ if type_ == "SalesByItemSummary":
392
+ return self._sales_by_item(rng)
393
+ if type_ == "SalesByCustomerSummary":
394
+ return self._sales_by_customer(rng)
395
+ if type_ in ("OpenSalesOrderByCustomer", "OpenPOs"):
396
+ return self._open_orders(type_, rng)
397
+ if type_ == "TxnDetailByAccount":
398
+ return self._register(rq, rng)
399
+ if type_ == "SalesTaxLiability":
400
+ return self._sales_tax_liability(rng)
401
+ if type_ == "BalanceSheetStandard":
402
+ return self._balance_sheet(rng, basis)
403
+ if type_ == "InventoryValuationDetail":
404
+ return self._valuation_detail(rq, rng)
405
+ raise Status(3250, f"The sample company has no {type_} report. It has ProfitAndLossStandard, SalesByItemSummary, SalesByCustomerSummary, ARAgingSummary, ARAgingDetail, APAgingSummary, APAgingDetail, OpenSalesOrderByCustomer, OpenPOs, TxnDetailByAccount, InventoryValuationDetail, SalesTaxLiability and BalanceSheetStandard.")
406
+
407
+ def _open_orders(self, type_: str, rng: dict[str, str]) -> dict:
408
+ """Sales orders or purchase orders still open, one row each, grouped by customer or vendor."""
409
+ so = type_ == "OpenSalesOrderByCustomer"
410
+ src = self.company.txns.get("SalesOrder" if so else "PurchaseOrder", [])
411
+ open_ = [
412
+ t for t in src
413
+ if not truthy(t.get("IsManuallyClosed")) and not truthy(t.get("IsFullyInvoiced" if so else "IsFullyReceived"))
414
+ and rng["from"] <= text(get(t, "TxnDate")) <= rng["to"]
415
+ ]
416
+
417
+ def party(t: Rec) -> str:
418
+ return text(get(nz(get(t, "CustomerRef"), get(t, "VendorRef")), "FullName"))
419
+
420
+ rows: list[dict] = []
421
+ for name in sorted(dict.fromkeys(party(t) for t in open_)):
422
+ own = [t for t in open_ if party(t) == name]
423
+ rows.append({"kind": "text", "label": name})
424
+ for t in own:
425
+ rows.append({"kind": "data", "label": "", "values": ["Sales Order" if so else "Purchase Order", text(get(t, "TxnDate")), text(get(t, "RefNumber")), name, t["TotalAmount"], text(get(t, "TxnID"))]})
426
+ rows.append({"kind": "subtotal", "label": f"Total {name}", "values": ["", "", "", "", money(add_up(t["TotalAmount"] for t in own)), ""]})
427
+ rows.append({"kind": "total", "label": "TOTAL", "values": ["", "", "", "", money(add_up(t["TotalAmount"] for t in open_)), ""]})
428
+ cols = [("", "Blank", "STRTYPE"), ("Type", "TxnType", "STRTYPE"), ("Date", "Date", "DATETYPE"), ("Num", "RefNumber", "STRTYPE"), ("Name", "Name", "STRTYPE"), ("Amount", "Amount", "AMTTYPE"), ("", "TxnID", "STRTYPE")]
429
+ return self._report_xml("Open Sales Orders by Customer" if so else "Open Purchase Orders", rng, None, cols, rows)
430
+
431
+ def _register(self, rq: Rec, rng: dict[str, str]) -> dict:
432
+ """Transaction Detail by Account: payments out of bank and card accounts, and expense and journal lines posted to
433
+ any account (a check written to Sales Tax Payable, say). One text row per account, then its lines."""
434
+ c = self.company
435
+ f = get(rq, "ReportAccountFilter")
436
+ kind, names = _str(get(f, "AccountTypeFilter")), [text(x) for x in _arr(get(f, "FullName"))]
437
+ accounts = [a["Name"] for a in c.lists["Account"] if (a["Name"] in names if names else not kind or a.get("AccountType") == kind)]
438
+ types = {text(x) for x in _arr(get(get(rq, "ReportTxnTypeFilter"), "TxnTypeFilter"))}
439
+ shown = {"Check": "Check", "BillPaymentCheck": "Bill Pmt -Check", "CreditCardCharge": "Credit Card Charge"}
440
+ lines = []
441
+ for base, label in shown.items():
442
+ for t in c.txns.get(base, []) if not types or base in types else []:
443
+ account = text(get(nz(get(t, "BankAccountRef"), get(t, "AccountRef")), "FullName"))
444
+ if account in accounts and rng["from"] <= t["TxnDate"] <= rng["to"]:
445
+ status = c.cleared.get(t["TxnID"], "Cleared" if t["TxnDate"] <= add_days(c.today, -7) else "NotCleared")
446
+ lines.append({"TxnID": t["TxnID"], "TxnType": label, "Date": t["TxnDate"], "RefNumber": text(nz(get(t, "RefNumber"), "")),
447
+ "Name": text(get(get(t, "PayeeEntityRef"), "FullName")), "Memo": text(nz(get(t, "Memo"), "")), "Account": account,
448
+ "ClearedStatus": "" if status == "NotCleared" else status, "Amount": -t["Amount"],
449
+ "ModifiedTime": text(t["TimeModified"]), "LastModifiedBy": "Admin"})
450
+ posted = {"Check": "Check", "Bill": "Bill", "CreditCardCharge": "Credit Card Charge", "JournalEntry": "General Journal"}
451
+ for base, label in posted.items():
452
+ for t in c.txns.get(base, []) if not types or base in types else []:
453
+ for key, sign in (("ExpenseLineRet", 1), ("JournalDebitLine", 1), ("JournalCreditLine", -1)):
454
+ for line in _arr(get(t, key)):
455
+ account = text(get(get(line, "AccountRef"), "FullName"))
456
+ if account in accounts and rng["from"] <= t["TxnDate"] <= rng["to"]:
457
+ party = nz(get(line, "EntityRef"), get(t, "PayeeEntityRef"), get(t, "VendorRef"))
458
+ lines.append({"TxnID": t["TxnID"], "TxnType": label, "Date": t["TxnDate"], "RefNumber": text(nz(get(t, "RefNumber"), "")),
459
+ "Name": text(nz(get(party, "FullName"), "")), "Memo": text(nz(get(line, "Memo"), get(t, "Memo"), "")),
460
+ "Account": account, "ClearedStatus": "", "Amount": sign * line["Amount"],
461
+ "ModifiedTime": text(t["TimeModified"]), "LastModifiedBy": "Admin"})
462
+ titles = {"TxnID": "", "TxnType": "Type", "Date": "Date", "RefNumber": "Num", "Name": "Name", "Memo": "Memo", "Account": "Account",
463
+ "ClearedStatus": "Clr", "Amount": "Amount", "ModifiedTime": "Last modified", "LastModifiedBy": "Last modified by"}
464
+ wanted = [text(x) for x in _arr(get(rq, "IncludeColumn"))] or ["TxnType", "Date", "RefNumber", "Name", "Memo", "ClearedStatus", "Amount"]
465
+ cols = [k for k in titles if k in wanted]
466
+ rows: list[dict] = []
467
+ for account in accounts:
468
+ own = sorted((x for x in lines if x["Account"] == account), key=lambda x: x["Date"])
469
+ if not own:
470
+ continue
471
+ rows.append({"kind": "text", "label": account})
472
+ rows += [{"kind": "data", "label": "", "values": [x[k] for k in cols]} for x in own]
473
+ rows.append({"kind": "subtotal", "label": f"Total {account}", "values": [money(add_up(x["Amount"] for x in own)) if k == "Amount" else "" for k in cols]})
474
+ rows.append({"kind": "total", "label": "TOTAL", "values": [money(add_up(x["Amount"] for x in lines)) if k == "Amount" else "" for k in cols]})
475
+ types = {"Date": "DATETYPE", "Amount": "AMTTYPE"}
476
+ return self._report_xml("Transaction Detail By Account", rng, None, [("", "Blank", "STRTYPE"), *((titles[k], k, types.get(k, "STRTYPE")) for k in cols)], rows)
477
+
478
+ def _valuation_detail(self, rq: Rec, rng: dict[str, str]) -> dict:
479
+ """Inventory Valuation Detail: each item's movements with the quantity on hand after each. The sample company
480
+ keeps only the current quantity on an item, so earlier ones are worked back from it, transactions dated ahead
481
+ included, as QuickBooks counts them in an item's quantity; costs stay at the item's average cost."""
482
+ c = self.company
483
+ names = {text(x).lower() for x in _arr(get(get(rq, "ReportItemFilter"), "FullName"))}
484
+ kinds = {"Invoice": ("Invoice", -1), "SalesReceipt": ("Sales Receipt", -1), "CreditMemo": ("Credit Memo", 1), "Bill": ("Bill", 1),
485
+ "ItemReceipt": ("Item Receipt", 1), "Check": ("Check", 1), "CreditCardCharge": ("Credit Card Charge", 1)}
486
+ titles = {"TxnID": "", "TxnType": "Type", "Date": "Date", "Name": "Name", "RefNumber": "Num", "Quantity": "Qty",
487
+ "QuantityOnHand": "On Hand", "AverageCost": "Avg Cost", "ValueOnHand": "Asset Value"}
488
+ wanted = [text(x) for x in _arr(get(rq, "IncludeColumn"))] or [k for k in titles if k != "TxnID"]
489
+ cols = [k for k in titles if k in wanted]
490
+ rows: list[dict] = []
491
+ for item in c.lists.get("ItemInventory", []):
492
+ if names and item["FullName"].lower() not in names:
493
+ continue
494
+ moves = []
495
+ for base, (label, sign) in kinds.items():
496
+ for t in c.txns.get(base, []):
497
+ for k, lines in t.items():
498
+ if k.endswith("LineRet") and k != "ExpenseLineRet":
499
+ moves += [(t, label, sign * line["Quantity"]) for line in _arr(lines) if get(get(line, "ItemRef"), "FullName") == item["FullName"]]
500
+ moves.sort(key=lambda m: (m[0]["TxnDate"], m[0]["TxnNumber"]))
501
+ on_hand = item["QuantityOnHand"] - add_up(qty for _, _, qty in moves)
502
+ shown = []
503
+ for t, label, qty in moves:
504
+ on_hand += qty
505
+ if rng["from"] <= t["TxnDate"] <= rng["to"]:
506
+ party = text(get(nz(get(t, "CustomerRef"), get(t, "VendorRef"), get(t, "PayeeEntityRef")), "FullName"))
507
+ shown.append({"TxnID": t["TxnID"], "TxnType": label, "Date": t["TxnDate"], "Name": party, "RefNumber": text(nz(get(t, "RefNumber"), "")),
508
+ "Quantity": qty, "QuantityOnHand": on_hand, "AverageCost": item["AverageCost"], "ValueOnHand": money(on_hand * item["AverageCost"])})
509
+ if not shown:
510
+ continue
511
+ rows.append({"kind": "text", "label": item["FullName"]})
512
+ rows += [{"kind": "data", "label": "", "values": [x[k] for k in cols]} for x in shown]
513
+ end = shown[-1]
514
+ rows.append({"kind": "subtotal", "label": f"Total {item['FullName']}", "values": [end[k] if k in ("QuantityOnHand", "ValueOnHand") else "" for k in cols]})
515
+ types = {"Date": "DATETYPE", "Quantity": "QUANTYPE", "QuantityOnHand": "QUANTYPE", "AverageCost": "PRICETYPE", "ValueOnHand": "AMTTYPE"}
516
+ return self._report_xml("Inventory Valuation Detail", rng, None, [("", "Blank", "STRTYPE"), *((titles[k], k, types.get(k, "STRTYPE")) for k in cols)], rows)
517
+
518
+ def _sales_tax_liability(self, rng: dict[str, str]) -> dict:
519
+ """Sales Tax Liability, its TOTAL row only: the sample company charges no sales tax unless a test gives a sale a
520
+ SalesTaxTotal, and has no tax agencies to group by."""
521
+ c = self.company
522
+
523
+ def tax(base: str) -> float:
524
+ return add_up(to_num(get(t, "SalesTaxTotal")) for t in c.txns.get(base, []) if t["TxnDate"] <= rng["to"])
525
+
526
+ paid = add_up(t["Amount"] for t in c.txns.get("SalesTaxPaymentCheck", []) if t["TxnDate"] <= rng["to"])
527
+ owed = money(tax("Invoice") + tax("SalesReceipt") - tax("CreditMemo") - paid)
528
+ cols = [("", "Blank", "STRTYPE"), (["Sales Tax Payable", f"As of {rng['to']}"], "Amount", "AMTTYPE")]
529
+ return self._report_xml("Sales Tax Liability", rng, None, cols, [{"kind": "total", "label": "TOTAL", "values": [owed]}])
530
+
531
+ def _balance_sheet(self, rng: dict[str, str], basis: str) -> dict:
532
+ """Balance Sheet: today's account balances whatever the date or basis, without totals: the sample company keeps no
533
+ history of its balances and no equity close-out."""
534
+ groups = {"ASSETS": ("Bank", "AccountsReceivable", "OtherCurrentAsset", "FixedAsset", "OtherAsset"),
535
+ "LIABILITIES": ("AccountsPayable", "CreditCard", "OtherCurrentLiability", "LongTermLiability"), "EQUITY": ("Equity",)}
536
+ rows: list[dict] = []
537
+ for heading, kinds in groups.items():
538
+ rows.append({"kind": "text", "label": heading})
539
+ rows += [{"kind": "data", "label": a["Name"], "rowType": "account", "values": [money(a["Balance"])]}
540
+ for a in self.company.lists["Account"] if a.get("AccountType") in kinds]
541
+ return self._report_xml("Balance Sheet", {"from": rng["to"], "to": rng["to"]}, basis, [("", "Blank", "STRTYPE"), (rng["to"], "Amount", "AMTTYPE")], rows)
542
+
543
+ def _report_xml(self, title: str, rng: dict[str, str], basis: str | None, columns: list[tuple], rows: list[dict]) -> dict:
544
+ """Column titles may have several rows, top first, as QuickBooks sends them."""
545
+
546
+ def titles(t: Any) -> list[str]:
547
+ return t if isinstance(t, list) else [t]
548
+
549
+ out: dict[str, Any] = {"ReportTitle": title, "ReportSubtitle": rng["to"] if rng["from"] == rng["to"] else f"{rng['from']} through {rng['to']}"}
550
+ if basis:
551
+ out["ReportBasis"] = basis
552
+ out["NumRows"] = str(len(rows))
553
+ out["NumColumns"] = str(len(columns))
554
+ out["NumColTitleRows"] = str(max(len(titles(t)) for t, _, _ in columns))
555
+ out["ColDesc"] = [
556
+ {"@colID": str(i + 1), "@dataType": data_type, "ColTitle": [{"@titleRow": str(k + 1), **({"@value": v} if v else {})} for k, v in enumerate(titles(t))], "ColType": type_}
557
+ for i, (t, type_, data_type) in enumerate(columns)
558
+ ]
559
+ out["ReportData"] = _report_rows(rows)
560
+ return out
561
+
562
+ def _profit_and_loss(self, rng: dict[str, str], basis: str) -> dict:
563
+ c = self.company
564
+ totals: dict[str, float] = {}
565
+
566
+ def add(account: str, v: float) -> None:
567
+ totals[account] = money(totals.get(account, 0) + v)
568
+
569
+ items = {i["FullName"]: i for i in c.lists.get("ItemInventory", [])}
570
+
571
+ def in_range(r: Rec) -> bool:
572
+ return rng["from"] <= text(get(r, "TxnDate")) <= rng["to"]
573
+
574
+ for base in ("Invoice", "SalesReceipt"):
575
+ for t in [x for x in c.txns.get(base, []) if in_range(x)]:
576
+ for line in _arr(get(t, f"{base}LineRet")):
577
+ name = line["ItemRef"]["FullName"]
578
+ stock = items.get(name)
579
+ account = "Merchandise Sales" if stock else "Delivery Income" if name == "Delivery Fee" else "Installation Income"
580
+ add(account, line["Amount"])
581
+ if stock:
582
+ add("Cost of Goods Sold", to_num(get(line, "Quantity")) * to_num(get(stock, "AverageCost")))
583
+ for base in ("Bill", "Check", "CreditCardCharge", "VendorCredit"):
584
+ for t in [x for x in c.txns.get(base, []) if in_range(x)]:
585
+ for line in _arr(get(t, "ExpenseLineRet")):
586
+ add(line["AccountRef"]["FullName"], -line["Amount"] if base == "VendorCredit" else line["Amount"])
587
+
588
+ def of(type_: str) -> list[str]:
589
+ return [a["Name"] for a in c.lists["Account"] if a.get("AccountType") == type_ and a["Name"] in totals]
590
+
591
+ def total(names: list[str]) -> float:
592
+ return money(add_up(totals.get(n, 0) for n in names))
593
+
594
+ income, cogs, expense = of("Income"), of("CostOfGoodsSold"), of("Expense")
595
+ gross = money(total(income) - total(cogs))
596
+ net = money(gross - total(expense))
597
+
598
+ def data(names: list[str]) -> list[dict]:
599
+ return [{"kind": "data", "label": n, "rowType": "account", "values": [totals[n]]} for n in names]
600
+
601
+ rows = [
602
+ {"kind": "text", "label": "Ordinary Income/Expense"},
603
+ {"kind": "text", "label": "Income"},
604
+ *data(income),
605
+ {"kind": "subtotal", "label": "Total Income", "rowType": "account", "values": [total(income)]},
606
+ {"kind": "text", "label": "Cost of Goods Sold"},
607
+ *data(cogs),
608
+ {"kind": "subtotal", "label": "Total COGS", "rowType": "account", "values": [total(cogs)]},
609
+ {"kind": "data", "label": "Gross Profit", "values": [gross]},
610
+ {"kind": "text", "label": "Expense"},
611
+ *data(expense),
612
+ {"kind": "subtotal", "label": "Total Expense", "rowType": "account", "values": [total(expense)]},
613
+ {"kind": "subtotal", "label": "Net Ordinary Income", "values": [net]},
614
+ {"kind": "total", "label": "Net Income", "values": [net]},
615
+ ]
616
+ return self._report_xml("Profit & Loss", rng, basis, [("", "Label", "STRTYPE"), ("TOTAL", "Total", "AMTTYPE")], rows)
617
+
618
+ def _sales_by_item(self, rng: dict[str, str]) -> dict:
619
+ c = self.company
620
+ qty: dict[str, float] = {}
621
+ amt: dict[str, float] = {}
622
+ for base in ("Invoice", "SalesReceipt"):
623
+ for t in c.txns.get(base, []):
624
+ if text(get(t, "TxnDate")) < rng["from"] or text(get(t, "TxnDate")) > rng["to"]:
625
+ continue
626
+ for line in _arr(get(t, f"{base}LineRet")):
627
+ name = line["ItemRef"]["FullName"]
628
+ qty[name] = qty.get(name, 0) + to_num(get(line, "Quantity"))
629
+ amt[name] = money(amt.get(name, 0) + line["Amount"])
630
+ stock = {i["FullName"] for i in c.lists.get("ItemInventory", [])}
631
+
632
+ def group(label: str, names: list[str]) -> list[dict]:
633
+ if not names:
634
+ return []
635
+ return [
636
+ {"kind": "text", "label": label},
637
+ *({"kind": "data", "label": n, "rowType": "item", "values": [qty[n], amt[n]]} for n in names),
638
+ {"kind": "subtotal", "label": f"Total {label}", "values": [add_up(qty[n] for n in names), money(add_up(amt[n] for n in names))]},
639
+ ]
640
+
641
+ names = sorted(qty)
642
+ rows = [
643
+ *group("Inventory", [n for n in names if n in stock]),
644
+ *group("Service", [n for n in names if n not in stock]),
645
+ {"kind": "total", "label": "TOTAL", "values": [add_up(qty.values()), money(add_up(amt.values()))]},
646
+ ]
647
+ # QuickBooks titles the columns with the period on a first row, e.g. "Apr - Jun 25" over "Qty".
648
+ period = f"{rng['from']} - {rng['to']}"
649
+ return self._report_xml("Sales by Item Summary", rng, "Accrual", [("", "Label", "STRTYPE"), ([period, "Qty"], "Quantity", "QUANTYPE"), ([period, "Amount"], "Amount", "AMTTYPE")], rows)
650
+
651
+ def _sales_by_customer(self, rng: dict[str, str]) -> dict:
652
+ amt: dict[str, float] = {}
653
+ for base in ("Invoice", "SalesReceipt"):
654
+ for t in self.company.txns.get(base, []):
655
+ if text(get(t, "TxnDate")) < rng["from"] or text(get(t, "TxnDate")) > rng["to"]:
656
+ continue
657
+ name = t["CustomerRef"]["FullName"]
658
+ amt[name] = money(amt.get(name, 0) + nz(amount_of(t), 0))
659
+ rows = [{"kind": "data", "label": n, "rowType": "customer", "values": [amt[n]]} for n in sorted(amt)]
660
+ rows.append({"kind": "total", "label": "TOTAL", "values": [money(add_up(amt.values()))]})
661
+ return self._report_xml("Sales by Customer Summary", rng, "Accrual", [("", "Label", "STRTYPE"), ("TOTAL", "Total", "AMTTYPE")], rows)
662
+
663
+ def _open_items(self, ar: bool, as_of: str) -> list[dict]:
664
+ """What an aging lists on as_of: invoices and bills with something open, and payments and credits not applied yet (negative)."""
665
+ out: list[dict] = []
666
+
667
+ def add(type_: str, r: Rec, open_: Any, due: Any = MISSING) -> None:
668
+ if truthy(open_) and text(get(r, "TxnDate")) <= as_of:
669
+ out.append({
670
+ "type": type_, "id": text(get(r, "TxnID")), "ref": nz(_str(get(r, "RefNumber")), ""),
671
+ "party": text(get(nz(get(r, "CustomerRef"), get(r, "VendorRef")), "FullName")), "date": text(get(r, "TxnDate")), "due": due, "open": open_,
672
+ })
673
+
674
+ t = self.company.txns
675
+ if ar:
676
+ for i in t.get("Invoice", []):
677
+ add("Invoice", i, get(i, "BalanceRemaining"), text(get(i, "DueDate")))
678
+ for p in t.get("ReceivePayment", []):
679
+ add("Payment", p, -to_num(get(p, "UnusedPayment")))
680
+ else:
681
+ for b in t.get("Bill", []):
682
+ add("Bill", b, get(b, "OpenAmount"), text(get(b, "DueDate")))
683
+ for v in t.get("VendorCredit", []):
684
+ add("Credit", v, -to_num(get(v, "OpenAmount")))
685
+ return out
686
+
687
+ def _aging(self, type_: str, as_of: str) -> dict:
688
+ ar = type_ == "ARAgingSummary"
689
+ buckets: dict[str, list[float]] = {}
690
+ for x in self._open_items(ar, as_of):
691
+ i = _bucket(days_between(nz(x["due"], x["date"]), as_of))
692
+ b = buckets.get(x["party"]) or [0, 0, 0, 0, 0]
693
+ b[i] = money(b[i] + x["open"])
694
+ buckets[x["party"]] = b
695
+ rows = [{"kind": "data", "label": n, "rowType": "customer" if ar else "vendor", "values": [*buckets[n], money(add_up(buckets[n]))]} for n in sorted(buckets)]
696
+ totals = [money(add_up(b[i] for b in buckets.values())) for i in range(5)]
697
+ rows.append({"kind": "total", "label": "TOTAL", "values": [*totals, money(add_up(totals))]})
698
+ cols = [("", "Label", "STRTYPE"), *((t, "Total" if t == "TOTAL" else "Amount", "AMTTYPE") for t in [*BUCKETS, "TOTAL"])]
699
+ return self._report_xml("A/R Aging Summary" if ar else "A/P Aging Summary", {"from": as_of, "to": as_of}, None, cols, rows)
700
+
701
+ def _aging_detail(self, type_: str, as_of: str, party: Any) -> dict:
702
+ """One row per open item under each age bucket, laid out like QuickBooks' A/R and A/P Aging Detail."""
703
+ ar = type_ == "ARAgingDetail"
704
+ p = party.lower() if isinstance(party, str) else None
705
+ items = [x for x in self._open_items(ar, as_of) if not p or x["party"].lower() == p or x["party"].lower().startswith(f"{p}:")]
706
+ items.sort(key=lambda x: x["date"])
707
+
708
+ def total(xs: list[dict]) -> list:
709
+ return ["", "", "", "", "", "", money(add_up(x["open"] for x in xs)), ""]
710
+
711
+ rows: list[dict] = []
712
+ for i, label in enumerate(BUCKETS):
713
+ xs = [x for x in items if _bucket(days_between(nz(x["due"], x["date"]), as_of)) == i]
714
+ rows.append({"kind": "text", "label": label})
715
+ for x in xs:
716
+ late = days_between(x["due"], as_of) if truthy(x["due"]) else 0
717
+ rows.append({"kind": "data", "label": "", "values": [x["type"], x["date"], x["ref"], x["party"], nz(x["due"], ""), late if late > 0 else "", x["open"], x["id"]]})
718
+ rows.append({"kind": "subtotal", "label": f"Total {label}", "values": total(xs)})
719
+ rows.append({"kind": "total", "label": "TOTAL", "values": total(items)})
720
+ # As in QuickBooks, the first column has no title.
721
+ cols = [
722
+ ("", "Blank", "STRTYPE"), ("Type", "TxnType", "STRTYPE"), ("Date", "Date", "DATETYPE"), ("Num", "RefNumber", "STRTYPE"), ("Name", "Name", "STRTYPE"),
723
+ ("Due Date", "DueDate", "DATETYPE"), ("Aging", "Aging", "INTTYPE"), ("Open Balance", "Amount", "AMTTYPE"), ("", "TxnID", "STRTYPE"),
724
+ ]
725
+ return self._report_xml("A/R Aging Detail" if ar else "A/P Aging Detail", {"from": as_of, "to": as_of}, "Accrual", cols, rows)
726
+
727
+ # ---------------------------------------------------------------- writes
728
+
729
+ def _items(self) -> list[Rec]:
730
+ return [x for t in ITEM_TYPES for x in self.company.lists.get(t, [])]
731
+
732
+ def _find_ref(self, base: str, ref_value: Any) -> Rec | None:
733
+ r = ref_value
734
+ if not truthy(r):
735
+ return None
736
+ pool = self._items() if base == "Item" else self.company.lists.get(base, [])
737
+ for x in pool:
738
+ if truthy(get(r, "ListID")) and x.get("ListID") == r["ListID"]:
739
+ return x
740
+ if truthy(get(r, "FullName")) and text(nz(get(x, "FullName"), get(x, "Name"))).lower() == text(r["FullName"]).lower():
741
+ return x
742
+ return None
743
+
744
+ def _require_ref(self, base: str, ref_value: Any, where: str) -> Rec:
745
+ found = self._find_ref(base, ref_value)
746
+ if found is None:
747
+ name = text(nz(get(ref_value, "FullName"), get(ref_value, "ListID"), ""))
748
+ raise Status(3140, f'There is an invalid reference to QuickBooks {base} "{name}" in the {where}. QuickBooks error message: Invalid argument. The specified record does not exist in the list.')
749
+ return found
750
+
751
+ def _add(self, base: str, fields: Rec) -> Rec:
752
+ c = self.company
753
+ now = f"{c.today}T12:00:00"
754
+ if base in ("Customer", "Vendor", *ITEM_TYPES):
755
+ name = text(nz(get(fields, "Name"), ""))
756
+ if not name:
757
+ raise Status(3000, f"{base}Add needs a Name.")
758
+ pool = self._items() if base.startswith("Item") else c.lists.get(base, [])
759
+ if any(text(get(x, "Name")).lower() == name.lower() for x in pool):
760
+ raise Status(3100, f'The name "{name}" of the list element is already in use.')
761
+ rec = {
762
+ "ListID": new_list_id(c), "TimeCreated": now, "TimeModified": now, "EditSequence": "1800000000", "Name": name,
763
+ **({} if base == "Vendor" else {"FullName": name, "Sublevel": 0}), "IsActive": True, **fields, "Balance": 0,
764
+ }
765
+ c.lists.setdefault(base, []).append(rec)
766
+ return rec
767
+ party = nz(get(fields, "CustomerRef"), get(fields, "VendorRef"), get(fields, "PayeeEntityRef"))
768
+ if truthy(get(fields, "CustomerRef")):
769
+ self._require_ref("Customer", fields["CustomerRef"], base)
770
+ if truthy(get(fields, "VendorRef")):
771
+ self._require_ref("Vendor", fields["VendorRef"], base)
772
+ rec = {"TxnID": new_txn_id(c), "TimeCreated": now, "TimeModified": now, "EditSequence": "1800000000", "TxnNumber": c.seq, "TxnDate": nz(get(fields, "TxnDate"), c.today)}
773
+ for k, v in fields.items():
774
+ if re.search(r"Line(Group)?Add\Z", k):
775
+ rec[k[:-3] + "Ret"] = [self._line(base, line, i) for i, line in enumerate(_arr(v))]
776
+ elif k != "AppliedToTxnAdd":
777
+ rec[k] = v
778
+ if truthy(get(party, "FullName")) and not truthy(rec.get("RefNumber")) and base == "Invoice":
779
+ rec["RefNumber"] = str(1040 + len(c.txns.get("Invoice", [])) + 1)
780
+ line_total = money(add_up(get(line, "Amount") if truthy(get(line, "Amount")) else 0 for k, v in list(rec.items()) if re.search(r"LineRet\Z", k) for line in _arr(v)))
781
+ if base == "Invoice":
782
+ rec.update({"Subtotal": line_total, "SalesTaxTotal": 0, "AppliedAmount": 0, "BalanceRemaining": line_total, "IsPaid": False})
783
+ if not truthy(rec.get("DueDate")):
784
+ rec["DueDate"] = add_days(text(rec["TxnDate"]), 30)
785
+ elif base == "Bill":
786
+ rec.update({"AmountDue": line_total, "OpenAmount": line_total, "IsPaid": False})
787
+ if not truthy(rec.get("DueDate")):
788
+ rec["DueDate"] = add_days(text(rec["TxnDate"]), 30)
789
+ elif base == "ReceivePayment":
790
+ rec["TotalAmount"] = to_num(nz(get(fields, "TotalAmount"), 0))
791
+ rec["AppliedToTxnRet"] = self._apply_payment(rec, fields)
792
+ elif base == "CreditMemo":
793
+ rec.update({"Subtotal": line_total, "TotalAmount": line_total, "CreditRemaining": line_total})
794
+ elif truthy(line_total):
795
+ rec.update({"Subtotal": line_total, "TotalAmount": line_total})
796
+ c.txns.setdefault(base, []).append(rec)
797
+ recompute_balances(c)
798
+ return rec
799
+
800
+ def _line(self, base: str, line: Any, i: int) -> Rec:
801
+ line = _dict(line)
802
+ out: Rec = {"TxnLineID": f"{self.company.seq}-{i + 1}"}
803
+ self.company.seq += 1
804
+ for k, v in line.items():
805
+ if k != "TxnLineID":
806
+ out[k] = v
807
+ if truthy(get(line, "ItemRef")):
808
+ item = self._require_ref("Item", line["ItemRef"], f"{base} line")
809
+ out["ItemRef"] = {"ListID": get(item, "ListID"), "FullName": get(item, "FullName")}
810
+ if truthy(get(line, "AccountRef")):
811
+ acct = self._require_ref("Account", line["AccountRef"], f"{base} line")
812
+ out["AccountRef"] = {"ListID": get(acct, "ListID"), "FullName": get(acct, "FullName")}
813
+ if get(out, "Amount") is MISSING:
814
+ rate = to_num(nz(get(line, "Rate"), get(line, "Cost"), 0))
815
+ out["Amount"] = money(to_num(nz(get(line, "Quantity"), 1)) * rate)
816
+ else:
817
+ out["Amount"] = to_num(out["Amount"])
818
+ if get(line, "Quantity") is not MISSING:
819
+ out["Quantity"] = to_num(line["Quantity"])
820
+ return out
821
+
822
+ def _apply_payment(self, rec: Rec, fields: Rec) -> list[Rec]:
823
+ customer = get(self._find_ref("Customer", get(rec, "CustomerRef")), "FullName")
824
+ invoices = [i for i in self.company.txns.get("Invoice", []) if not truthy(i.get("IsPaid")) and get(i["CustomerRef"], "FullName") == customer]
825
+ wanted = _arr(get(fields, "AppliedToTxnAdd"))
826
+ left = rec["TotalAmount"]
827
+ applied: list[Rec] = []
828
+
829
+ def apply(inv: Rec, amount: float) -> None:
830
+ nonlocal left
831
+ amt = money(_js_min(amount, to_num(inv["BalanceRemaining"]), left))
832
+ if amt <= 0:
833
+ return
834
+ inv["BalanceRemaining"] = money(inv["BalanceRemaining"] - amt)
835
+ inv["AppliedAmount"] = money(inv["AppliedAmount"] - amt)
836
+ inv["IsPaid"] = inv["BalanceRemaining"] <= 0
837
+ left = money(left - amt)
838
+ applied.append({"TxnID": inv["TxnID"], "TxnType": "Invoice", "TxnDate": inv["TxnDate"], "RefNumber": get(inv, "RefNumber"), "Amount": amt})
839
+
840
+ if wanted:
841
+ for w in wanted:
842
+ inv = next((i for i in invoices if i.get("TxnID") == get(w, "TxnID")), None)
843
+ if inv is None:
844
+ raise Status(3120, f'Object "{text(get(w, "TxnID"))}" specified in the request cannot be found.')
845
+ apply(inv, to_num(nz(get(w, "PaymentAmount"), left)))
846
+ elif text(get(fields, "IsAutoApply")) == "true":
847
+ for inv in sorted(invoices, key=lambda i: text(get(i, "TxnDate"))):
848
+ apply(inv, left)
849
+ rec["UnusedPayment"] = left
850
+ rec["UnusedCredits"] = 0
851
+ return applied
852
+
853
+ def _mod(self, base: str, fields: Rec) -> Rec:
854
+ c = self.company
855
+ is_list = not truthy(get(fields, "TxnID"))
856
+ pool = (self._items() if base.startswith("Item") else c.lists.get(base, [])) if is_list else c.txns.get(base, [])
857
+ key_field = "ListID" if is_list else "TxnID"
858
+ rec = next((x for x in pool if get(x, key_field) == get(fields, key_field)), None)
859
+ if rec is None:
860
+ raise Status(3120, f'Object "{text(nz(get(fields, "ListID"), get(fields, "TxnID")))}" specified in the request cannot be found.')
861
+ if text(get(rec, "EditSequence")) != text(get(fields, "EditSequence")):
862
+ raise Status(3200, "The provided edit sequence is out-of-date.")
863
+ for k, v in fields.items():
864
+ if k in ("ListID", "TxnID", "EditSequence"):
865
+ continue
866
+ if re.search(r"LineMod\Z", k):
867
+ key = k[:-3] + "Ret"
868
+ old = _arr(get(rec, key))
869
+ lines = []
870
+ for i, line in enumerate(_arr(v)):
871
+ line = _dict(line)
872
+ prev = next((o for o in old if get(o, "TxnLineID") == get(line, "TxnLineID")), None)
873
+ lines.append({**prev, **self._line(base, {**prev, **line}, i), "TxnLineID": get(prev, "TxnLineID")} if prev is not None else self._line(base, line, i))
874
+ rec[key] = lines
875
+ else:
876
+ rec[k] = v
877
+ rec["EditSequence"] = text(to_num(get(rec, "EditSequence")) + 1)
878
+ rec["TimeModified"] = f"{c.today}T12:00:00"
879
+ if base == "Invoice":
880
+ sub = money(add_up(to_num(get(line, "Amount")) for line in _arr(get(rec, "InvoiceLineRet"))))
881
+ rec["Subtotal"] = sub
882
+ rec["BalanceRemaining"] = money(sub + to_num(get(rec, "AppliedAmount")))
883
+ recompute_balances(c)
884
+ return rec
885
+
886
+ def _delete_txn(self, message: str, rq: Rec) -> Rec:
887
+ c = self.company
888
+ type_ = text(nz(get(rq, "TxnDelType"), get(rq, "TxnVoidType")))
889
+ rows = c.txns.get(type_, [])
890
+ i = next((j for j, x in enumerate(rows) if x.get("TxnID") == get(rq, "TxnID")), -1)
891
+ if i < 0:
892
+ raise Status(3120, f'Object "{text(get(rq, "TxnID"))}" specified in the request cannot be found.')
893
+ rec = rows[i]
894
+ if message == "TxnDelRq":
895
+ rows.pop(i)
896
+ recompute_balances(c)
897
+ ret = {"TxnDelType": type_, "TxnID": rec["TxnID"], "TimeCreated": rec.get("TimeCreated"), "TimeDeleted": f"{c.today}T12:00:00", "RefNumber": get(rec, "RefNumber")}
898
+ self.deleted["TxnDeletedRet"].append(ret)
899
+ return {k: v for k, v in ret.items() if k != "TimeCreated"}
900
+ for k in ("Subtotal", "TotalAmount", "AmountDue", "OpenAmount", "BalanceRemaining", "Amount"):
901
+ if k in rec:
902
+ rec[k] = 0
903
+ rec["Memo"] = f"VOID: {text(nz(get(rec, 'Memo'), ''))}".strip()
904
+ recompute_balances(c)
905
+ return {"TxnVoidType": type_, "TxnID": rec["TxnID"]}
906
+
907
+ def _delete_list(self, rq: Rec) -> Rec:
908
+ c = self.company
909
+ type_ = text(get(rq, "ListDelType"))
910
+ rows = c.lists.get(type_, [])
911
+ i = next((j for j, x in enumerate(rows) if x.get("ListID") == get(rq, "ListID")), -1)
912
+ if i < 0:
913
+ raise Status(3120, f'Object "{text(get(rq, "ListID"))}" specified in the request cannot be found.')
914
+ rec = rows.pop(i)
915
+ ret = {"ListDelType": type_, "ListID": rec["ListID"], "TimeDeleted": f"{c.today}T12:00:00", "FullName": nz(get(rec, "FullName"), get(rec, "Name"))}
916
+ self.deleted["ListDeletedRet"].append({**ret, "TimeCreated": rec.get("TimeCreated")})
917
+ return ret