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.
- qbd_agent_kit/__init__.py +3 -0
- qbd_agent_kit/__main__.py +3 -0
- qbd_agent_kit/bridge/__init__.py +0 -0
- qbd_agent_kit/bridge/com.py +162 -0
- qbd_agent_kit/cli.py +459 -0
- qbd_agent_kit/config.py +94 -0
- qbd_agent_kit/demo/__init__.py +0 -0
- qbd_agent_kit/demo/company.py +480 -0
- qbd_agent_kit/demo/transport.py +917 -0
- qbd_agent_kit/errors.py +130 -0
- qbd_agent_kit/jsutil.py +261 -0
- qbd_agent_kit/qb/__init__.py +0 -0
- qbd_agent_kit/qb/catalog.py +164 -0
- qbd_agent_kit/qb/client.py +164 -0
- qbd_agent_kit/qb/dates.py +161 -0
- qbd_agent_kit/qbxml/__init__.py +0 -0
- qbd_agent_kit/qbxml/build.py +147 -0
- qbd_agent_kit/qbxml/parse.py +226 -0
- qbd_agent_kit/qbxml/schema.json +1 -0
- qbd_agent_kit/qbxml/schema.py +63 -0
- qbd_agent_kit/qbxml/xml.py +150 -0
- qbd_agent_kit/safety/__init__.py +0 -0
- qbd_agent_kit/safety/audit.py +55 -0
- qbd_agent_kit/safety/privacy.py +52 -0
- qbd_agent_kit/tools/__init__.py +32 -0
- qbd_agent_kit/tools/args.py +95 -0
- qbd_agent_kit/tools/common.py +158 -0
- qbd_agent_kit/tools/files.py +453 -0
- qbd_agent_kit/tools/insights.py +2646 -0
- qbd_agent_kit/tools/read.py +963 -0
- qbd_agent_kit/tools/write.py +881 -0
- qbd_agent_kit-0.2.0.dist-info/METADATA +264 -0
- qbd_agent_kit-0.2.0.dist-info/RECORD +37 -0
- qbd_agent_kit-0.2.0.dist-info/WHEEL +5 -0
- qbd_agent_kit-0.2.0.dist-info/entry_points.txt +2 -0
- qbd_agent_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
- qbd_agent_kit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|