qbo-cli 0.1.0__tar.gz

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,34 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+ *.whl
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ env/
16
+
17
+ # IDE
18
+ .idea/
19
+ .vscode/
20
+ *.swp
21
+ *.swo
22
+ *~
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
28
+ # qbo tokens (sensitive)
29
+ .qbo/
30
+
31
+ # Testing
32
+ .pytest_cache/
33
+ .coverage
34
+ htmlcov/
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-02-16)
4
+
5
+ Initial release.
6
+
7
+ - OAuth 2.0 authentication with local callback server and manual mode
8
+ - Query entities with QBO SQL syntax and automatic pagination
9
+ - Get, create, update, and delete any QBO entity
10
+ - Financial reports (P&L, Balance Sheet, Cash Flow, etc.)
11
+ - Raw API access for arbitrary endpoints
12
+ - Automatic access token refresh with file locking
13
+ - Refresh token expiry warnings
14
+ - JSON and TSV output formats
15
+ - Sandbox mode support
qbo_cli-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex Bukhalenkov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
qbo_cli-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,261 @@
1
+ Metadata-Version: 2.4
2
+ Name: qbo-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for QuickBooks Online API
5
+ Project-URL: Homepage, https://github.com/alexph-dev/qbo-cli
6
+ Project-URL: Repository, https://github.com/alexph-dev/qbo-cli
7
+ Project-URL: Issues, https://github.com/alexph-dev/qbo-cli/issues
8
+ Author: Alex Bukhalenkov
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: accounting,cli,intuit,qbo,quickbooks
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Financial and Insurance Industry
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: requests>=2.28
27
+ Description-Content-Type: text/markdown
28
+
29
+ # qbo-cli
30
+
31
+ A command-line interface for the QuickBooks Online API. Query entities, run reports, create invoices — all from your terminal.
32
+
33
+ ## Features
34
+
35
+ - **OAuth 2.0 authentication** with local callback server or manual mode
36
+ - **Query** entities using QBO's SQL-like syntax with automatic pagination
37
+ - **CRUD operations** on any QBO entity (Customer, Invoice, Bill, etc.)
38
+ - **Financial reports** — P&L, Balance Sheet, Cash Flow, and more
39
+ - **Raw API access** for anything the CLI doesn't cover
40
+ - **Auto token refresh** — access tokens refresh transparently
41
+ - **TSV and JSON output** — pipe to `jq`, `awk`, spreadsheets
42
+ - **Sandbox support** for development and testing
43
+ - **File-locked token storage** — safe for concurrent use
44
+
45
+ ## Installation
46
+
47
+ ```bash
48
+ pip install qbo-cli
49
+ ```
50
+
51
+ Requires Python 3.9+.
52
+
53
+ ## Setup
54
+
55
+ ### 1. Create an Intuit Developer App
56
+
57
+ Go to [developer.intuit.com](https://developer.intuit.com), create an app, and note your **Client ID** and **Client Secret**.
58
+
59
+ Add a **Redirect URI** in your app's settings. For production apps, Intuit requires HTTPS with a real domain (e.g., `https://yourapp.example.com/callback`). For development, `https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl` works. Set `QBO_REDIRECT_URI` to match.
60
+
61
+ > **Tip:** If you're running on a headless server, use `qbo auth init --manual` — the redirect doesn't need to resolve. You'll just copy the URL from your browser's address bar after authorization.
62
+
63
+ ### 2. Configure Credentials
64
+
65
+ **Option A: Environment variables** (recommended for CI/scripts)
66
+
67
+ ```bash
68
+ export QBO_CLIENT_ID="your-client-id"
69
+ export QBO_CLIENT_SECRET="your-client-secret"
70
+ ```
71
+
72
+ **Option B: Config file** (`~/.qbo/config.json`)
73
+
74
+ ```json
75
+ {
76
+ "client_id": "your-client-id",
77
+ "client_secret": "your-client-secret"
78
+ }
79
+ ```
80
+
81
+ Environment variables take precedence over the config file.
82
+
83
+ ### 3. Authorize
84
+
85
+ ```bash
86
+ qbo auth init
87
+ ```
88
+
89
+ This opens an OAuth flow — authorize in your browser, and tokens are saved to `~/.qbo/tokens.json` (chmod 600).
90
+
91
+ On headless servers, use manual mode:
92
+
93
+ ```bash
94
+ qbo auth init --manual
95
+ ```
96
+
97
+ ## Usage
98
+
99
+ ### Check auth status
100
+
101
+ ```bash
102
+ qbo auth status
103
+ ```
104
+
105
+ ### Query entities
106
+
107
+ ```bash
108
+ # All customers
109
+ qbo query "SELECT * FROM Customer"
110
+
111
+ # Recent invoices
112
+ qbo query "SELECT * FROM Invoice WHERE TxnDate > '2025-01-01'"
113
+
114
+ # Unpaid invoices
115
+ qbo query "SELECT * FROM Invoice WHERE Balance > '0'"
116
+
117
+ # Vendors with email
118
+ qbo query "SELECT DisplayName, PrimaryEmailAddr FROM Vendor"
119
+
120
+ # Count items
121
+ qbo query "SELECT COUNT(*) FROM Item"
122
+
123
+ # TSV output (great for spreadsheets)
124
+ qbo query "SELECT DisplayName, Balance FROM Customer WHERE Balance > '0'" -f tsv
125
+ ```
126
+
127
+ Queries automatically paginate through all results (up to 100 pages × 1000 rows).
128
+
129
+ ### Get a single entity
130
+
131
+ ```bash
132
+ qbo get Customer 5
133
+ qbo get Invoice 1042
134
+ ```
135
+
136
+ ### Create an entity
137
+
138
+ ```bash
139
+ echo '{
140
+ "DisplayName": "John Smith",
141
+ "PrimaryEmailAddr": {"Address": "john@example.com"}
142
+ }' | qbo create Customer
143
+
144
+ echo '{
145
+ "CustomerRef": {"value": "5"},
146
+ "Line": [{
147
+ "Amount": 150.00,
148
+ "DetailType": "SalesItemLineDetail",
149
+ "SalesItemLineDetail": {"ItemRef": {"value": "1"}}
150
+ }]
151
+ }' | qbo create Invoice
152
+ ```
153
+
154
+ ### Update an entity
155
+
156
+ ```bash
157
+ # Fetch, modify, and update
158
+ qbo get Customer 5 | jq '.Customer.CompanyName = "New Name"' | qbo update Customer
159
+ ```
160
+
161
+ ### Delete an entity
162
+
163
+ ```bash
164
+ qbo delete Invoice 1042
165
+ ```
166
+
167
+ The CLI fetches the entity first (to get the required `SyncToken`), then deletes it.
168
+
169
+ ### Run reports
170
+
171
+ ```bash
172
+ # Profit and Loss for a date range
173
+ qbo report ProfitAndLoss --start-date 2025-01-01 --end-date 2025-12-31
174
+
175
+ # Balance Sheet as of today
176
+ qbo report BalanceSheet
177
+
178
+ # Using date macros
179
+ qbo report ProfitAndLoss --date-macro "Last Month"
180
+ qbo report ProfitAndLoss --date-macro "This Year"
181
+
182
+ # With extra parameters
183
+ qbo report ProfitAndLoss --start-date 2025-01-01 --end-date 2025-12-31 accounting_method=Cash
184
+ ```
185
+
186
+ Available reports: `ProfitAndLoss`, `BalanceSheet`, `CashFlow`, `CustomerIncome`, `AgedReceivables`, `AgedPayables`, `GeneralLedger`, `TrialBalance`, and more.
187
+
188
+ ### Raw API access
189
+
190
+ ```bash
191
+ # GET request
192
+ qbo raw GET "query?query=SELECT * FROM CompanyInfo"
193
+
194
+ # POST with body
195
+ echo '{"TrackQtyOnHand": true}' | qbo raw POST "item"
196
+ ```
197
+
198
+ ### Output formats
199
+
200
+ ```bash
201
+ # JSON (default)
202
+ qbo query "SELECT * FROM Customer"
203
+
204
+ # TSV (tab-separated, for spreadsheets/awk)
205
+ qbo query "SELECT * FROM Customer" -f tsv
206
+
207
+ # Pipe to jq
208
+ qbo query "SELECT * FROM Customer" | jq '.[].DisplayName'
209
+ ```
210
+
211
+ ### Sandbox mode
212
+
213
+ ```bash
214
+ # Use sandbox API endpoint
215
+ qbo --sandbox query "SELECT * FROM Customer"
216
+
217
+ # Or set via env/config
218
+ export QBO_SANDBOX=true
219
+ ```
220
+
221
+ ## Configuration Reference
222
+
223
+ | Setting | Env Variable | Config Key | Default |
224
+ |---------|-------------|------------|---------|
225
+ | Client ID | `QBO_CLIENT_ID` | `client_id` | — |
226
+ | Client Secret | `QBO_CLIENT_SECRET` | `client_secret` | — |
227
+ | Redirect URI | `QBO_REDIRECT_URI` | `redirect_uri` | — (must match your Intuit app) |
228
+ | Realm ID | `QBO_REALM_ID` | `realm_id` | From auth flow |
229
+ | Sandbox mode | `QBO_SANDBOX` | `sandbox` | `false` |
230
+
231
+ Config file location: `~/.qbo/config.json`
232
+
233
+ Token storage: `~/.qbo/tokens.json` (created automatically, chmod 600)
234
+
235
+ ## Token Management
236
+
237
+ - **Access tokens** expire every 60 minutes. The CLI refreshes them automatically before each request.
238
+ - **Refresh tokens** are valid for 100 days. Each refresh extends the 100-day window (rolling expiry).
239
+ - If you don't use the CLI for 100+ days, the refresh token expires and you need to re-authorize with `qbo auth init`.
240
+ - The CLI warns you when the refresh token has fewer than 14 days remaining.
241
+ - Token refresh uses file locking — safe to run concurrent `qbo` commands.
242
+
243
+ Force a manual refresh:
244
+
245
+ ```bash
246
+ qbo auth refresh
247
+ ```
248
+
249
+ ## Contributing
250
+
251
+ Contributions welcome. Please open an issue first to discuss what you'd like to change.
252
+
253
+ ```bash
254
+ git clone https://github.com/alexph-dev/qbo-cli.git
255
+ cd qbo-cli
256
+ pip install -e .
257
+ ```
258
+
259
+ ## License
260
+
261
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,233 @@
1
+ # qbo-cli
2
+
3
+ A command-line interface for the QuickBooks Online API. Query entities, run reports, create invoices — all from your terminal.
4
+
5
+ ## Features
6
+
7
+ - **OAuth 2.0 authentication** with local callback server or manual mode
8
+ - **Query** entities using QBO's SQL-like syntax with automatic pagination
9
+ - **CRUD operations** on any QBO entity (Customer, Invoice, Bill, etc.)
10
+ - **Financial reports** — P&L, Balance Sheet, Cash Flow, and more
11
+ - **Raw API access** for anything the CLI doesn't cover
12
+ - **Auto token refresh** — access tokens refresh transparently
13
+ - **TSV and JSON output** — pipe to `jq`, `awk`, spreadsheets
14
+ - **Sandbox support** for development and testing
15
+ - **File-locked token storage** — safe for concurrent use
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install qbo-cli
21
+ ```
22
+
23
+ Requires Python 3.9+.
24
+
25
+ ## Setup
26
+
27
+ ### 1. Create an Intuit Developer App
28
+
29
+ Go to [developer.intuit.com](https://developer.intuit.com), create an app, and note your **Client ID** and **Client Secret**.
30
+
31
+ Add a **Redirect URI** in your app's settings. For production apps, Intuit requires HTTPS with a real domain (e.g., `https://yourapp.example.com/callback`). For development, `https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl` works. Set `QBO_REDIRECT_URI` to match.
32
+
33
+ > **Tip:** If you're running on a headless server, use `qbo auth init --manual` — the redirect doesn't need to resolve. You'll just copy the URL from your browser's address bar after authorization.
34
+
35
+ ### 2. Configure Credentials
36
+
37
+ **Option A: Environment variables** (recommended for CI/scripts)
38
+
39
+ ```bash
40
+ export QBO_CLIENT_ID="your-client-id"
41
+ export QBO_CLIENT_SECRET="your-client-secret"
42
+ ```
43
+
44
+ **Option B: Config file** (`~/.qbo/config.json`)
45
+
46
+ ```json
47
+ {
48
+ "client_id": "your-client-id",
49
+ "client_secret": "your-client-secret"
50
+ }
51
+ ```
52
+
53
+ Environment variables take precedence over the config file.
54
+
55
+ ### 3. Authorize
56
+
57
+ ```bash
58
+ qbo auth init
59
+ ```
60
+
61
+ This opens an OAuth flow — authorize in your browser, and tokens are saved to `~/.qbo/tokens.json` (chmod 600).
62
+
63
+ On headless servers, use manual mode:
64
+
65
+ ```bash
66
+ qbo auth init --manual
67
+ ```
68
+
69
+ ## Usage
70
+
71
+ ### Check auth status
72
+
73
+ ```bash
74
+ qbo auth status
75
+ ```
76
+
77
+ ### Query entities
78
+
79
+ ```bash
80
+ # All customers
81
+ qbo query "SELECT * FROM Customer"
82
+
83
+ # Recent invoices
84
+ qbo query "SELECT * FROM Invoice WHERE TxnDate > '2025-01-01'"
85
+
86
+ # Unpaid invoices
87
+ qbo query "SELECT * FROM Invoice WHERE Balance > '0'"
88
+
89
+ # Vendors with email
90
+ qbo query "SELECT DisplayName, PrimaryEmailAddr FROM Vendor"
91
+
92
+ # Count items
93
+ qbo query "SELECT COUNT(*) FROM Item"
94
+
95
+ # TSV output (great for spreadsheets)
96
+ qbo query "SELECT DisplayName, Balance FROM Customer WHERE Balance > '0'" -f tsv
97
+ ```
98
+
99
+ Queries automatically paginate through all results (up to 100 pages × 1000 rows).
100
+
101
+ ### Get a single entity
102
+
103
+ ```bash
104
+ qbo get Customer 5
105
+ qbo get Invoice 1042
106
+ ```
107
+
108
+ ### Create an entity
109
+
110
+ ```bash
111
+ echo '{
112
+ "DisplayName": "John Smith",
113
+ "PrimaryEmailAddr": {"Address": "john@example.com"}
114
+ }' | qbo create Customer
115
+
116
+ echo '{
117
+ "CustomerRef": {"value": "5"},
118
+ "Line": [{
119
+ "Amount": 150.00,
120
+ "DetailType": "SalesItemLineDetail",
121
+ "SalesItemLineDetail": {"ItemRef": {"value": "1"}}
122
+ }]
123
+ }' | qbo create Invoice
124
+ ```
125
+
126
+ ### Update an entity
127
+
128
+ ```bash
129
+ # Fetch, modify, and update
130
+ qbo get Customer 5 | jq '.Customer.CompanyName = "New Name"' | qbo update Customer
131
+ ```
132
+
133
+ ### Delete an entity
134
+
135
+ ```bash
136
+ qbo delete Invoice 1042
137
+ ```
138
+
139
+ The CLI fetches the entity first (to get the required `SyncToken`), then deletes it.
140
+
141
+ ### Run reports
142
+
143
+ ```bash
144
+ # Profit and Loss for a date range
145
+ qbo report ProfitAndLoss --start-date 2025-01-01 --end-date 2025-12-31
146
+
147
+ # Balance Sheet as of today
148
+ qbo report BalanceSheet
149
+
150
+ # Using date macros
151
+ qbo report ProfitAndLoss --date-macro "Last Month"
152
+ qbo report ProfitAndLoss --date-macro "This Year"
153
+
154
+ # With extra parameters
155
+ qbo report ProfitAndLoss --start-date 2025-01-01 --end-date 2025-12-31 accounting_method=Cash
156
+ ```
157
+
158
+ Available reports: `ProfitAndLoss`, `BalanceSheet`, `CashFlow`, `CustomerIncome`, `AgedReceivables`, `AgedPayables`, `GeneralLedger`, `TrialBalance`, and more.
159
+
160
+ ### Raw API access
161
+
162
+ ```bash
163
+ # GET request
164
+ qbo raw GET "query?query=SELECT * FROM CompanyInfo"
165
+
166
+ # POST with body
167
+ echo '{"TrackQtyOnHand": true}' | qbo raw POST "item"
168
+ ```
169
+
170
+ ### Output formats
171
+
172
+ ```bash
173
+ # JSON (default)
174
+ qbo query "SELECT * FROM Customer"
175
+
176
+ # TSV (tab-separated, for spreadsheets/awk)
177
+ qbo query "SELECT * FROM Customer" -f tsv
178
+
179
+ # Pipe to jq
180
+ qbo query "SELECT * FROM Customer" | jq '.[].DisplayName'
181
+ ```
182
+
183
+ ### Sandbox mode
184
+
185
+ ```bash
186
+ # Use sandbox API endpoint
187
+ qbo --sandbox query "SELECT * FROM Customer"
188
+
189
+ # Or set via env/config
190
+ export QBO_SANDBOX=true
191
+ ```
192
+
193
+ ## Configuration Reference
194
+
195
+ | Setting | Env Variable | Config Key | Default |
196
+ |---------|-------------|------------|---------|
197
+ | Client ID | `QBO_CLIENT_ID` | `client_id` | — |
198
+ | Client Secret | `QBO_CLIENT_SECRET` | `client_secret` | — |
199
+ | Redirect URI | `QBO_REDIRECT_URI` | `redirect_uri` | — (must match your Intuit app) |
200
+ | Realm ID | `QBO_REALM_ID` | `realm_id` | From auth flow |
201
+ | Sandbox mode | `QBO_SANDBOX` | `sandbox` | `false` |
202
+
203
+ Config file location: `~/.qbo/config.json`
204
+
205
+ Token storage: `~/.qbo/tokens.json` (created automatically, chmod 600)
206
+
207
+ ## Token Management
208
+
209
+ - **Access tokens** expire every 60 minutes. The CLI refreshes them automatically before each request.
210
+ - **Refresh tokens** are valid for 100 days. Each refresh extends the 100-day window (rolling expiry).
211
+ - If you don't use the CLI for 100+ days, the refresh token expires and you need to re-authorize with `qbo auth init`.
212
+ - The CLI warns you when the refresh token has fewer than 14 days remaining.
213
+ - Token refresh uses file locking — safe to run concurrent `qbo` commands.
214
+
215
+ Force a manual refresh:
216
+
217
+ ```bash
218
+ qbo auth refresh
219
+ ```
220
+
221
+ ## Contributing
222
+
223
+ Contributions welcome. Please open an issue first to discuss what you'd like to change.
224
+
225
+ ```bash
226
+ git clone https://github.com/alexph-dev/qbo-cli.git
227
+ cd qbo-cli
228
+ pip install -e .
229
+ ```
230
+
231
+ ## License
232
+
233
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qbo-cli"
7
+ version = "0.1.0"
8
+ description = "Command-line interface for QuickBooks Online API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Alex Bukhalenkov" },
14
+ ]
15
+ keywords = ["quickbooks", "qbo", "cli", "accounting", "intuit"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "Intended Audience :: Financial and Insurance Industry",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.9",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Office/Business :: Financial :: Accounting",
30
+ ]
31
+ dependencies = [
32
+ "requests>=2.28",
33
+ ]
34
+
35
+ [project.scripts]
36
+ qbo = "qbo_cli.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/alexph-dev/qbo-cli"
40
+ Repository = "https://github.com/alexph-dev/qbo-cli"
41
+ Issues = "https://github.com/alexph-dev/qbo-cli/issues"
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,668 @@
1
+ #!/usr/bin/env python3
2
+ """qbo-cli — Command-line interface for QuickBooks Online API.
3
+
4
+ A single-file CLI for interacting with the QuickBooks Online (QBO) API.
5
+ Supports OAuth 2.0 authentication, querying entities with auto-pagination,
6
+ CRUD operations, financial reports, and raw API access.
7
+
8
+ Homepage: https://github.com/alexph-dev/qbo-cli
9
+ License: MIT
10
+ """
11
+
12
+ import argparse
13
+ import fcntl
14
+ import json
15
+ import os
16
+ import sys
17
+ import time
18
+ from http.server import HTTPServer, BaseHTTPRequestHandler
19
+ from pathlib import Path
20
+ from urllib.parse import urlencode, urlparse, parse_qs
21
+
22
+ import requests
23
+
24
+ # ─── Constants ───────────────────────────────────────────────────────────────
25
+
26
+ QBO_DIR = Path.home() / ".qbo"
27
+ CONFIG_PATH = QBO_DIR / "config.json"
28
+ TOKENS_PATH = QBO_DIR / "tokens.json"
29
+ AUTH_URL = "https://appcenter.intuit.com/connect/oauth2"
30
+ TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
31
+ PROD_BASE = "https://quickbooks.api.intuit.com/v3/company"
32
+ SANDBOX_BASE = "https://sandbox-quickbooks.api.intuit.com/v3/company"
33
+ SCOPE = "com.intuit.quickbooks.accounting"
34
+ DEFAULT_REDIRECT = "http://localhost:8844/callback"
35
+ REFRESH_MARGIN_SEC = 300 # 5 minutes
36
+ MAX_RESULTS = 1000 # QBO max per page
37
+ DEFAULT_MAX_PAGES = 100 # safety cap
38
+ MINOR_VERSION = 75 # QBO API minor version
39
+ REFRESH_EXPIRY_WARN_DAYS = 14 # warn when refresh token < this many days left
40
+
41
+
42
+ # ─── Helpers ─────────────────────────────────────────────────────────────────
43
+
44
+ def die(msg: str, code: int = 1):
45
+ """Print to stderr and exit."""
46
+ print(f"Error: {msg}", file=sys.stderr)
47
+ sys.exit(code)
48
+
49
+
50
+ def err_print(msg: str):
51
+ print(msg, file=sys.stderr)
52
+
53
+
54
+ def output(data, fmt: str = "json"):
55
+ """Write result to stdout."""
56
+ if fmt == "tsv":
57
+ output_tsv(data)
58
+ else:
59
+ json.dump(data, sys.stdout, indent=2, default=str)
60
+ print()
61
+
62
+
63
+ def output_tsv(data):
64
+ """Flatten list-of-dicts to TSV."""
65
+ if isinstance(data, dict):
66
+ for v in data.values():
67
+ if isinstance(v, list):
68
+ data = v
69
+ break
70
+ else:
71
+ data = [data]
72
+ if not data:
73
+ return
74
+ if isinstance(data, list) and data and isinstance(data[0], dict):
75
+ keys = list(data[0].keys())
76
+ print("\t".join(keys))
77
+ for row in data:
78
+ print("\t".join(str(row.get(k, "")) for k in keys))
79
+ else:
80
+ json.dump(data, sys.stdout, indent=2, default=str)
81
+ print()
82
+
83
+
84
+ # ─── Config ──────────────────────────────────────────────────────────────────
85
+
86
+ class Config:
87
+ """Load config from env vars → config file → defaults."""
88
+
89
+ def __init__(self):
90
+ self.client_id: str = ""
91
+ self.client_secret: str = ""
92
+ self.redirect_uri: str = DEFAULT_REDIRECT
93
+ self.realm_id: str = ""
94
+ self.sandbox: bool = False
95
+ self._load()
96
+
97
+ def _load(self):
98
+ file_cfg = {}
99
+ if CONFIG_PATH.exists():
100
+ try:
101
+ file_cfg = json.loads(CONFIG_PATH.read_text())
102
+ except json.JSONDecodeError:
103
+ err_print("Warning: ~/.qbo/config.json is not valid JSON, ignoring.")
104
+
105
+ self.client_id = os.environ.get("QBO_CLIENT_ID", file_cfg.get("client_id", ""))
106
+ self.client_secret = os.environ.get("QBO_CLIENT_SECRET", file_cfg.get("client_secret", ""))
107
+ self.redirect_uri = os.environ.get("QBO_REDIRECT_URI", file_cfg.get("redirect_uri", DEFAULT_REDIRECT))
108
+ self.realm_id = os.environ.get("QBO_REALM_ID", file_cfg.get("realm_id", ""))
109
+ self.sandbox = os.environ.get("QBO_SANDBOX", file_cfg.get("sandbox", False))
110
+ if isinstance(self.sandbox, str):
111
+ self.sandbox = self.sandbox.lower() in ("1", "true", "yes")
112
+
113
+ def validate(self, need_tokens=True):
114
+ """Raise if missing required config."""
115
+ if not self.client_id or not self.client_secret:
116
+ die(
117
+ "Missing QBO_CLIENT_ID / QBO_CLIENT_SECRET.\n"
118
+ "Set env vars or create ~/.qbo/config.json with client_id and client_secret."
119
+ )
120
+
121
+
122
+ # ─── Token Manager ───────────────────────────────────────────────────────────
123
+
124
+ class TokenManager:
125
+ """Thread-safe, file-locked token storage with auto-refresh."""
126
+
127
+ def __init__(self, config: Config):
128
+ self.config = config
129
+ self._tokens: dict | None = None
130
+
131
+ def load(self) -> dict:
132
+ """Load tokens from disk."""
133
+ if not TOKENS_PATH.exists():
134
+ die("No tokens found. Run: qbo auth init")
135
+ try:
136
+ self._tokens = json.loads(TOKENS_PATH.read_text())
137
+ except json.JSONDecodeError:
138
+ die("Token file corrupted. Delete ~/.qbo/tokens.json and re-run: qbo auth init")
139
+ return self._tokens
140
+
141
+ def save(self, tokens: dict):
142
+ """Atomic write: temp file → rename. Permissions set before rename."""
143
+ QBO_DIR.mkdir(parents=True, exist_ok=True)
144
+ tmp = TOKENS_PATH.with_suffix(".tmp")
145
+ tmp.write_text(json.dumps(tokens, indent=2))
146
+ tmp.chmod(0o600) # set permissions BEFORE rename to avoid exposure window
147
+ tmp.rename(TOKENS_PATH)
148
+ self._tokens = tokens
149
+
150
+ def get_valid_token(self) -> str:
151
+ """Return a valid access token, refreshing if needed."""
152
+ tokens = self.load()
153
+ self._warn_refresh_expiry(tokens)
154
+ expires_at = tokens.get("expires_at", 0)
155
+
156
+ if time.time() < expires_at - REFRESH_MARGIN_SEC:
157
+ return tokens["access_token"]
158
+
159
+ return self._locked_refresh(tokens)
160
+
161
+ def _warn_refresh_expiry(self, tokens: dict):
162
+ """Warn to stderr if refresh token is nearing expiry."""
163
+ refresh_exp = tokens.get("refresh_expires_at", 0)
164
+ days_left = (refresh_exp - time.time()) / 86400
165
+ if 0 < days_left < REFRESH_EXPIRY_WARN_DAYS:
166
+ err_print(
167
+ f"⚠ Refresh token expires in {days_left:.1f} days. "
168
+ f"Run 'qbo auth init' to re-authorize before it expires."
169
+ )
170
+
171
+ def _locked_refresh(self, tokens: dict) -> str:
172
+ """Refresh with exclusive file lock to prevent concurrent refresh."""
173
+ lock_path = TOKENS_PATH.with_suffix(".lock")
174
+ QBO_DIR.mkdir(parents=True, exist_ok=True)
175
+
176
+ with open(lock_path, "w") as lock_file:
177
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
178
+ try:
179
+ # Re-read — another process may have refreshed
180
+ tokens = self.load()
181
+ if time.time() < tokens["expires_at"] - REFRESH_MARGIN_SEC:
182
+ return tokens["access_token"]
183
+
184
+ new_tokens = self._do_refresh(tokens)
185
+ self.save(new_tokens)
186
+ return new_tokens["access_token"]
187
+ finally:
188
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
189
+
190
+ def _do_refresh(self, tokens: dict) -> dict:
191
+ """Call Intuit token endpoint to refresh."""
192
+ try:
193
+ resp = requests.post(TOKEN_URL, data={
194
+ "grant_type": "refresh_token",
195
+ "refresh_token": tokens["refresh_token"],
196
+ }, auth=(self.config.client_id, self.config.client_secret), timeout=30)
197
+ except requests.ConnectionError:
198
+ die("Network error during token refresh. Check your connection.")
199
+ except requests.Timeout:
200
+ die("Timeout during token refresh. Intuit OAuth may be down.")
201
+
202
+ if resp.status_code == 400:
203
+ try:
204
+ body = resp.json()
205
+ except ValueError:
206
+ die(f"Token refresh failed (400): {resp.text[:500]}")
207
+ if body.get("error") == "invalid_grant":
208
+ die(
209
+ "Refresh token expired or revoked.\n"
210
+ "Re-authorize: qbo auth init\n"
211
+ "This happens if the token wasn't refreshed within 100 days."
212
+ )
213
+ die(f"Token refresh failed: {body.get('error', 'unknown')} — {body.get('error_description', '')}")
214
+
215
+ if not resp.ok:
216
+ die(f"Token refresh failed (HTTP {resp.status_code}): {resp.text[:500]}")
217
+
218
+ data = resp.json()
219
+
220
+ return {
221
+ "access_token": data["access_token"],
222
+ "refresh_token": data["refresh_token"],
223
+ "expires_at": time.time() + data["expires_in"],
224
+ "refresh_expires_at": time.time() + data.get("x_refresh_token_expires_in", 8640000),
225
+ "realm_id": tokens.get("realm_id", self.config.realm_id),
226
+ "token_type": data.get("token_type", "bearer"),
227
+ "created_at": tokens.get("created_at", time.time()),
228
+ "refreshed_at": time.time(),
229
+ }
230
+
231
+ def exchange_code(self, auth_code: str, realm_id: str) -> dict:
232
+ """Exchange authorization code for tokens."""
233
+ try:
234
+ resp = requests.post(TOKEN_URL, data={
235
+ "grant_type": "authorization_code",
236
+ "code": auth_code,
237
+ "redirect_uri": self.config.redirect_uri,
238
+ }, auth=(self.config.client_id, self.config.client_secret), timeout=30)
239
+ except requests.ConnectionError:
240
+ die("Network error during code exchange. Check your connection.")
241
+ except requests.Timeout:
242
+ die("Timeout during code exchange. Intuit OAuth may be down.")
243
+
244
+ if not resp.ok:
245
+ die(f"Code exchange failed (HTTP {resp.status_code}): {resp.text[:500]}")
246
+
247
+ data = resp.json()
248
+
249
+ tokens = {
250
+ "access_token": data["access_token"],
251
+ "refresh_token": data["refresh_token"],
252
+ "expires_at": time.time() + data["expires_in"],
253
+ "refresh_expires_at": time.time() + data.get("x_refresh_token_expires_in", 8640000),
254
+ "realm_id": realm_id,
255
+ "token_type": data.get("token_type", "bearer"),
256
+ "created_at": time.time(),
257
+ "refreshed_at": time.time(),
258
+ }
259
+ self.save(tokens)
260
+ return tokens
261
+
262
+
263
+ # ─── QBO Client ──────────────────────────────────────────────────────────────
264
+
265
+ class QBOClient:
266
+ """QuickBooks Online API client with auto-refresh and retry."""
267
+
268
+ def __init__(self, config: Config, token_mgr: TokenManager):
269
+ self.config = config
270
+ self.token_mgr = token_mgr
271
+
272
+ def _headers(self, token: str) -> dict:
273
+ return {
274
+ "Authorization": f"Bearer {token}",
275
+ "Accept": "application/json",
276
+ "Content-Type": "application/json",
277
+ }
278
+
279
+ def _base_url(self) -> str:
280
+ tokens = self.token_mgr._tokens or self.token_mgr.load()
281
+ realm = tokens.get("realm_id") or self.config.realm_id
282
+ if not realm:
283
+ die("No realm_id. Set QBO_REALM_ID or run qbo auth init.")
284
+ base = SANDBOX_BASE if self.config.sandbox else PROD_BASE
285
+ return f"{base}/{realm}"
286
+
287
+ def request(self, method: str, path: str, params: dict = None,
288
+ json_body: dict = None, raw_response: bool = False):
289
+ """Make API request with auto-refresh and 401 retry."""
290
+ token = self.token_mgr.get_valid_token()
291
+ url = f"{self._base_url()}/{path}"
292
+
293
+ # Always include minorversion for consistent API behavior
294
+ if params is None:
295
+ params = {}
296
+ params.setdefault("minorversion", MINOR_VERSION)
297
+
298
+ for attempt in range(2):
299
+ try:
300
+ resp = requests.request(
301
+ method, url,
302
+ headers=self._headers(token),
303
+ params=params,
304
+ json=json_body,
305
+ timeout=60,
306
+ )
307
+ except requests.ConnectionError:
308
+ die("Network error connecting to QBO API. Check your connection.")
309
+ except requests.Timeout:
310
+ die("QBO API request timed out (60s). Try again later.")
311
+
312
+ if resp.status_code == 401 and attempt == 0:
313
+ err_print("Got 401, forcing token refresh...")
314
+ token = self.token_mgr._locked_refresh(self.token_mgr.load())
315
+ continue
316
+
317
+ break
318
+
319
+ if raw_response:
320
+ return resp
321
+
322
+ if not resp.ok:
323
+ # Try to extract QBO Fault message for better error reporting
324
+ error_detail = resp.text[:500]
325
+ try:
326
+ error_json = resp.json()
327
+ fault = error_json.get("Fault", {})
328
+ errors = fault.get("Error", [])
329
+ if errors:
330
+ error_detail = "; ".join(
331
+ f"{e.get('Message', '')} — {e.get('Detail', '')}" for e in errors
332
+ )
333
+ except (ValueError, AttributeError):
334
+ pass
335
+ err_print(f"API error {resp.status_code}: {error_detail}")
336
+ sys.exit(1)
337
+
338
+ return resp.json()
339
+
340
+ def query(self, sql: str, max_pages: int = DEFAULT_MAX_PAGES) -> list:
341
+ """Run QBO query with auto-pagination."""
342
+ all_results = []
343
+ start = 1
344
+
345
+ for page in range(max_pages):
346
+ paginated_sql = f"{sql} STARTPOSITION {start} MAXRESULTS {MAX_RESULTS}"
347
+ data = self.request("GET", "query", params={"query": paginated_sql})
348
+
349
+ qr = data.get("QueryResponse", {})
350
+ entities = []
351
+ for key, val in qr.items():
352
+ if isinstance(val, list):
353
+ entities = val
354
+ break
355
+
356
+ all_results.extend(entities)
357
+
358
+ if len(entities) < MAX_RESULTS:
359
+ break
360
+ start += MAX_RESULTS
361
+
362
+ return all_results
363
+
364
+ def get(self, entity: str, entity_id: str) -> dict:
365
+ return self.request("GET", f"{entity}/{entity_id}")
366
+
367
+ def create(self, entity: str, body: dict) -> dict:
368
+ return self.request("POST", entity, json_body=body)
369
+
370
+ def update(self, entity: str, body: dict) -> dict:
371
+ return self.request("POST", entity, json_body=body)
372
+
373
+ def delete(self, entity: str, entity_id: str) -> dict:
374
+ current = self.get(entity, entity_id)
375
+ entity_data = current.get(entity, current)
376
+ return self.request("POST", entity,
377
+ params={"operation": "delete"},
378
+ json_body=entity_data)
379
+
380
+ def report(self, report_type: str, params: dict = None) -> dict:
381
+ return self.request("GET", f"reports/{report_type}", params=params)
382
+
383
+ def raw(self, method: str, path: str, body: dict = None) -> dict:
384
+ return self.request(method.upper(), path, json_body=body)
385
+
386
+
387
+ # ─── Auth Commands ───────────────────────────────────────────────────────────
388
+
389
+ def cmd_auth_init(args, config, token_mgr):
390
+ """Interactive OAuth authorization flow."""
391
+ config.validate(need_tokens=False)
392
+
393
+ auth_params = urlencode({
394
+ "client_id": config.client_id,
395
+ "scope": SCOPE,
396
+ "redirect_uri": config.redirect_uri,
397
+ "response_type": "code",
398
+ "state": os.urandom(16).hex(),
399
+ })
400
+ auth_url = f"{AUTH_URL}?{auth_params}"
401
+
402
+ if args.manual:
403
+ print(f"Open this URL in a browser:\n\n{auth_url}\n", file=sys.stderr)
404
+ print("After authorizing, paste the full redirect URL here:", file=sys.stderr)
405
+ redirect_url = input().strip()
406
+ parsed = parse_qs(urlparse(redirect_url).query)
407
+ try:
408
+ code = parsed["code"][0]
409
+ realm_id = parsed["realmId"][0]
410
+ except (KeyError, IndexError):
411
+ die("Could not parse code and realmId from the redirect URL.")
412
+ else:
413
+ code, realm_id = _run_callback_server(auth_url, config, args.port)
414
+
415
+ tokens = token_mgr.exchange_code(code, realm_id)
416
+ err_print(f"✓ Authorized. Realm: {realm_id}")
417
+ err_print(f" Access token expires: {time.ctime(tokens['expires_at'])}")
418
+ err_print(f" Refresh token expires: {time.ctime(tokens['refresh_expires_at'])}")
419
+
420
+
421
+ def _run_callback_server(auth_url: str, config: Config, port: int) -> tuple:
422
+ """Start temp HTTP server, print auth URL, wait for callback."""
423
+ result = {}
424
+
425
+ class Handler(BaseHTTPRequestHandler):
426
+ def do_GET(self):
427
+ qs = parse_qs(urlparse(self.path).query)
428
+ if "code" in qs:
429
+ result["code"] = qs["code"][0]
430
+ result["realm_id"] = qs["realmId"][0]
431
+ self.send_response(200)
432
+ self.end_headers()
433
+ self.wfile.write(b"<h1>Authorization successful!</h1><p>You can close this tab.</p>")
434
+ else:
435
+ self.send_response(400)
436
+ self.end_headers()
437
+ self.wfile.write(b"Missing code parameter")
438
+
439
+ def log_message(self, *args):
440
+ pass
441
+
442
+ server = HTTPServer(("127.0.0.1", port), Handler)
443
+ err_print(f"Open this URL in a browser:\n\n{auth_url}\n")
444
+ err_print(f"Waiting for callback on port {port}... (5 min timeout)")
445
+
446
+ server.timeout = 30 # per-request timeout
447
+ deadline = time.time() + 300 # 5 min total deadline
448
+ while "code" not in result:
449
+ if time.time() > deadline:
450
+ server.server_close()
451
+ die("Timed out waiting for OAuth callback (5 min). Try again or use --manual mode.")
452
+ server.handle_request()
453
+
454
+ server.server_close()
455
+ return result["code"], result["realm_id"]
456
+
457
+
458
+ def cmd_auth_status(args, config, token_mgr):
459
+ tokens = token_mgr.load()
460
+ now = time.time()
461
+ access_exp = tokens.get("expires_at", 0)
462
+ refresh_exp = tokens.get("refresh_expires_at", 0)
463
+
464
+ info = {
465
+ "realm_id": tokens.get("realm_id"),
466
+ "access_token_valid": access_exp > now,
467
+ "access_token_expires": time.ctime(access_exp),
468
+ "access_token_remaining_min": max(0, round((access_exp - now) / 60, 1)),
469
+ "refresh_token_expires": time.ctime(refresh_exp),
470
+ "refresh_token_remaining_days": max(0, round((refresh_exp - now) / 86400, 1)),
471
+ "last_refreshed": time.ctime(tokens.get("refreshed_at", 0)),
472
+ }
473
+ output(info, args.format)
474
+
475
+
476
+ def cmd_auth_refresh(args, config, token_mgr):
477
+ config.validate(need_tokens=False)
478
+ token_mgr.load()
479
+ token_mgr._locked_refresh(token_mgr._tokens)
480
+ err_print("✓ Token refreshed successfully")
481
+
482
+
483
+ # ─── Entity Commands ─────────────────────────────────────────────────────────
484
+
485
+ def cmd_query(args, config, token_mgr):
486
+ client = QBOClient(config, token_mgr)
487
+ results = client.query(args.sql, max_pages=args.max_pages)
488
+ output(results, args.format)
489
+
490
+
491
+ def cmd_get(args, config, token_mgr):
492
+ client = QBOClient(config, token_mgr)
493
+ result = client.get(args.entity, args.id)
494
+ output(result, args.format)
495
+
496
+
497
+ def cmd_create(args, config, token_mgr):
498
+ if sys.stdin.isatty():
499
+ die("Pipe JSON body via stdin. Example: echo '{...}' | qbo create Invoice")
500
+ try:
501
+ body = json.load(sys.stdin)
502
+ except json.JSONDecodeError:
503
+ die("Invalid JSON on stdin.")
504
+ client = QBOClient(config, token_mgr)
505
+ result = client.create(args.entity, body)
506
+ output(result, args.format)
507
+
508
+
509
+ def cmd_update(args, config, token_mgr):
510
+ if sys.stdin.isatty():
511
+ die("Pipe JSON body via stdin. Example: echo '{...}' | qbo update Customer")
512
+ try:
513
+ body = json.load(sys.stdin)
514
+ except json.JSONDecodeError:
515
+ die("Invalid JSON on stdin.")
516
+ client = QBOClient(config, token_mgr)
517
+ result = client.update(args.entity, body)
518
+ output(result, args.format)
519
+
520
+
521
+ def cmd_delete(args, config, token_mgr):
522
+ client = QBOClient(config, token_mgr)
523
+ result = client.delete(args.entity, args.id)
524
+ output(result, args.format)
525
+
526
+
527
+ def cmd_report(args, config, token_mgr):
528
+ client = QBOClient(config, token_mgr)
529
+ params = {}
530
+ if args.start_date:
531
+ params["start_date"] = args.start_date
532
+ if args.end_date:
533
+ params["end_date"] = args.end_date
534
+ if args.date_macro:
535
+ params["date_macro"] = args.date_macro
536
+ if args.params:
537
+ for p in args.params:
538
+ if "=" not in p:
539
+ die(f"Invalid param format '{p}'. Use key=value.")
540
+ k, v = p.split("=", 1)
541
+ params[k] = v
542
+ result = client.report(args.report_type, params or None)
543
+ output(result, args.format)
544
+
545
+
546
+ def cmd_raw(args, config, token_mgr):
547
+ client = QBOClient(config, token_mgr)
548
+ body = None
549
+ if args.method.upper() in ("POST", "PUT") and not sys.stdin.isatty():
550
+ try:
551
+ body = json.load(sys.stdin)
552
+ except json.JSONDecodeError:
553
+ die("Invalid JSON on stdin.")
554
+ result = client.raw(args.method, args.path, body)
555
+ output(result, args.format)
556
+
557
+
558
+ # ─── CLI Parser ──────────────────────────────────────────────────────────────
559
+
560
+ def main():
561
+ parser = argparse.ArgumentParser(
562
+ prog="qbo",
563
+ description="QuickBooks Online CLI — query, create, update, delete entities and run reports.",
564
+ )
565
+ parser.add_argument("--format", "-f", choices=["json", "tsv"], default="json",
566
+ help="Output format (default: json)")
567
+ parser.add_argument("--sandbox", action="store_true",
568
+ help="Use sandbox API endpoint")
569
+
570
+ subs = parser.add_subparsers(dest="command")
571
+
572
+ # ── auth ──
573
+ auth_p = subs.add_parser("auth", help="Authentication commands")
574
+ auth_subs = auth_p.add_subparsers(dest="auth_command")
575
+
576
+ init_p = auth_subs.add_parser("init", help="Start OAuth authorization flow")
577
+ init_p.add_argument("--manual", action="store_true",
578
+ help="Manual mode: paste redirect URL instead of local callback server")
579
+ init_p.add_argument("--port", type=int, default=8844,
580
+ help="Callback server port (default: 8844)")
581
+
582
+ auth_subs.add_parser("status", help="Show token status")
583
+ auth_subs.add_parser("refresh", help="Force token refresh")
584
+
585
+ # ── query ──
586
+ query_p = subs.add_parser("query", help="Run a QBO query (SQL-like)")
587
+ query_p.add_argument("sql", help='QBO query, e.g. "SELECT * FROM Customer"')
588
+ query_p.add_argument("--max-pages", type=int, default=DEFAULT_MAX_PAGES,
589
+ help=f"Max pagination pages (default: {DEFAULT_MAX_PAGES})")
590
+
591
+ # ── get ──
592
+ get_p = subs.add_parser("get", help="Get a single entity by ID")
593
+ get_p.add_argument("entity", help="Entity type (Invoice, Customer, etc.)")
594
+ get_p.add_argument("id", help="Entity ID")
595
+
596
+ # ── create ──
597
+ create_p = subs.add_parser("create", help="Create an entity (JSON on stdin)")
598
+ create_p.add_argument("entity", help="Entity type")
599
+
600
+ # ── update ──
601
+ update_p = subs.add_parser("update", help="Update an entity (JSON on stdin)")
602
+ update_p.add_argument("entity", help="Entity type")
603
+
604
+ # ── delete ──
605
+ delete_p = subs.add_parser("delete", help="Delete an entity by ID")
606
+ delete_p.add_argument("entity", help="Entity type")
607
+ delete_p.add_argument("id", help="Entity ID")
608
+
609
+ # ── report ──
610
+ report_p = subs.add_parser("report", help="Run a QBO report")
611
+ report_p.add_argument("report_type", help="Report type (ProfitAndLoss, BalanceSheet, etc.)")
612
+ report_p.add_argument("--start-date", help="Start date (YYYY-MM-DD)")
613
+ report_p.add_argument("--end-date", help="End date (YYYY-MM-DD)")
614
+ report_p.add_argument("--date-macro", help='Date macro (e.g. "Last Month", "This Year")')
615
+ report_p.add_argument("params", nargs="*", help="Extra params as key=value")
616
+
617
+ # ── raw ──
618
+ raw_p = subs.add_parser("raw", help="Make a raw API request")
619
+ raw_p.add_argument("method", help="HTTP method (GET, POST, PUT, DELETE)")
620
+ raw_p.add_argument("path", help="API path after /v3/company/{realm}/")
621
+
622
+ args = parser.parse_args()
623
+
624
+ if not args.command:
625
+ parser.print_help()
626
+ sys.exit(1)
627
+
628
+ config = Config()
629
+ if args.sandbox:
630
+ config.sandbox = True
631
+ token_mgr = TokenManager(config)
632
+
633
+ # ── Dispatch ──
634
+ if args.command == "auth":
635
+ if not args.auth_command:
636
+ auth_p.print_help()
637
+ sys.exit(1)
638
+ dispatch = {
639
+ "init": cmd_auth_init,
640
+ "status": cmd_auth_status,
641
+ "refresh": cmd_auth_refresh,
642
+ }
643
+ dispatch[args.auth_command](args, config, token_mgr)
644
+ elif args.command == "query":
645
+ config.validate()
646
+ cmd_query(args, config, token_mgr)
647
+ elif args.command == "get":
648
+ config.validate()
649
+ cmd_get(args, config, token_mgr)
650
+ elif args.command == "create":
651
+ config.validate()
652
+ cmd_create(args, config, token_mgr)
653
+ elif args.command == "update":
654
+ config.validate()
655
+ cmd_update(args, config, token_mgr)
656
+ elif args.command == "delete":
657
+ config.validate()
658
+ cmd_delete(args, config, token_mgr)
659
+ elif args.command == "report":
660
+ config.validate()
661
+ cmd_report(args, config, token_mgr)
662
+ elif args.command == "raw":
663
+ config.validate()
664
+ cmd_raw(args, config, token_mgr)
665
+
666
+
667
+ if __name__ == "__main__":
668
+ main()