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,94 @@
1
+ """Settings come from environment variables, so any AI app or script that starts `qbd` can set them.
2
+ Everything is safe by default: read-only."""
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass, field, replace
9
+ from typing import Mapping
10
+
11
+ from .jsutil import to_number
12
+
13
+ DEFAULT_WRITE_ENTITIES = [
14
+ "customer",
15
+ "vendor",
16
+ "invoice",
17
+ "estimate",
18
+ "sales_order",
19
+ "sales_receipt",
20
+ "credit_memo",
21
+ "receive_payment",
22
+ "bill",
23
+ "purchase_order",
24
+ ]
25
+
26
+
27
+ @dataclass
28
+ class Config:
29
+ app_name: str = "QBD Agent Kit" # the name QuickBooks shows in its permission dialog
30
+ app_id: str = ""
31
+ company_file: str = "" # "" = whichever file is open in QuickBooks
32
+ qbxml_version: str | None = None # force a qbXML version instead of asking QuickBooks
33
+ allow_writes: bool = False # draft/commit exist only when true
34
+ allow_delete: bool = False # delete and void need this as well
35
+ write_entities: list[str] = field(default_factory=lambda: list(DEFAULT_WRITE_ENTITIES))
36
+ max_amount: float = 10_000 # a single draft above this total is refused
37
+ timeout_seconds: float = 120
38
+ open_timeout_seconds: float = 120
39
+ audit_log: str = ""
40
+ mask_personal_data: bool = True
41
+ demo: bool = False
42
+ state_dir: str = ""
43
+
44
+
45
+ def _clean(env: Mapping[str, str]) -> dict[str, str]:
46
+ """Empty, or a placeholder an app left unfilled (e.g. "${user_config.x}"), counts as not set."""
47
+ return {k: v for k, v in env.items() if not k.startswith("QBD_") or (v is not None and not re.fullmatch(r"\$\{.*\}", v.strip(), re.S))}
48
+
49
+
50
+ def _flag(v: str | None, fallback: bool) -> bool:
51
+ if v is None or v.strip() == "":
52
+ return fallback
53
+ return re.fullmatch(r"1|true|yes|on", v.strip(), re.I) is not None
54
+
55
+
56
+ def _num(v: str | None, fallback: float) -> float:
57
+ n = to_number(v) if v is not None and v.strip() != "" else math.nan
58
+ if not math.isfinite(n) or n <= 0:
59
+ return fallback
60
+ return int(n) if n.is_integer() else n
61
+
62
+
63
+ def state_dir(env: Mapping[str, str]) -> str:
64
+ """Where drafts, the audit log and the lock live: %LOCALAPPDATA%\\qbd-agent-kit on Windows."""
65
+ if env.get("LOCALAPPDATA"):
66
+ return os.path.join(env["LOCALAPPDATA"], "qbd-agent-kit")
67
+ base = env.get("XDG_STATE_HOME") or os.path.join(os.path.expanduser("~"), ".local", "state")
68
+ return os.path.join(base, "qbd-agent-kit")
69
+
70
+
71
+ def load_config(raw_env: Mapping[str, str] | None = None, **overrides: object) -> Config:
72
+ env = _clean(os.environ if raw_env is None else raw_env)
73
+ demo = bool(overrides["demo"]) if "demo" in overrides else _flag(env.get("QBD_DEMO"), False)
74
+ entities = [s.lower() for s in re.split(r"[\s,]+", env.get("QBD_WRITE_ENTITIES", "")) if s]
75
+ sdir = state_dir(env)
76
+ config = Config(
77
+ app_name=(env.get("QBD_APP_NAME") or "").strip() or "QBD Agent Kit",
78
+ app_id=(env.get("QBD_APP_ID") or "").strip(),
79
+ company_file=(env.get("QBD_COMPANY_FILE") or "").strip(),
80
+ qbxml_version=(env.get("QBD_QBXML_VERSION") or "").strip() or None,
81
+ # The demo company lives in memory, so trying changes there is harmless.
82
+ allow_writes=_flag(env.get("QBD_ALLOW_WRITES"), demo),
83
+ allow_delete=_flag(env.get("QBD_ALLOW_DELETE"), demo),
84
+ write_entities=list(dict.fromkeys(entities)) if entities else list(DEFAULT_WRITE_ENTITIES),
85
+ max_amount=_num(env.get("QBD_MAX_AMOUNT"), 10_000),
86
+ timeout_seconds=_num(env.get("QBD_TIMEOUT_SECONDS"), 120),
87
+ open_timeout_seconds=_num(env.get("QBD_OPEN_TIMEOUT_SECONDS"), 120),
88
+ audit_log=(env.get("QBD_AUDIT_LOG") or "").strip() or os.path.join(sdir, "audit-demo.jsonl" if demo else "audit.jsonl"),
89
+ mask_personal_data=_flag(env.get("QBD_MASK_PERSONAL_DATA"), True),
90
+ demo=demo,
91
+ state_dir=sdir,
92
+ )
93
+ rest = {k: v for k, v in overrides.items() if k != "demo"}
94
+ return replace(config, **rest) if rest else config
File without changes
@@ -0,0 +1,480 @@
1
+ """A small made-up hardware and supply company, dated around "today" so its numbers always look current.
2
+ Every name, email (.example) and phone number (555-01xx) is fictional. It includes the problems the kit
3
+ is meant to find: overdue customers, a duplicate bill, a bill paid twice, undeposited payments, an
4
+ unapplied payment, an unapplied vendor credit, negative stock, a future-dated invoice, an old PO.
5
+
6
+ The numbers come from a seeded generator, so the company is the same on every run and every PC."""
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import re
11
+ from typing import Any, Callable
12
+
13
+ from ..jsutil import add_up, get, round2 as money, truthy
14
+ from ..qb.dates import add_days, month_start
15
+
16
+ Rec = dict[str, Any]
17
+
18
+
19
+ class DemoCompany:
20
+ def __init__(self, today: str):
21
+ self.today = today
22
+ self.name = "Bayside Hardware & Supply (sample company)"
23
+ self.closing_date = add_days(month_start(today, -2), -1)
24
+ self.lists: dict[str, list[Rec]] = {} # "Customer" -> CustomerRet records
25
+ self.txns: dict[str, list[Rec]] = {} # "Invoice" -> InvoiceRet records
26
+ self.undeposited: list[Rec] = [] # ReceivePaymentToDepositRet records
27
+ self.form1099: list[Rec] = [] # Form1099CategoryAccountMappingRet records
28
+ self.personal_data = False # whether the app may read tax IDs and card numbers
29
+ self.cleared: dict[str, str] = {} # TxnID -> cleared status, where it is not the usual one
30
+ self.home_currency: str | None = None # multicurrency is on when set
31
+ self.seq = 1
32
+
33
+
34
+ def _i32(x: int) -> int:
35
+ x &= 0xFFFFFFFF
36
+ return x - 0x100000000 if x & 0x80000000 else x
37
+
38
+
39
+ def _imul(a: int, b: int) -> int:
40
+ return _i32((a & 0xFFFFFFFF) * (b & 0xFFFFFFFF))
41
+
42
+
43
+ def rng(seed: int) -> Callable[[], float]:
44
+ """mulberry32: small, fast and the same everywhere."""
45
+ state = _i32(seed)
46
+
47
+ def next_value() -> float:
48
+ nonlocal state
49
+ state = _i32(state + 0x6D2B79F5)
50
+ t = _imul(state ^ ((state & 0xFFFFFFFF) >> 15), 1 | state)
51
+ t = _i32(t + _imul(t ^ ((t & 0xFFFFFFFF) >> 7), 61 | t)) ^ t
52
+ return ((t ^ ((t & 0xFFFFFFFF) >> 14)) & 0xFFFFFFFF) / 4294967296
53
+
54
+ return next_value
55
+
56
+
57
+ def ref(name: str, id_: str | None = None) -> Rec:
58
+ return {"ListID": id_, "FullName": name} if id_ else {"FullName": name}
59
+
60
+
61
+ def new_list_id(c: DemoCompany) -> str:
62
+ n = 0x80000000 + c.seq
63
+ c.seq += 1
64
+ return f"{n:X}-1700000000"
65
+
66
+
67
+ def new_txn_id(c: DemoCompany) -> str:
68
+ n = 0x1000 + c.seq
69
+ c.seq += 1
70
+ return f"{n:X}-1700000000"
71
+
72
+
73
+ ACCOUNTS = [
74
+ ("1010", "Business Checking", "Bank"),
75
+ ("1020", "Business Savings", "Bank"),
76
+ ("1100", "Accounts Receivable", "AccountsReceivable"),
77
+ ("1300", "Inventory Asset", "OtherCurrentAsset"),
78
+ ("1499", "Undeposited Funds", "OtherCurrentAsset"),
79
+ ("2000", "Accounts Payable", "AccountsPayable"),
80
+ ("2100", "Business Visa", "CreditCard"),
81
+ ("2200", "Sales Tax Payable", "OtherCurrentLiability"),
82
+ ("3000", "Opening Balance Equity", "Equity"),
83
+ ("3100", "Owner's Equity", "Equity"),
84
+ ("4000", "Merchandise Sales", "Income"),
85
+ ("4100", "Installation Income", "Income"),
86
+ ("4200", "Delivery Income", "Income"),
87
+ ("5000", "Cost of Goods Sold", "CostOfGoodsSold"),
88
+ ("6000", "Advertising and Promotion", "Expense"),
89
+ ("6100", "Freight and Delivery", "Expense"),
90
+ ("6200", "Insurance Expense", "Expense"),
91
+ ("6300", "Office Supplies", "Expense"),
92
+ ("6400", "Rent Expense", "Expense"),
93
+ ("6500", "Utilities", "Expense"),
94
+ ("6999", "Uncategorized Expenses", "Expense"),
95
+ ]
96
+
97
+ # name, contact, terms days, pays late
98
+ CUSTOMERS = [
99
+ ("Alder Street Cafe", "Maria Lopez", 30, False),
100
+ ("Blue Ridge Builders", "Tom Becker", 30, False),
101
+ ("Cedar Point Marina", "Dana Walsh", 30, False),
102
+ ("Dunmore Property Management", "Raj Patel", 30, True),
103
+ ("Elm Grove School District", "Accounts Payable Office", 30, False),
104
+ ("Foxhall Landscaping", "Chris Foxhall", 15, False),
105
+ ("Granite Peak Contractors", "Lee Morgan", 30, True),
106
+ ("Harper & Lowe Architects", "Sam Harper", 30, False),
107
+ ("Ironwood Fitness", "Jordan Reyes", 30, False),
108
+ ("Juniper Dental Group", "Office Manager", 30, False),
109
+ ("Kestrel Auto Repair", "Pat Kowalski", 15, False),
110
+ ("Lakeside Event Center", "Alex Chen", 30, False),
111
+ ]
112
+
113
+ # name, terms days, kind
114
+ VENDORS = [
115
+ ("Summit Lumber Supply", 30, "stock"),
116
+ ("Pioneer Paint Distributors", 30, "stock"),
117
+ ("Metro Electric Wholesale", 30, "stock"),
118
+ ("Harbor Hardware Wholesale", 30, "stock"),
119
+ ("Coastal Freight Lines", 15, "Freight and Delivery"),
120
+ ("City Power & Light", 15, "Utilities"),
121
+ ("Oakline Insurance Agency", 30, "Insurance Expense"),
122
+ ("Brightway Office Products", 30, "Office Supplies"),
123
+ ("Riverbend Properties", 0, "Rent Expense"),
124
+ ("Social Ads Co.", 0, "Advertising and Promotion"),
125
+ ]
126
+
127
+ # name, cost, price, vendor, reorder point, max, on hand
128
+ STOCK = [
129
+ ("Deck Screws 5lb", 18.5, 32.99, "Harbor Hardware Wholesale", 40, 150, 22),
130
+ ("Cedar Board 2x6x8", 14.2, 24.5, "Summit Lumber Supply", 100, 400, 260),
131
+ ("Pressure-Treated Post 4x4x8", 11.75, 19.99, "Summit Lumber Supply", 60, 240, 48),
132
+ ("Exterior Paint 1gal White", 21, 38, "Pioneer Paint Distributors", 30, 120, 75),
133
+ ("Exterior Paint 5gal White", 89, 149, "Pioneer Paint Distributors", 8, 30, 5),
134
+ ("Primer 1gal", 13.4, 24, "Pioneer Paint Distributors", 24, 96, 31),
135
+ ("LED Flood Light 50W", 27.6, 54.99, "Metro Electric Wholesale", 15, 60, -3),
136
+ ("Outdoor Outlet Cover", 4.1, 9.49, "Metro Electric Wholesale", 40, 200, 140),
137
+ ("Wire 12/2 250ft", 82, 129, "Metro Electric Wholesale", 6, 24, 9),
138
+ ("Wood Stain 1gal Cedar", 19.8, 36.5, "Pioneer Paint Distributors", 20, 80, 12),
139
+ ("Hinge Set Heavy Duty", 6.3, 12.99, "Harbor Hardware Wholesale", 30, 120, 64),
140
+ ("Concrete Mix 80lb", 5.4, 8.99, "Summit Lumber Supply", 80, 300, 95),
141
+ ]
142
+
143
+
144
+ def _slug(s: str) -> str:
145
+ s = re.sub(r"[^a-z0-9]+", "-", s.lower().replace("&", "and"))
146
+ s = s[1:] if s.startswith("-") else s
147
+ return s[:-1] if s.endswith("-") else s
148
+
149
+
150
+ def create_demo_company(today: str) -> DemoCompany:
151
+ r = rng(20261020)
152
+
153
+ def pick(xs: list) -> Any:
154
+ return xs[math.floor(r() * len(xs))]
155
+
156
+ def between(a: int, b: int) -> int:
157
+ return a + math.floor(r() * (b - a + 1))
158
+
159
+ c = DemoCompany(today)
160
+ counters = {"invoice": 1040, "check": 5210, "bill": 0}
161
+
162
+ def next_number(key: str) -> int:
163
+ n = counters[key]
164
+ counters[key] += 1
165
+ return n
166
+
167
+ def time(date: str) -> str:
168
+ return f"{date}T10:15:00"
169
+
170
+ def lst(base: str, rec: Rec) -> Rec:
171
+ full = {"ListID": new_list_id(c), "TimeCreated": time(add_days(today, -400)), "TimeModified": time(add_days(today, -30)), "EditSequence": "1700000000", **rec}
172
+ c.lists.setdefault(base, []).append(full)
173
+ return full
174
+
175
+ def txn(base: str, rec: Rec) -> Rec:
176
+ date = min(rec["TxnDate"], today) # a transaction dated ahead was still entered by today
177
+ full = {"TxnID": new_txn_id(c), "TimeCreated": time(date), "TimeModified": time(date), "EditSequence": "1700000000", "TxnNumber": c.seq, **rec}
178
+ c.txns.setdefault(base, []).append(full)
179
+ return full
180
+
181
+ for num, name, type_ in ACCOUNTS:
182
+ lst("Account", {"Name": name, "FullName": name, "IsActive": True, "Sublevel": 0, "AccountType": type_, "AccountNumber": num, "Balance": 0, "TotalBalance": 0,
183
+ **({"SpecialAccountType": "SalesTaxPayable"} if name == "Sales Tax Payable" else {})})
184
+ for name, days in (("Net 30", 30), ("Net 15", 15), ("Due on receipt", 0)):
185
+ lst("StandardTerms", {"Name": name, "IsActive": True, "StdDueDays": days})
186
+ for name in ("Check", "ACH", "Visa", "Cash"):
187
+ lst("PaymentMethod", {"Name": name, "IsActive": True})
188
+
189
+ customers = [
190
+ lst("Customer", {
191
+ "Name": name, "FullName": name, "IsActive": True, "Sublevel": 0, "CompanyName": name, "Contact": contact,
192
+ "Phone": f"555-01{between(10, 99)}", "Email": f"billing@{_slug(name)}.example",
193
+ "TermsRef": ref("Net 15" if terms == 15 else "Net 30"), "Balance": 0, "TotalBalance": 0,
194
+ })
195
+ for name, contact, terms, _late in CUSTOMERS
196
+ ]
197
+ # A near-duplicate entered by someone who did not search first.
198
+ lst("Customer", {
199
+ "Name": "Blue Ridge Builders Inc.", "FullName": "Blue Ridge Builders Inc.", "IsActive": True, "Sublevel": 0,
200
+ "CompanyName": "Blue Ridge Builders Inc.", "Email": customers[1]["Email"], "Balance": 0, "TotalBalance": 0,
201
+ })
202
+ cash_customer = lst("Customer", {"Name": "Cash Sales", "FullName": "Cash Sales", "IsActive": True, "Sublevel": 0, "Balance": 0, "TotalBalance": 0})
203
+
204
+ for name, terms, kind in VENDORS:
205
+ lst("Vendor", {
206
+ "Name": name, "IsActive": True, "CompanyName": name, "Phone": f"555-01{between(10, 99)}",
207
+ "Email": f"ar@{_slug(name)}.example", "TermsRef": ref("Net 15" if terms == 15 else "Due on receipt" if terms == 0 else "Net 30"), "Balance": 0,
208
+ **({} if kind == "stock" else {"PrefillAccountRef": [ref(kind)]}),
209
+ **({"IsVendorEligibleFor1099": True, "VendorAddress": {"Addr1": "88 Riverbend Road", "City": "Riverbend", "State": "CA"}}
210
+ if name == "Riverbend Properties" else {}),
211
+ })
212
+ # The rent account is mapped to 1099-MISC box 1, with the threshold QuickBooks shipped before 2026.
213
+ c.form1099 = [{"FormType": "1099-MISC", "IsFiling1099Misc": True,
214
+ "CategoryAccountMapping": [{"MappingCategory": "Box 1: Rents", "AccountRef": [ref("Rent Expense")], "Threshold": 600}]}]
215
+
216
+ for name, cost, price, vendor, rp, max_, on_hand in STOCK:
217
+ lst("ItemInventory", {
218
+ "Name": name, "FullName": name, "IsActive": True, "Sublevel": 0, "SalesDesc": name, "SalesPrice": price,
219
+ "IncomeAccountRef": ref("Merchandise Sales"), "PurchaseDesc": name, "PurchaseCost": cost, "COGSAccountRef": ref("Cost of Goods Sold"),
220
+ "PrefVendorRef": ref(vendor), "AssetAccountRef": ref("Inventory Asset"), "ReorderPoint": rp, "Max": max_,
221
+ "QuantityOnHand": on_hand, "AverageCost": cost, "QuantityOnOrder": 0, "QuantityOnSalesOrder": 0,
222
+ })
223
+ lst("ItemService", {
224
+ "Name": "Installation Labor", "FullName": "Installation Labor", "IsActive": True, "Sublevel": 0,
225
+ "SalesOrPurchase": {"Desc": "Installation labor, per hour", "Price": 85, "AccountRef": ref("Installation Income")},
226
+ })
227
+ lst("ItemOtherCharge", {
228
+ "Name": "Delivery Fee", "FullName": "Delivery Fee", "IsActive": True, "Sublevel": 0,
229
+ "SalesOrPurchase": {"Desc": "Local delivery", "Price": 45, "AccountRef": ref("Delivery Income")},
230
+ })
231
+ stock = c.lists["ItemInventory"]
232
+
233
+ def sales_lines(prefix: str, n: int) -> list[Rec]:
234
+ lines: list[Rec] = []
235
+ for i in range(n):
236
+ item = pick(stock)
237
+ qty = between(2, 24)
238
+ lines.append({"TxnLineID": f"{prefix}-{i + 1}", "ItemRef": ref(item["FullName"], item["ListID"]), "Desc": item["SalesDesc"], "Quantity": qty, "Rate": item["SalesPrice"], "Amount": money(qty * item["SalesPrice"])})
239
+ if r() < 0.25:
240
+ hours = between(2, 8)
241
+ lines.append({"TxnLineID": f"{prefix}-L", "ItemRef": ref("Installation Labor"), "Desc": "Installation labor, per hour", "Quantity": hours, "Rate": 85, "Amount": hours * 85})
242
+ if r() < 0.2:
243
+ lines.append({"TxnLineID": f"{prefix}-D", "ItemRef": ref("Delivery Fee"), "Desc": "Local delivery", "Quantity": 1, "Rate": 45, "Amount": 45})
244
+ return lines
245
+
246
+ def total(lines: list[Rec]) -> float:
247
+ return money(add_up(line["Amount"] for line in lines))
248
+
249
+ def payment(customer: Rec, date: str, amount: float, applied: list[Rec], unused: float = 0) -> Rec:
250
+ return txn("ReceivePayment", {
251
+ "TxnDate": date, "RefNumber": str(next_number("check")), "CustomerRef": ref(customer["FullName"], customer["ListID"]),
252
+ "ARAccountRef": ref("Accounts Receivable"), "TotalAmount": money(amount), "PaymentMethodRef": ref(pick(["Check", "ACH"])),
253
+ "DepositToAccountRef": ref("Undeposited Funds"), "UnusedPayment": money(unused), "UnusedCredits": 0, "AppliedToTxnRet": applied,
254
+ })
255
+
256
+ # Invoices over the last five months, and the payments that settled them.
257
+ for i in range(70):
258
+ name, _contact, terms, late = CUSTOMERS[i % len(CUSTOMERS)]
259
+ customer = next(x for x in customers if x["Name"] == name)
260
+ date = add_days(today, -between(1, 150))
261
+ due = add_days(date, terms)
262
+ lines = sales_lines(f"I{i}", between(1, 3))
263
+ sub = total(lines)
264
+ overdue = due < today
265
+ chance = 0.85 if date < add_days(today, -60) else 0.5 if overdue else 0.25
266
+ if late:
267
+ chance *= 0.35
268
+ paid = r() < chance
269
+ partial = not paid and overdue and r() < 0.12
270
+ inv = txn("Invoice", {
271
+ "CustomerRef": ref(name, customer["ListID"]), "ARAccountRef": ref("Accounts Receivable"), "TxnDate": date,
272
+ "RefNumber": str(next_number("invoice")), "TermsRef": ref("Net 15" if terms == 15 else "Net 30"), "DueDate": due,
273
+ "Subtotal": sub, "SalesTaxPercentage": 0, "SalesTaxTotal": 0, "AppliedAmount": 0, "BalanceRemaining": sub, "IsPaid": False,
274
+ "InvoiceLineRet": lines,
275
+ })
276
+ if paid or partial:
277
+ amount = sub if paid else money(sub / 2)
278
+ pay_date = min(add_days(date, between(5, terms + 12)), add_days(today, -1))
279
+ payment(customer, pay_date, amount, [{"TxnID": inv["TxnID"], "TxnType": "Invoice", "TxnDate": date, "RefNumber": inv["RefNumber"], "Amount": amount}])
280
+ inv["AppliedAmount"] = -amount
281
+ inv["BalanceRemaining"] = money(sub - amount)
282
+ inv["IsPaid"] = paid
283
+ # An invoice typed with the wrong month: dated 45 days in the future.
284
+ customer = customers[11]
285
+ lines = sales_lines("F", 2)
286
+ txn("Invoice", {
287
+ "CustomerRef": ref(customer["FullName"], customer["ListID"]), "ARAccountRef": ref("Accounts Receivable"),
288
+ "TxnDate": add_days(today, 45), "RefNumber": str(next_number("invoice")), "TermsRef": ref("Net 30"), "DueDate": add_days(today, 75),
289
+ "Subtotal": total(lines), "SalesTaxPercentage": 0, "SalesTaxTotal": 0, "AppliedAmount": 0, "BalanceRemaining": total(lines), "IsPaid": False, "InvoiceLineRet": lines,
290
+ })
291
+ # A customer payment nobody applied to an invoice.
292
+ payment(customers[9], add_days(today, -20), 500, [], 500)
293
+
294
+ # Cash sales.
295
+ for i in range(8):
296
+ lines = sales_lines(f"S{i}", between(1, 2))
297
+ txn("SalesReceipt", {
298
+ "CustomerRef": ref("Cash Sales", cash_customer["ListID"]), "TxnDate": add_days(today, -between(1, 60)), "RefNumber": f"CS-{300 + i}",
299
+ "PaymentMethodRef": ref(pick(["Cash", "Visa"])), "DepositToAccountRef": ref("Undeposited Funds"),
300
+ "Subtotal": total(lines), "SalesTaxTotal": 0, "TotalAmount": total(lines), "SalesReceiptLineRet": lines,
301
+ })
302
+
303
+ # Payments and cash sales not yet deposited: the last three days, plus two forgotten ones.
304
+ payments = c.txns.get("ReceivePayment", [])
305
+ forgotten = {p["TxnID"] for p in [x for x in payments if x["TxnDate"] < add_days(today, -9)][-2:]}
306
+ for t in payments + c.txns.get("SalesReceipt", []):
307
+ recent = t["TxnDate"] >= add_days(today, -3)
308
+ if not recent and t["TxnID"] not in forgotten:
309
+ continue
310
+ c.undeposited.append({
311
+ "TxnID": t["TxnID"], "TxnType": "SalesReceipt" if "SalesReceiptLineRet" in t else "ReceivePayment",
312
+ "CustomerRef": t["CustomerRef"], "TxnDate": t["TxnDate"], "RefNumber": t["RefNumber"], "Amount": t["TotalAmount"],
313
+ })
314
+
315
+ # Estimates and sales orders; one estimate and one sales order have sat open for months.
316
+ for i in range(6):
317
+ customer = customers[(i * 5) % len(customers)]
318
+ lines = sales_lines(f"E{i}", 2)
319
+ txn("Estimate", {
320
+ "CustomerRef": ref(customer["FullName"], customer["ListID"]), "TxnDate": add_days(today, -(130 if i == 0 else between(5, 60))),
321
+ "RefNumber": f"E-{210 + i}", "DueDate": add_days(today, 30), "Subtotal": total(lines), "TotalAmount": total(lines), "IsActive": i % 2 == 0, "EstimateLineRet": lines,
322
+ })
323
+ for i in range(4):
324
+ customer = customers[(i * 3 + 1) % len(customers)]
325
+ lines = sales_lines(f"O{i}", 2)
326
+ for line in lines:
327
+ item = next((s for s in stock if s["FullName"] == line["ItemRef"]["FullName"]), None)
328
+ if item:
329
+ item["QuantityOnSalesOrder"] = item["QuantityOnSalesOrder"] + line["Quantity"]
330
+ txn("SalesOrder", {
331
+ "CustomerRef": ref(customer["FullName"], customer["ListID"]), "TxnDate": add_days(today, -(100 if i == 0 else between(2, 20))),
332
+ "RefNumber": f"SO-{880 + i}", "DueDate": add_days(today, 10), "Subtotal": total(lines), "TotalAmount": total(lines),
333
+ "IsManuallyClosed": False, "IsFullyInvoiced": False, "SalesOrderLineRet": lines,
334
+ })
335
+
336
+ # Purchase orders: two still open, one of them three months old.
337
+ def po(vendor: str, date: str, item: str, qty: int, open_: bool, ref_number: str) -> None:
338
+ it = next(s for s in stock if s["FullName"] == item)
339
+ if open_:
340
+ it["QuantityOnOrder"] = it["QuantityOnOrder"] + qty
341
+ txn("PurchaseOrder", {
342
+ "VendorRef": ref(vendor), "TxnDate": date, "RefNumber": ref_number, "DueDate": add_days(date, 14),
343
+ "TotalAmount": money(qty * it["PurchaseCost"]), "IsManuallyClosed": False, "IsFullyReceived": not open_,
344
+ "PurchaseOrderLineRet": [{"TxnLineID": f"{ref_number}-1", "ItemRef": ref(item, it["ListID"]), "Desc": item, "Quantity": qty, "Rate": it["PurchaseCost"], "Amount": money(qty * it["PurchaseCost"]), "ReceivedQuantity": 0 if open_ else qty}],
345
+ })
346
+
347
+ po("Pioneer Paint Distributors", add_days(today, -8), "Wood Stain 1gal Cedar", 40, True, "PO-1501")
348
+ po("Metro Electric Wholesale", add_days(today, -97), "Outdoor Outlet Cover", 60, True, "PO-1468")
349
+ po("Summit Lumber Supply", add_days(today, -40), "Cedar Board 2x6x8", 120, False, "PO-1482")
350
+ po("Harbor Hardware Wholesale", add_days(today, -25), "Hinge Set Heavy Duty", 60, False, "PO-1490")
351
+
352
+ # Bills: stock purchases and running costs.
353
+ def bill(vendor: str, date: str, ref_number: str, lines: list[Rec], terms: int) -> Rec:
354
+ amount = total(lines)
355
+ return txn("Bill", {
356
+ "VendorRef": ref(vendor), "APAccountRef": ref("Accounts Payable"), "TxnDate": date, "DueDate": add_days(date, terms),
357
+ "AmountDue": amount, "RefNumber": ref_number, "TermsRef": ref("Net 15" if terms == 15 else "Net 30"), "IsPaid": False, "OpenAmount": amount,
358
+ **({"ItemLineRet": lines} if truthy(lines[0].get("ItemRef")) else {"ExpenseLineRet": lines}),
359
+ })
360
+
361
+ def expense(account: str, amount: float, id_: str) -> list[Rec]:
362
+ return [{"TxnLineID": id_, "AccountRef": ref(account), "Amount": money(amount)}]
363
+
364
+ for i in range(14):
365
+ vendor = VENDORS[i % 4][0]
366
+ items = [s for s in stock if s["PrefVendorRef"]["FullName"] == vendor]
367
+ lines = []
368
+ for k in (0, 1):
369
+ it = pick(items)
370
+ qty = between(10, 60)
371
+ lines.append({"TxnLineID": f"B{i}-{k}", "ItemRef": ref(it["FullName"], it["ListID"]), "Desc": it["PurchaseDesc"], "Quantity": qty, "Cost": it["PurchaseCost"], "Amount": money(qty * it["PurchaseCost"])})
372
+ bill(vendor, add_days(today, -between(3, 100)), f"INV-{7000 + next_number('bill')}", lines, 30)
373
+ for m in range(1, 5):
374
+ bill("City Power & Light", add_days(month_start(today, -m + 1), 4), f"CPL-{2600 + m}", expense("Utilities", 380 + between(0, 140), f"U{m}"), 15)
375
+ bill("Oakline Insurance Agency", add_days(today, -70), "OIA-3317", expense("Insurance Expense", 1850, "IN1"), 30)
376
+ for i in range(5):
377
+ bill("Coastal Freight Lines", add_days(today, -between(15, 90)), f"CF-{1150 + i}", expense("Freight and Delivery", 140 + between(0, 460), f"F{i}"), 15)
378
+ # The same freight invoice entered twice, and the same office order billed twice.
379
+ bill("Coastal Freight Lines", add_days(today, -12), "CF-1182", expense("Freight and Delivery", 486.2, "FD1"), 15)
380
+ bill("Coastal Freight Lines", add_days(today, -10), "CF-1182", expense("Freight and Delivery", 486.2, "FD2"), 15)
381
+ bill("Brightway Office Products", add_days(today, -33), "BW-5531", expense("Office Supplies", 129.95, "OS1"), 30)
382
+ bill("Brightway Office Products", add_days(today, -31), "BW-5538", expense("Office Supplies", 129.95, "OS2"), 30)
383
+
384
+ # Pay most bills that came due more than five days ago, but leave some overdue.
385
+ for bl in c.txns["Bill"]:
386
+ if bl["DueDate"] >= add_days(today, -5) or r() < 0.15 or bl["RefNumber"].startswith("CF-118"):
387
+ continue
388
+ amount = bl["AmountDue"]
389
+ txn("BillPaymentCheck", {
390
+ "PayeeEntityRef": bl["VendorRef"], "APAccountRef": ref("Accounts Payable"), "TxnDate": min(add_days(bl["DueDate"], -2), add_days(today, -1)),
391
+ "BankAccountRef": ref("Business Checking"), "Amount": amount, "RefNumber": str(next_number("check")),
392
+ "AppliedToTxnRet": [{"TxnID": bl["TxnID"], "TxnType": "Bill", "TxnDate": bl["TxnDate"], "RefNumber": bl["RefNumber"], "Amount": amount}],
393
+ })
394
+ bl["IsPaid"] = True
395
+ bl["OpenAmount"] = 0
396
+
397
+ # Checks: rent each month, an expense nobody categorised, and a utility bill paid by check
398
+ # while the bill itself stays open.
399
+ def check(payee: str, date: str, account: str, amount: float, memo: str | None = None) -> Rec:
400
+ rec: Rec = {"AccountRef": ref("Business Checking"), "PayeeEntityRef": ref(payee), "RefNumber": str(next_number("check")), "TxnDate": date, "Amount": money(amount)}
401
+ if memo:
402
+ rec["Memo"] = memo
403
+ rec["ExpenseLineRet"] = expense(account, amount, f"C{counters['check']}")
404
+ return txn("Check", rec)
405
+
406
+ for m in range(5):
407
+ check("Riverbend Properties", month_start(today, -m), "Rent Expense", 3200, "Monthly rent")
408
+ check("Brightway Office Products", add_days(month_start(today, -1), 9), "Uncategorized Expenses", 312.4)
409
+ open_utility = next((x for x in c.txns["Bill"] if x["VendorRef"]["FullName"] == "City Power & Light" and not x["IsPaid"]), None)
410
+ if open_utility:
411
+ check("City Power & Light", add_days(open_utility["TxnDate"], 3), "Utilities", open_utility["AmountDue"])
412
+
413
+ for i in range(6):
414
+ date = add_days(today, -between(1, 120))
415
+ if i % 2:
416
+ payee, account, amount = "Brightway Office Products", "Office Supplies", 60 + between(0, 180)
417
+ else:
418
+ payee, account, amount = "Social Ads Co.", "Advertising and Promotion", 200 + between(0, 150)
419
+ txn("CreditCardCharge", {"AccountRef": ref("Business Visa"), "PayeeEntityRef": ref(payee), "TxnDate": date, "Amount": amount, "ExpenseLineRet": expense(account, amount, f"CC{i}")})
420
+
421
+ # A vendor credit for a damaged delivery, not applied to any bill yet.
422
+ txn("VendorCredit", {
423
+ "VendorRef": ref("Coastal Freight Lines"), "APAccountRef": ref("Accounts Payable"), "TxnDate": add_days(today, -6), "RefNumber": "CF-CR-21",
424
+ "CreditAmount": 95, "OpenAmount": 95, "Memo": "Damaged delivery refund", "ExpenseLineRet": expense("Freight and Delivery", 95, "VC1"),
425
+ })
426
+
427
+ recompute_balances(c)
428
+ # Bank and card items clear a week after their date, except a check never cashed and a bill payment marked in
429
+ # a reconciliation someone left unfinished.
430
+ c.cleared[next(x for x in c.txns["Check"] if x.get("Memo") is None and x["PayeeEntityRef"]["FullName"] == "Brightway Office Products")["TxnID"]] = "NotCleared"
431
+ c.cleared[max((x for x in c.txns["BillPaymentCheck"] if x["TxnDate"] <= add_days(today, -14)), key=lambda x: x["TxnDate"])["TxnID"]] = "Pending"
432
+ # Two inventory sites (Advanced Inventory), with fixed IDs so records added later keep theirs.
433
+ for i, (name, desc) in enumerate((("Main Store", "Shop floor and front counter"), ("Warehouse", "Back warehouse")), 1):
434
+ c.lists.setdefault("InventorySite", []).append({
435
+ "ListID": f"8000F00{i}-1700000000", "TimeCreated": time(add_days(today, -400)), "TimeModified": time(add_days(today, -400)),
436
+ "EditSequence": "1700000000", "Name": name, "IsActive": True, "IsDefaultSite": i == 1, "SiteDesc": desc,
437
+ })
438
+ return c
439
+
440
+
441
+ def _or0(v: Any) -> Any:
442
+ return v if truthy(v) else 0
443
+
444
+
445
+ def recompute_balances(c: DemoCompany) -> None:
446
+ """Customer, vendor and account balances follow from the open transactions."""
447
+
448
+ def total(xs: list[Rec] | None, f: str) -> float:
449
+ return money(add_up(_or0(x.get(f)) for x in xs or []))
450
+
451
+ for cu in c.lists.get("Customer", []):
452
+ def own(x: Rec) -> bool:
453
+ return isinstance(x.get("CustomerRef"), dict) and x["CustomerRef"].get("FullName") == cu.get("FullName")
454
+
455
+ invoices = [x for x in c.txns["Invoice"] if own(x)] if "Invoice" in c.txns else None
456
+ payments = [x for x in c.txns["ReceivePayment"] if own(x)] if "ReceivePayment" in c.txns else None
457
+ open_ = total(invoices, "BalanceRemaining") - total(payments, "UnusedPayment")
458
+ cu["Balance"] = money(open_)
459
+ cu["TotalBalance"] = money(open_)
460
+ for v in c.lists.get("Vendor", []):
461
+ def mine(x: Rec) -> bool:
462
+ return get(x["VendorRef"], "FullName") == v.get("Name")
463
+
464
+ bills = [x for x in c.txns["Bill"] if mine(x)] if "Bill" in c.txns else None
465
+ credits = [x for x in c.txns["VendorCredit"] if mine(x)] if "VendorCredit" in c.txns else None
466
+ v["Balance"] = money(total(bills, "OpenAmount") - total(credits, "OpenAmount"))
467
+
468
+ def set_(name: str, value: float) -> None:
469
+ a = next((x for x in c.lists["Account"] if x["Name"] == name), None)
470
+ if a:
471
+ a["Balance"] = a["TotalBalance"] = money(value)
472
+
473
+ set_("Business Checking", 48250.17)
474
+ set_("Business Savings", 120000)
475
+ set_("Business Visa", 3410.55)
476
+ # Unapplied payments and credits lower the A/R and A/P balances, as in QuickBooks.
477
+ set_("Accounts Receivable", total(c.lists.get("Customer"), "Balance"))
478
+ set_("Accounts Payable", total(c.lists.get("Vendor"), "Balance"))
479
+ set_("Undeposited Funds", total(c.undeposited, "Amount"))
480
+ set_("Inventory Asset", add_up(max(0, i["QuantityOnHand"]) * i["AverageCost"] for i in c.lists.get("ItemInventory", [])))