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.
- qbo_cli-0.1.0/.gitignore +34 -0
- qbo_cli-0.1.0/CHANGELOG.md +15 -0
- qbo_cli-0.1.0/LICENSE +21 -0
- qbo_cli-0.1.0/PKG-INFO +261 -0
- qbo_cli-0.1.0/README.md +233 -0
- qbo_cli-0.1.0/pyproject.toml +41 -0
- qbo_cli-0.1.0/qbo_cli/__init__.py +1 -0
- qbo_cli-0.1.0/qbo_cli/cli.py +668 -0
qbo_cli-0.1.0/.gitignore
ADDED
|
@@ -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).
|
qbo_cli-0.1.0/README.md
ADDED
|
@@ -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()
|