@ktmcp-cli/billingo 1.0.0

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.
package/OPENCLAW.md ADDED
@@ -0,0 +1,503 @@
1
+ # Billingo CLI - OpenClaw Integration Guide
2
+
3
+ This guide shows how to integrate the Billingo CLI with OpenClaw for AI-driven invoicing automation.
4
+
5
+ ## What is OpenClaw?
6
+
7
+ OpenClaw is a framework for building AI agents with tool access. Instead of using MCP (Model Context Protocol), OpenClaw embraces the Unix philosophy: CLI tools are better than proprietary abstractions.
8
+
9
+ ## Why This CLI Works Perfectly with OpenClaw
10
+
11
+ 1. **Zero Configuration**: No server to run, no MCP gateway to configure
12
+ 2. **Standard Interface**: Uses stdin/stdout/stderr like any Unix tool
13
+ 3. **Structured Output**: JSON format for machine parsing, pretty format for humans
14
+ 4. **Self-Documenting**: `--help` text provides complete API documentation
15
+ 5. **Composable**: Works with pipes, scripts, and other CLI tools
16
+
17
+ ## Installation for OpenClaw
18
+
19
+ ```bash
20
+ # Install the CLI
21
+ npm install -g @ktmcp-cli/billingo
22
+
23
+ # Configure API key
24
+ billingo config set apiKey YOUR_API_KEY
25
+
26
+ # Verify installation
27
+ billingo organization get
28
+ ```
29
+
30
+ ## Tool Configuration
31
+
32
+ ### Option 1: Direct Shell Access
33
+
34
+ If your OpenClaw agent has shell access, it can use the CLI directly:
35
+
36
+ ```python
37
+ # In your OpenClaw agent
38
+ result = await shell.execute("billingo documents list --format json")
39
+ documents = json.loads(result.stdout)
40
+ ```
41
+
42
+ ### Option 2: Wrapped Tool
43
+
44
+ Create a Python wrapper for type-safe access:
45
+
46
+ ```python
47
+ # tools/billingo.py
48
+ import json
49
+ import subprocess
50
+ from typing import List, Dict, Any, Optional
51
+
52
+ class BillingoTool:
53
+ """Billingo API access via CLI"""
54
+
55
+ def __init__(self, api_key: Optional[str] = None):
56
+ if api_key:
57
+ subprocess.run(["billingo", "config", "set", "apiKey", api_key])
58
+
59
+ def _run(self, args: List[str], format: str = "json") -> Any:
60
+ """Execute CLI command and return parsed result"""
61
+ cmd = ["billingo"] + args + ["--format", format]
62
+ result = subprocess.run(cmd, capture_output=True, text=True)
63
+
64
+ if result.returncode != 0:
65
+ raise Exception(f"Billingo CLI error: {result.stderr}")
66
+
67
+ if format == "json":
68
+ return json.loads(result.stdout)
69
+ return result.stdout
70
+
71
+ # Documents
72
+ def list_documents(self, page: int = 1, per_page: int = 25, **filters) -> Dict:
73
+ """List all documents with optional filters"""
74
+ args = ["documents", "list", "--page", str(page), "--per-page", str(per_page)]
75
+
76
+ # Add filters
77
+ if "partner_id" in filters:
78
+ args += ["--partner-id", str(filters["partner_id"])]
79
+ if "payment_status" in filters:
80
+ args += ["--payment-status", filters["payment_status"]]
81
+ if "start_date" in filters:
82
+ args += ["--start-date", filters["start_date"]]
83
+ if "end_date" in filters:
84
+ args += ["--end-date", filters["end_date"]]
85
+
86
+ return self._run(args)
87
+
88
+ def get_document(self, doc_id: int) -> Dict:
89
+ """Get document by ID"""
90
+ return self._run(["documents", "get", str(doc_id)])
91
+
92
+ def create_document(self, data: Dict) -> Dict:
93
+ """Create a new document"""
94
+ import tempfile
95
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
96
+ json.dump(data, f)
97
+ f.flush()
98
+ result = self._run(["documents", "create", "--file", f.name])
99
+ return result
100
+
101
+ def cancel_document(self, doc_id: int) -> Dict:
102
+ """Cancel a document"""
103
+ return self._run(["documents", "cancel", str(doc_id)])
104
+
105
+ def download_document(self, doc_id: int, output_path: str) -> str:
106
+ """Download document PDF"""
107
+ self._run(["documents", "download", str(doc_id), "--output", output_path], format="pretty")
108
+ return output_path
109
+
110
+ def send_document(self, doc_id: int, emails: List[str]) -> Dict:
111
+ """Send document via email"""
112
+ return self._run(["documents", "send", str(doc_id), "--emails", ",".join(emails)])
113
+
114
+ # Partners
115
+ def list_partners(self, page: int = 1, per_page: int = 25) -> Dict:
116
+ """List all partners"""
117
+ return self._run(["partners", "list", "--page", str(page), "--per-page", str(per_page)])
118
+
119
+ def get_partner(self, partner_id: int) -> Dict:
120
+ """Get partner by ID"""
121
+ return self._run(["partners", "get", str(partner_id)])
122
+
123
+ def create_partner(self, data: Dict) -> Dict:
124
+ """Create a new partner"""
125
+ import tempfile
126
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
127
+ json.dump(data, f)
128
+ f.flush()
129
+ result = self._run(["partners", "create", "--file", f.name])
130
+ return result
131
+
132
+ # Products
133
+ def list_products(self, page: int = 1, per_page: int = 25) -> Dict:
134
+ """List all products"""
135
+ return self._run(["products", "list", "--page", str(page), "--per-page", str(per_page)])
136
+
137
+ def create_product(self, data: Dict) -> Dict:
138
+ """Create a new product"""
139
+ import tempfile
140
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
141
+ json.dump(data, f)
142
+ f.flush()
143
+ result = self._run(["products", "create", "--file", f.name])
144
+ return result
145
+
146
+ # Bank Accounts
147
+ def list_bank_accounts(self, page: int = 1, per_page: int = 25) -> Dict:
148
+ """List all bank accounts"""
149
+ return self._run(["bank-accounts", "list", "--page", str(page), "--per-page", str(per_page)])
150
+
151
+ # Currencies
152
+ def convert_currency(self, from_currency: str, to_currency: str) -> Dict:
153
+ """Get currency conversion rate"""
154
+ return self._run(["currencies", "convert", "--from", from_currency, "--to", to_currency])
155
+
156
+ # Organization
157
+ def get_organization(self) -> Dict:
158
+ """Get organization data"""
159
+ return self._run(["organization", "get"])
160
+ ```
161
+
162
+ ## Example OpenClaw Agent
163
+
164
+ Here's a complete example of an OpenClaw agent that handles invoicing:
165
+
166
+ ```python
167
+ # agents/invoice_agent.py
168
+ from openclaw import Agent, tool
169
+ from tools.billingo import BillingoTool
170
+
171
+ class InvoiceAgent(Agent):
172
+ """AI agent for managing invoices via Billingo"""
173
+
174
+ def __init__(self):
175
+ super().__init__()
176
+ self.billingo = BillingoTool()
177
+
178
+ @tool
179
+ async def create_invoice(
180
+ self,
181
+ customer_name: str,
182
+ customer_email: str,
183
+ items: List[Dict],
184
+ due_days: int = 15
185
+ ) -> str:
186
+ """
187
+ Create and send an invoice to a customer.
188
+
189
+ Args:
190
+ customer_name: Customer company name
191
+ customer_email: Customer email address
192
+ items: List of invoice items [{"name": "...", "quantity": 1, "unit_price": 100, ...}]
193
+ due_days: Days until payment is due (default: 15)
194
+
195
+ Returns:
196
+ Invoice ID and public URL
197
+ """
198
+ from datetime import datetime, timedelta
199
+
200
+ # Find or create partner
201
+ partners = self.billingo.list_partners(per_page=100)
202
+ partner = None
203
+ for p in partners.get("data", []):
204
+ if p["name"] == customer_name:
205
+ partner = p
206
+ break
207
+
208
+ if not partner:
209
+ # Create new partner
210
+ partner = self.billingo.create_partner({
211
+ "name": customer_name,
212
+ "emails": [customer_email],
213
+ "address": {
214
+ "country_code": "HU",
215
+ "post_code": "1234",
216
+ "city": "Budapest",
217
+ "address": "Address TBD"
218
+ }
219
+ })
220
+
221
+ # Get organization details for vendor_id
222
+ org = self.billingo.get_organization()
223
+ vendor_id = org["id"]
224
+
225
+ # Get first document block
226
+ blocks = self.billingo._run(["document-blocks", "list"])
227
+ block_id = blocks["data"][0]["id"]
228
+
229
+ # Prepare invoice data
230
+ today = datetime.now().date()
231
+ due_date = today + timedelta(days=due_days)
232
+
233
+ invoice_data = {
234
+ "vendor_id": vendor_id,
235
+ "partner_id": partner["id"],
236
+ "block_id": block_id,
237
+ "type": "invoice",
238
+ "fulfillment_date": today.isoformat(),
239
+ "due_date": due_date.isoformat(),
240
+ "payment_method": "transfer",
241
+ "language": "en",
242
+ "currency": "HUF",
243
+ "items": items
244
+ }
245
+
246
+ # Create invoice
247
+ invoice = self.billingo.create_document(invoice_data)
248
+ invoice_id = invoice["id"]
249
+
250
+ # Send to customer
251
+ self.billingo.send_document(invoice_id, [customer_email])
252
+
253
+ # Get public URL
254
+ public_url = self.billingo._run(["documents", "public-url", str(invoice_id)])
255
+
256
+ return f"Invoice {invoice_id} created and sent to {customer_email}. " \
257
+ f"Public URL: {public_url['public_url']}"
258
+
259
+ @tool
260
+ async def list_unpaid_invoices(self) -> str:
261
+ """List all unpaid invoices"""
262
+ docs = self.billingo.list_documents(
263
+ per_page=100,
264
+ payment_status="unpaid"
265
+ )
266
+
267
+ if not docs.get("data"):
268
+ return "No unpaid invoices found."
269
+
270
+ result = "Unpaid Invoices:\n\n"
271
+ for doc in docs["data"]:
272
+ result += f"- Invoice {doc['invoice_number']}: {doc['partner_name']} - " \
273
+ f"{doc['total_gross']} {doc['currency']} (Due: {doc['due_date']})\n"
274
+
275
+ return result
276
+
277
+ @tool
278
+ async def send_invoice_reminder(self, invoice_id: int) -> str:
279
+ """Send a payment reminder for an invoice"""
280
+ # Get invoice details
281
+ doc = self.billingo.get_document(invoice_id)
282
+
283
+ # Send reminder
284
+ emails = [email for email in doc["partner"]["emails"]]
285
+ self.billingo.send_document(invoice_id, emails)
286
+
287
+ return f"Reminder sent for invoice {doc['invoice_number']} to {', '.join(emails)}"
288
+
289
+ @tool
290
+ async def get_invoice_status(self, invoice_number: str) -> str:
291
+ """Get the status of an invoice by number"""
292
+ # Search for invoice
293
+ docs = self.billingo.list_documents(per_page=100)
294
+
295
+ for doc in docs.get("data", []):
296
+ if doc["invoice_number"] == invoice_number:
297
+ return f"Invoice {invoice_number}:\n" \
298
+ f"Customer: {doc['partner_name']}\n" \
299
+ f"Amount: {doc['total_gross']} {doc['currency']}\n" \
300
+ f"Status: {doc['payment_status']}\n" \
301
+ f"Due: {doc['due_date']}"
302
+
303
+ return f"Invoice {invoice_number} not found."
304
+ ```
305
+
306
+ ## Usage Example
307
+
308
+ ```python
309
+ # main.py
310
+ from agents.invoice_agent import InvoiceAgent
311
+
312
+ async def main():
313
+ agent = InvoiceAgent()
314
+
315
+ # User request: "Create an invoice for Acme Corp"
316
+ response = await agent.run(
317
+ "Create an invoice for Acme Corp (acme@example.com) for 10 hours of consulting at 50000 HUF/hour with 27% VAT"
318
+ )
319
+
320
+ print(response)
321
+ # Output: Invoice 123 created and sent to acme@example.com. Public URL: https://...
322
+
323
+ if __name__ == "__main__":
324
+ import asyncio
325
+ asyncio.run(main())
326
+ ```
327
+
328
+ ## Environment Variables
329
+
330
+ Set these for containerized deployments:
331
+
332
+ ```bash
333
+ export BILLINGO_API_KEY=your_api_key_here
334
+ export BILLINGO_BASE_URL=https://api.billingo.hu/v3
335
+ ```
336
+
337
+ The CLI will automatically use these if no config is set.
338
+
339
+ ## Docker Integration
340
+
341
+ ```dockerfile
342
+ FROM node:18-alpine
343
+
344
+ # Install CLI
345
+ RUN npm install -g @ktmcp-cli/billingo
346
+
347
+ # Install Python for OpenClaw
348
+ RUN apk add --no-cache python3 py3-pip
349
+
350
+ # Copy your OpenClaw agent
351
+ COPY . /app
352
+ WORKDIR /app
353
+
354
+ # Install Python dependencies
355
+ RUN pip3 install -r requirements.txt
356
+
357
+ # Set environment
358
+ ENV BILLINGO_API_KEY=${BILLINGO_API_KEY}
359
+
360
+ # Run your agent
361
+ CMD ["python3", "main.py"]
362
+ ```
363
+
364
+ ## Error Handling
365
+
366
+ The wrapper should handle CLI errors gracefully:
367
+
368
+ ```python
369
+ def _run(self, args: List[str], format: str = "json") -> Any:
370
+ """Execute CLI command with error handling"""
371
+ cmd = ["billingo"] + args + ["--format", format]
372
+ result = subprocess.run(cmd, capture_output=True, text=True)
373
+
374
+ if result.returncode != 0:
375
+ error_msg = result.stderr
376
+
377
+ # Parse specific errors
378
+ if "API key not configured" in error_msg:
379
+ raise ValueError("Billingo API key not configured")
380
+ elif "Rate limit exceeded" in error_msg:
381
+ raise RateLimitError("API rate limit exceeded")
382
+ elif "Resource not found" in error_msg:
383
+ raise NotFoundError("Resource not found")
384
+ else:
385
+ raise Exception(f"Billingo API error: {error_msg}")
386
+
387
+ if format == "json":
388
+ try:
389
+ return json.loads(result.stdout)
390
+ except json.JSONDecodeError:
391
+ raise Exception(f"Invalid JSON response: {result.stdout}")
392
+
393
+ return result.stdout
394
+ ```
395
+
396
+ ## Advanced: Streaming Output
397
+
398
+ For long-running operations, stream output to the user:
399
+
400
+ ```python
401
+ import subprocess
402
+
403
+ def create_invoice_with_progress(data: Dict) -> Dict:
404
+ """Create invoice with progress updates"""
405
+ import tempfile
406
+
407
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
408
+ json.dump(data, f)
409
+ f.flush()
410
+
411
+ # Stream output line by line
412
+ process = subprocess.Popen(
413
+ ["billingo", "documents", "create", "--file", f.name],
414
+ stdout=subprocess.PIPE,
415
+ stderr=subprocess.PIPE,
416
+ text=True
417
+ )
418
+
419
+ # Read stderr for progress (ora spinner output)
420
+ for line in process.stderr:
421
+ print(f"Progress: {line.strip()}")
422
+
423
+ stdout, _ = process.communicate()
424
+
425
+ if process.returncode != 0:
426
+ raise Exception("Invoice creation failed")
427
+
428
+ return json.loads(stdout)
429
+ ```
430
+
431
+ ## Testing
432
+
433
+ Test your integration without API calls:
434
+
435
+ ```python
436
+ import unittest
437
+ from unittest.mock import patch, MagicMock
438
+
439
+ class TestBillingoTool(unittest.TestCase):
440
+ @patch('subprocess.run')
441
+ def test_list_documents(self, mock_run):
442
+ # Mock CLI response
443
+ mock_run.return_value = MagicMock(
444
+ returncode=0,
445
+ stdout='{"data":[{"id":123}]}'
446
+ )
447
+
448
+ tool = BillingoTool()
449
+ result = tool.list_documents()
450
+
451
+ self.assertEqual(result["data"][0]["id"], 123)
452
+ mock_run.assert_called_once()
453
+ ```
454
+
455
+ ## Best Practices
456
+
457
+ 1. **Cache Organization Data**: Call `get_organization()` once and cache the result
458
+ 2. **Reuse Partner IDs**: Search for existing partners before creating new ones
459
+ 3. **Handle Rate Limits**: Implement exponential backoff for batch operations
460
+ 4. **Validate Before Creating**: Check data structure before calling create commands
461
+ 5. **Store Invoice PDFs**: Download and archive PDFs for record-keeping
462
+ 6. **Use Transactions**: Group related operations (create partner → create invoice → send)
463
+ 7. **Log Everything**: Keep audit logs of all invoice operations
464
+
465
+ ## Troubleshooting
466
+
467
+ ### CLI Not Found
468
+
469
+ ```bash
470
+ # Check installation
471
+ which billingo
472
+
473
+ # Reinstall if needed
474
+ npm install -g @ktmcp-cli/billingo
475
+ ```
476
+
477
+ ### API Key Issues
478
+
479
+ ```bash
480
+ # Verify API key is set
481
+ billingo config get apiKey
482
+
483
+ # Test connection
484
+ billingo organization get
485
+ ```
486
+
487
+ ### Permission Errors
488
+
489
+ Make sure the OpenClaw agent has execute permissions:
490
+
491
+ ```bash
492
+ chmod +x $(which billingo)
493
+ ```
494
+
495
+ ## Further Reading
496
+
497
+ - [README.md](./README.md) - General CLI usage
498
+ - [AGENT.md](./AGENT.md) - AI agent usage patterns
499
+ - [Billingo API Docs](https://api.billingo.hu/v3/swagger)
500
+
501
+ ## Support
502
+
503
+ For issues specific to OpenClaw integration, please check the OpenClaw documentation or open an issue in the CLI repository.