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
qbd_agent_kit/config.py
ADDED
|
@@ -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", [])))
|