billos 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.
billos-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BillOS (Pingo Creative)
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.
billos-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: billos
3
+ Version: 0.1.0
4
+ Summary: BillOS for Python: Israeli tax documents, expenses, webhooks and the regulatory outputs, over one small client.
5
+ Author: BillOS
6
+ License: MIT
7
+ Project-URL: Documentation, https://docs.billos.co.il
8
+ Project-URL: Homepage, https://billos.co.il
9
+ Project-URL: Source, https://github.com/baraviz/pest-os/tree/main/packages/billos-python
10
+ Keywords: billos,invoice,bookkeeping,israel,tax,api
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # billos (Python)
21
+
22
+ BillOS from Python: legally valid Israeli tax documents (חשבונית מס, קבלה, זיכוי), the expense ledger, webhooks and the regulatory outputs, over one small client with no dependencies beyond the standard library (Python 3.9+).
23
+
24
+ ```bash
25
+ pip install billos
26
+ ```
27
+
28
+ ```python
29
+ from billos import BillOS, BillOSError
30
+
31
+ billos = BillOS(api_key=os.environ["BILLOS_API_KEY"])
32
+ bid = billos.businesses.list()["businesses"][0]["id"]
33
+
34
+ created = billos.documents.create(bid, {
35
+ "docType": 320,
36
+ "priceMode": "gross",
37
+ "party": {"name": "דנה לוי", "phone": "0521111111"},
38
+ "lines": [{"description": "איפור כלה", "quantity": 1, "unitPriceExVat": 120000}],
39
+ "payments": [{"method": "card", "amount": 120000}],
40
+ }, idempotency_key="order-8812")
41
+
42
+ issued = billos.documents.issue(bid, created["document"]["id"])
43
+ print(issued["document"]["docNumber"])
44
+
45
+ pdf = billos.documents.print(bid, created["document"]["id"]) # Binary: .bytes, .content_type, .variant
46
+ link = billos.documents.share(bid, created["document"]["id"]) # a public URL for the customer
47
+ ```
48
+
49
+ Money is **integer agorot** everywhere, exactly like the API (₪354.00 is `35400`); a foreign-currency document (`"currency": "USD"`) is in that currency's cents. Nothing in the client converts.
50
+
51
+ ## What you get
52
+
53
+ - Every operation in the spec as `namespace.method` (snake_case): `businesses`, `parties`, `documents`, `recurring`, `expenses`, `exports`, `files`, `webhooks`, `events`, `keys`, `usage`, `sandbox`. `OPERATIONS` (method and path per operation) is generated from the OpenAPI spec, and a test checks every operation has a method, so the client cannot lag behind the API.
54
+ - The conventions handled: `X-Api-Key`, the `{ok, ...}` envelope returned as a dict (plus `request_id`), `Idempotency-Key` from `idempotency_key=`, `Retry-After` honoured.
55
+ - Retries on 429 / 502 / 503 / 504 and network errors, up to `max_retries` (default 2), for GET/DELETE and for POSTs carrying an idempotency key; never for a POST without one.
56
+ - `BillOSError` with `status`, `reason`, `request_id`, `body`, `retryable`. Branch on `reason`.
57
+ - Binary answers (`documents.preview`, `documents.print`, `files.get`, a PDF report) come back as `Binary(bytes, content_type, variant, request_id)`.
58
+ - `verify_webhook_signature(secret, header, raw_body)` for the `X-BillOS-Signature` header.
59
+ - A `bk_test_` key answers from the sandbox with no change on your side (`billos.sandbox.seed()` fills it with demo data).
60
+
61
+ Documentation and the OpenAPI spec: https://docs.billos.co.il. MIT.
billos-0.1.0/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # billos (Python)
2
+
3
+ BillOS from Python: legally valid Israeli tax documents (חשבונית מס, קבלה, זיכוי), the expense ledger, webhooks and the regulatory outputs, over one small client with no dependencies beyond the standard library (Python 3.9+).
4
+
5
+ ```bash
6
+ pip install billos
7
+ ```
8
+
9
+ ```python
10
+ from billos import BillOS, BillOSError
11
+
12
+ billos = BillOS(api_key=os.environ["BILLOS_API_KEY"])
13
+ bid = billos.businesses.list()["businesses"][0]["id"]
14
+
15
+ created = billos.documents.create(bid, {
16
+ "docType": 320,
17
+ "priceMode": "gross",
18
+ "party": {"name": "דנה לוי", "phone": "0521111111"},
19
+ "lines": [{"description": "איפור כלה", "quantity": 1, "unitPriceExVat": 120000}],
20
+ "payments": [{"method": "card", "amount": 120000}],
21
+ }, idempotency_key="order-8812")
22
+
23
+ issued = billos.documents.issue(bid, created["document"]["id"])
24
+ print(issued["document"]["docNumber"])
25
+
26
+ pdf = billos.documents.print(bid, created["document"]["id"]) # Binary: .bytes, .content_type, .variant
27
+ link = billos.documents.share(bid, created["document"]["id"]) # a public URL for the customer
28
+ ```
29
+
30
+ Money is **integer agorot** everywhere, exactly like the API (₪354.00 is `35400`); a foreign-currency document (`"currency": "USD"`) is in that currency's cents. Nothing in the client converts.
31
+
32
+ ## What you get
33
+
34
+ - Every operation in the spec as `namespace.method` (snake_case): `businesses`, `parties`, `documents`, `recurring`, `expenses`, `exports`, `files`, `webhooks`, `events`, `keys`, `usage`, `sandbox`. `OPERATIONS` (method and path per operation) is generated from the OpenAPI spec, and a test checks every operation has a method, so the client cannot lag behind the API.
35
+ - The conventions handled: `X-Api-Key`, the `{ok, ...}` envelope returned as a dict (plus `request_id`), `Idempotency-Key` from `idempotency_key=`, `Retry-After` honoured.
36
+ - Retries on 429 / 502 / 503 / 504 and network errors, up to `max_retries` (default 2), for GET/DELETE and for POSTs carrying an idempotency key; never for a POST without one.
37
+ - `BillOSError` with `status`, `reason`, `request_id`, `body`, `retryable`. Branch on `reason`.
38
+ - Binary answers (`documents.preview`, `documents.print`, `files.get`, a PDF report) come back as `Binary(bytes, content_type, variant, request_id)`.
39
+ - `verify_webhook_signature(secret, header, raw_body)` for the `X-BillOS-Signature` header.
40
+ - A `bk_test_` key answers from the sandbox with no change on your side (`billos.sandbox.seed()` fills it with demo data).
41
+
42
+ Documentation and the OpenAPI spec: https://docs.billos.co.il. MIT.
@@ -0,0 +1,4 @@
1
+ """BillOS for Python: a thin, dependency-free client over the ledger API (https://docs.billos.co.il)."""
2
+ from .client import BillOS, BillOSError, OPERATIONS, SURFACE, DEFAULT_BASE_URL, VERSION, verify_webhook_signature
3
+
4
+ __all__ = ["BillOS", "BillOSError", "OPERATIONS", "SURFACE", "DEFAULT_BASE_URL", "VERSION", "verify_webhook_signature"]
@@ -0,0 +1,62 @@
1
+ # GENERATED from the BillOS OpenAPI spec by packages/billos-sdk/scripts/generate.mjs. Do not edit.
2
+ # operationId -> (method, path template). The client's namespaces name these; a test in the Node
3
+ # SDK regenerates this file and fails when the commit is stale.
4
+ OPERATIONS = {
5
+ "createBusiness": ("POST", "/businesses"),
6
+ "listBusinesses": ("GET", "/businesses"),
7
+ "getBusiness": ("GET", "/businesses/{bid}"),
8
+ "updateBusiness": ("PATCH", "/businesses/{bid}"),
9
+ "deactivateBusiness": ("POST", "/businesses/{bid}/deactivate"),
10
+ "reactivateBusiness": ("POST", "/businesses/{bid}/reactivate"),
11
+ "createParty": ("POST", "/businesses/{bid}/parties"),
12
+ "listParties": ("GET", "/businesses/{bid}/parties"),
13
+ "updateParty": ("PATCH", "/businesses/{bid}/parties/{id}"),
14
+ "recordPartyConsent": ("POST", "/businesses/{bid}/parties/{id}/consent"),
15
+ "getFxRate": ("GET", "/businesses/{bid}/fx-rate"),
16
+ "createDocument": ("POST", "/businesses/{bid}/documents"),
17
+ "listDocuments": ("GET", "/businesses/{bid}/documents"),
18
+ "getDocument": ("GET", "/businesses/{bid}/documents/{id}"),
19
+ "updateDocument": ("PATCH", "/businesses/{bid}/documents/{id}"),
20
+ "deleteDocument": ("DELETE", "/businesses/{bid}/documents/{id}"),
21
+ "issueDocument": ("POST", "/businesses/{bid}/documents/{id}/issue"),
22
+ "issueCreditNote": ("POST", "/businesses/{bid}/documents/{id}/credit-note"),
23
+ "previewDocument": ("POST", "/businesses/{bid}/documents/{id}/preview"),
24
+ "printDocument": ("POST", "/businesses/{bid}/documents/{id}/prints"),
25
+ "createRecurring": ("POST", "/businesses/{bid}/recurring"),
26
+ "listRecurring": ("GET", "/businesses/{bid}/recurring"),
27
+ "getRecurring": ("GET", "/businesses/{bid}/recurring/{id}"),
28
+ "updateRecurring": ("PATCH", "/businesses/{bid}/recurring/{id}"),
29
+ "deleteRecurring": ("DELETE", "/businesses/{bid}/recurring/{id}"),
30
+ "runRecurring": ("POST", "/businesses/{bid}/recurring/{id}/run"),
31
+ "createExpense": ("POST", "/businesses/{bid}/expenses"),
32
+ "listExpenses": ("GET", "/businesses/{bid}/expenses"),
33
+ "shareDocument": ("POST", "/businesses/{bid}/documents/{id}/share"),
34
+ "emailDocument": ("POST", "/businesses/{bid}/documents/{id}/email"),
35
+ "findOrCreateParty": ("POST", "/businesses/{bid}/parties/find-or-create"),
36
+ "recordExpenseDirect": ("POST", "/businesses/{bid}/expenses/record"),
37
+ "deleteExpenseDraft": ("DELETE", "/businesses/{bid}/expenses/{id}"),
38
+ "getExpense": ("GET", "/businesses/{bid}/expenses/{id}"),
39
+ "updateExpense": ("PATCH", "/businesses/{bid}/expenses/{id}"),
40
+ "recordExpense": ("POST", "/businesses/{bid}/expenses/{id}/record"),
41
+ "correctExpense": ("POST", "/businesses/{bid}/expenses/{id}/correction"),
42
+ "ocrExpense": ("POST", "/businesses/{bid}/expenses/ocr"),
43
+ "exportOpenfrmt": ("POST", "/businesses/{bid}/exports/openfrmt"),
44
+ "continuityReport": ("GET", "/businesses/{bid}/reports/continuity"),
45
+ "managementReport": ("GET", "/businesses/{bid}/reports/{kind}"),
46
+ "createBackup": ("POST", "/businesses/{bid}/backup"),
47
+ "fileThumbnails": ("POST", "/businesses/{bid}/files/thumbs"),
48
+ "getFile": ("GET", "/businesses/{bid}/files/{id}"),
49
+ "listKeys": ("GET", "/keys"),
50
+ "updateKey": ("PATCH", "/keys/{id}"),
51
+ "getUsage": ("GET", "/usage"),
52
+ "createWebhook": ("POST", "/webhooks"),
53
+ "listWebhooks": ("GET", "/webhooks"),
54
+ "updateWebhook": ("PATCH", "/webhooks/{id}"),
55
+ "deleteWebhook": ("DELETE", "/webhooks/{id}"),
56
+ "listWebhookDeliveries": ("GET", "/webhooks/{id}/deliveries"),
57
+ "redeliverWebhook": ("POST", "/webhooks/{id}/deliveries/{did}/redeliver"),
58
+ "seedSandbox": ("POST", "/sandbox/seed"),
59
+ "listEvents": ("GET", "/events"),
60
+ "getEvent": ("GET", "/events/{id}"),
61
+ "testWebhook": ("POST", "/webhooks/{id}/test"),
62
+ }
@@ -0,0 +1,320 @@
1
+ """billos: BillOS from Python.
2
+
3
+ A thin wrapper over the ledger API with no dependencies beyond the standard library: one
4
+ ``request()`` that speaks the API's conventions (``X-Api-Key``, the ``{ok, ...}`` envelope,
5
+ the ``{error, reason}`` failure shape, ``Idempotency-Key``, ``X-Request-Id``,
6
+ ``Retry-After``) and resource namespaces that name every operation in the OpenAPI spec.
7
+
8
+ Money is integer agorot everywhere, exactly like the API. Amounts on a foreign-currency
9
+ document are that currency's minor units. Nothing here converts.
10
+
11
+ ``_operations.py`` (method + path per operationId) is GENERATED from the OpenAPI spec by the
12
+ Node SDK's generator and kept fresh by its test; ``SURFACE`` names where each operation lives
13
+ on this client, and a test walks it, so a new endpoint without a Python method fails the suite.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ import re
21
+ import time
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from typing import Any, Callable, Dict, Optional, Tuple
26
+
27
+ from ._operations import OPERATIONS # generated from the OpenAPI spec (see packages/billos-sdk/scripts/generate.mjs)
28
+
29
+ DEFAULT_BASE_URL = "https://api.billos.co.il/v1"
30
+ VERSION = "0.1.0"
31
+
32
+ # operationId -> "namespace.method" on the client (what the drift test walks).
33
+ SURFACE: Dict[str, str] = {
34
+ "createBusiness": "businesses.create", "listBusinesses": "businesses.list", "getBusiness": "businesses.get",
35
+ "updateBusiness": "businesses.update", "deactivateBusiness": "businesses.deactivate", "reactivateBusiness": "businesses.reactivate",
36
+ "listKeys": "keys.list", "updateKey": "keys.update", "getUsage": "usage.get",
37
+ "createParty": "parties.create", "listParties": "parties.list", "findOrCreateParty": "parties.find_or_create",
38
+ "updateParty": "parties.update", "recordPartyConsent": "parties.record_consent",
39
+ "getFxRate": "documents.fx_rate", "createDocument": "documents.create", "listDocuments": "documents.list",
40
+ "getDocument": "documents.get", "updateDocument": "documents.update", "deleteDocument": "documents.delete",
41
+ "issueDocument": "documents.issue", "issueCreditNote": "documents.credit_note", "previewDocument": "documents.preview",
42
+ "printDocument": "documents.print", "shareDocument": "documents.share", "emailDocument": "documents.email",
43
+ "createRecurring": "recurring.create", "listRecurring": "recurring.list", "getRecurring": "recurring.get",
44
+ "updateRecurring": "recurring.update", "deleteRecurring": "recurring.delete", "runRecurring": "recurring.run",
45
+ "createExpense": "expenses.create", "recordExpenseDirect": "expenses.record", "listExpenses": "expenses.list",
46
+ "getExpense": "expenses.get", "updateExpense": "expenses.update", "deleteExpenseDraft": "expenses.delete",
47
+ "recordExpense": "expenses.record_draft", "correctExpense": "expenses.correct", "ocrExpense": "expenses.ocr",
48
+ "exportOpenfrmt": "exports.openfrmt", "continuityReport": "exports.continuity", "managementReport": "exports.report",
49
+ "createBackup": "exports.backup", "fileThumbnails": "files.thumbnails", "getFile": "files.get",
50
+ "createWebhook": "webhooks.create", "listWebhooks": "webhooks.list", "updateWebhook": "webhooks.update",
51
+ "deleteWebhook": "webhooks.delete", "listWebhookDeliveries": "webhooks.deliveries", "redeliverWebhook": "webhooks.redeliver",
52
+ "testWebhook": "webhooks.test", "listEvents": "events.list", "getEvent": "events.get", "seedSandbox": "sandbox.seed",
53
+ }
54
+
55
+ TRANSIENT = {429, 502, 503, 504}
56
+
57
+
58
+ class BillOSError(Exception):
59
+ """Every failure. ``reason`` is the API's stable code; ``request_id`` is what to quote to support."""
60
+
61
+ def __init__(self, status: int, reason: Optional[str], message: str, request_id: Optional[str] = None, body: Any = None):
62
+ super().__init__(message)
63
+ self.status = status
64
+ self.reason = reason or ("internal" if status >= 500 else "error")
65
+ self.request_id = request_id
66
+ self.body = body
67
+
68
+ @property
69
+ def retryable(self) -> bool:
70
+ """Transient: a 429/502/503/504 or a network failure. Re-send GET/DELETE freely; re-send a
71
+ POST only with the same idempotency key, or you may create the thing twice."""
72
+ return self.status in TRANSIENT or self.status == 0
73
+
74
+
75
+ class Binary:
76
+ """A binary answer: PDF bytes, a stored file."""
77
+
78
+ def __init__(self, data: bytes, content_type: str, variant: Optional[str], request_id: Optional[str]):
79
+ self.bytes = data
80
+ self.content_type = content_type
81
+ self.variant = variant
82
+ self.request_id = request_id
83
+
84
+
85
+ # The transport seam: (method, url, headers, body) -> (status, headers dict, body bytes).
86
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Dict[str, str], bytes]]
87
+
88
+
89
+ def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float):
90
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
91
+ try:
92
+ with urllib.request.urlopen(req, timeout=timeout) as res: # noqa: S310 (https to our own API)
93
+ return res.status, {k.lower(): v for k, v in res.headers.items()}, res.read()
94
+ except urllib.error.HTTPError as e:
95
+ return e.code, {k.lower(): v for k, v in e.headers.items()}, e.read()
96
+
97
+
98
+ def _fill(template: str, params: Dict[str, Any]) -> str:
99
+ def one(m: "re.Match[str]") -> str:
100
+ v = params.get(m.group(1))
101
+ if v is None or v == "":
102
+ raise TypeError(f'billos: missing path parameter "{m.group(1)}" for {template}')
103
+ return urllib.parse.quote(str(v), safe="")
104
+ return re.sub(r"\{(\w+)\}", one, template)
105
+
106
+
107
+ def _query(query: Optional[Dict[str, Any]]) -> str:
108
+ if not query:
109
+ return ""
110
+ pairs = [(k, str(v).lower() if isinstance(v, bool) else str(v)) for k, v in query.items() if v is not None and v != ""]
111
+ return ("?" + urllib.parse.urlencode(pairs)) if pairs else ""
112
+
113
+
114
+ class _NS:
115
+ def __init__(self, client: "BillOS"):
116
+ self._c = client
117
+
118
+
119
+ class BillOS:
120
+ """``BillOS(api_key=...)``. Every method returns the API's JSON envelope as a dict (with
121
+ ``request_id`` added, non-standard but harmless) or a ``Binary`` for PDFs and files."""
122
+
123
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 30.0, max_retries: int = 2,
124
+ headers: Optional[Dict[str, str]] = None, transport: Optional[Transport] = None, sleep: Callable[[float], None] = time.sleep):
125
+ if not api_key or not isinstance(api_key, str):
126
+ raise TypeError("billos: api_key is required")
127
+ self.api_key = api_key
128
+ self.base_url = base_url.rstrip("/")
129
+ self.timeout = timeout
130
+ self.max_retries = max_retries
131
+ self.headers = dict(headers or {})
132
+ self._transport = transport or _urllib_transport
133
+ self._sleep = sleep
134
+ self.businesses = _Businesses(self)
135
+ self.keys = _Keys(self)
136
+ self.usage = _Usage(self)
137
+ self.parties = _Parties(self)
138
+ self.documents = _Documents(self)
139
+ self.recurring = _Recurring(self)
140
+ self.expenses = _Expenses(self)
141
+ self.exports = _Exports(self)
142
+ self.files = _Files(self)
143
+ self.webhooks = _Webhooks(self)
144
+ self.events = _Events(self)
145
+ self.sandbox = _Sandbox(self)
146
+
147
+ # ── the one call behind every method ────────────────────────────────────────────────
148
+ def request(self, op: str, path: Optional[Dict[str, Any]] = None, query: Optional[Dict[str, Any]] = None, body: Any = None,
149
+ idempotency_key: Optional[str] = None, headers: Optional[Dict[str, str]] = None, binary: bool = False,
150
+ raw: Optional[Tuple[bytes, str]] = None):
151
+ method, template = OPERATIONS[op]
152
+ url = self.base_url + _fill(template, path or {}) + _query(query)
153
+ can_retry = method in ("GET", "DELETE") or bool(idempotency_key)
154
+ h = {"X-Api-Key": self.api_key, "Accept": "*/*" if binary else "application/json", "User-Agent": f"billos-python/{VERSION}"}
155
+ h.update(self.headers)
156
+ h.update(headers or {})
157
+ if idempotency_key:
158
+ h["Idempotency-Key"] = str(idempotency_key)
159
+ payload: Optional[bytes] = None
160
+ if raw is not None:
161
+ payload, h["Content-Type"] = raw[0], raw[1]
162
+ elif body is not None:
163
+ payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
164
+ h["Content-Type"] = "application/json"
165
+
166
+ attempt = 0
167
+ while True:
168
+ attempt += 1
169
+ try:
170
+ status, rh, data = self._transport(method, url, h, payload, self.timeout)
171
+ except Exception as e: # network
172
+ if can_retry and attempt <= self.max_retries:
173
+ self._sleep(0.25 * (2 ** (attempt - 1)))
174
+ continue
175
+ raise BillOSError(0, "network", f"billos: {e}") from e
176
+ request_id = rh.get("x-request-id")
177
+ if 200 <= status < 300:
178
+ ctype = rh.get("content-type", "")
179
+ if binary or "json" not in ctype:
180
+ return Binary(data, ctype or "application/octet-stream", rh.get("x-print-variant"), request_id)
181
+ out = json.loads(data.decode("utf-8")) if data else {}
182
+ if isinstance(out, dict):
183
+ out.setdefault("request_id", request_id)
184
+ return out
185
+ try:
186
+ err_body = json.loads(data.decode("utf-8")) if data else None
187
+ except Exception:
188
+ err_body = None
189
+ if status in TRANSIENT and can_retry and attempt <= self.max_retries:
190
+ try:
191
+ ra = float(rh.get("retry-after", ""))
192
+ except ValueError:
193
+ ra = 0.0
194
+ self._sleep(min(ra, 30.0) if ra > 0 else 0.25 * (2 ** (attempt - 1)))
195
+ continue
196
+ ed = err_body if isinstance(err_body, dict) else {}
197
+ raise BillOSError(status, ed.get("reason"), ed.get("error") or f"HTTP {status}", request_id, err_body)
198
+
199
+
200
+ class _Businesses(_NS):
201
+ def list(self, query=None, **o): return self._c.request("listBusinesses", query=query, **o)
202
+ def create(self, body, **o): return self._c.request("createBusiness", body=body, **o)
203
+ def get(self, bid, **o): return self._c.request("getBusiness", path={"bid": bid}, **o)
204
+ def update(self, bid, body, **o): return self._c.request("updateBusiness", path={"bid": bid}, body=body, **o)
205
+ def deactivate(self, bid, **o): return self._c.request("deactivateBusiness", path={"bid": bid}, **o)
206
+ def reactivate(self, bid, **o): return self._c.request("reactivateBusiness", path={"bid": bid}, **o)
207
+
208
+
209
+ class _Keys(_NS):
210
+ def list(self, **o): return self._c.request("listKeys", **o)
211
+ def update(self, key_id, body, **o): return self._c.request("updateKey", path={"id": key_id}, body=body, **o)
212
+
213
+
214
+ class _Usage(_NS):
215
+ def get(self, query=None, **o): return self._c.request("getUsage", query=query, **o)
216
+
217
+
218
+ class _Parties(_NS):
219
+ def list(self, bid, query=None, **o): return self._c.request("listParties", path={"bid": bid}, query=query, **o)
220
+ def create(self, bid, body, **o): return self._c.request("createParty", path={"bid": bid}, body=body, **o)
221
+ def find_or_create(self, bid, body, **o): return self._c.request("findOrCreateParty", path={"bid": bid}, body=body, **o)
222
+ def update(self, bid, party_id, body, **o): return self._c.request("updateParty", path={"bid": bid, "id": party_id}, body=body, **o)
223
+ def record_consent(self, bid, party_id, body=None, **o): return self._c.request("recordPartyConsent", path={"bid": bid, "id": party_id}, body=body or {}, **o)
224
+
225
+
226
+ class _Documents(_NS):
227
+ def fx_rate(self, bid, query, **o): return self._c.request("getFxRate", path={"bid": bid}, query=query, **o)
228
+ def create(self, bid, body, **o): return self._c.request("createDocument", path={"bid": bid}, body=body, **o)
229
+ def list(self, bid, query=None, **o): return self._c.request("listDocuments", path={"bid": bid}, query=query, **o)
230
+ def get(self, bid, doc_id, **o): return self._c.request("getDocument", path={"bid": bid, "id": doc_id}, **o)
231
+ def update(self, bid, doc_id, body, **o): return self._c.request("updateDocument", path={"bid": bid, "id": doc_id}, body=body, **o)
232
+ def delete(self, bid, doc_id, **o): return self._c.request("deleteDocument", path={"bid": bid, "id": doc_id}, **o)
233
+ def issue(self, bid, doc_id, body=None, **o): return self._c.request("issueDocument", path={"bid": bid, "id": doc_id}, body=body or {}, **o)
234
+ def credit_note(self, bid, doc_id, body=None, **o): return self._c.request("issueCreditNote", path={"bid": bid, "id": doc_id}, body=body or {}, **o)
235
+ def preview(self, bid, doc_id, body=None, **o): return self._c.request("previewDocument", path={"bid": bid, "id": doc_id}, body=body or {}, binary=True, **o)
236
+ def print(self, bid, doc_id, body=None, **o): return self._c.request("printDocument", path={"bid": bid, "id": doc_id}, body=body or {"kind": "auto"}, binary=True, **o)
237
+ def share(self, bid, doc_id, body=None, **o): return self._c.request("shareDocument", path={"bid": bid, "id": doc_id}, body=body or {"kind": "auto"}, **o)
238
+ def email(self, bid, doc_id, body, **o): return self._c.request("emailDocument", path={"bid": bid, "id": doc_id}, body=body, **o)
239
+
240
+
241
+ class _Recurring(_NS):
242
+ def create(self, bid, body, **o): return self._c.request("createRecurring", path={"bid": bid}, body=body, **o)
243
+ def list(self, bid, query=None, **o): return self._c.request("listRecurring", path={"bid": bid}, query=query, **o)
244
+ def get(self, bid, plan_id, **o): return self._c.request("getRecurring", path={"bid": bid, "id": plan_id}, **o)
245
+ def update(self, bid, plan_id, body, **o): return self._c.request("updateRecurring", path={"bid": bid, "id": plan_id}, body=body, **o)
246
+ def delete(self, bid, plan_id, **o): return self._c.request("deleteRecurring", path={"bid": bid, "id": plan_id}, **o)
247
+ def run(self, bid, plan_id, **o): return self._c.request("runRecurring", path={"bid": bid, "id": plan_id}, **o)
248
+
249
+
250
+ class _Expenses(_NS):
251
+ def create(self, bid, body, **o): return self._c.request("createExpense", path={"bid": bid}, body=body, **o)
252
+ def record(self, bid, body, **o): return self._c.request("recordExpenseDirect", path={"bid": bid}, body=body, **o)
253
+ def list(self, bid, query=None, **o): return self._c.request("listExpenses", path={"bid": bid}, query=query, **o)
254
+ def get(self, bid, expense_id, **o): return self._c.request("getExpense", path={"bid": bid, "id": expense_id}, **o)
255
+ def update(self, bid, expense_id, body, **o): return self._c.request("updateExpense", path={"bid": bid, "id": expense_id}, body=body, **o)
256
+ def delete(self, bid, expense_id, **o): return self._c.request("deleteExpenseDraft", path={"bid": bid, "id": expense_id}, **o)
257
+ def record_draft(self, bid, expense_id, **o): return self._c.request("recordExpense", path={"bid": bid, "id": expense_id}, **o)
258
+ def correct(self, bid, expense_id, body=None, **o): return self._c.request("correctExpense", path={"bid": bid, "id": expense_id}, body=body or {}, **o)
259
+
260
+ def ocr(self, bid, data: bytes, mime: str = "image/jpeg", **o):
261
+ """A receipt image or PDF -> a pre-filled draft. The bytes are the body with their own Content-Type."""
262
+ return self._c.request("ocrExpense", path={"bid": bid}, raw=(data, mime), **o)
263
+
264
+
265
+ class _Exports(_NS):
266
+ def openfrmt(self, bid, body, **o): return self._c.request("exportOpenfrmt", path={"bid": bid}, body=body, **o)
267
+ def continuity(self, bid, query=None, **o): return self._c.request("continuityReport", path={"bid": bid}, query=query, **o)
268
+
269
+ def report(self, bid, kind, query=None, **o):
270
+ """``format='json'`` answers the report data; otherwise PDF bytes."""
271
+ return self._c.request("managementReport", path={"bid": bid, "kind": kind}, query=query, binary=(query or {}).get("format", "pdf") != "json", **o)
272
+
273
+ def backup(self, bid, **o): return self._c.request("createBackup", path={"bid": bid}, **o)
274
+
275
+
276
+ class _Files(_NS):
277
+ def thumbnails(self, bid, body, **o): return self._c.request("fileThumbnails", path={"bid": bid}, body=body, **o)
278
+ def get(self, bid, file_id, **o): return self._c.request("getFile", path={"bid": bid, "id": file_id}, binary=True, **o)
279
+
280
+
281
+ class _Webhooks(_NS):
282
+ def create(self, body, **o): return self._c.request("createWebhook", body=body, **o)
283
+ def list(self, **o): return self._c.request("listWebhooks", **o)
284
+ def update(self, webhook_id, body, **o): return self._c.request("updateWebhook", path={"id": webhook_id}, body=body, **o)
285
+ def delete(self, webhook_id, **o): return self._c.request("deleteWebhook", path={"id": webhook_id}, **o)
286
+ def deliveries(self, webhook_id, query=None, **o): return self._c.request("listWebhookDeliveries", path={"id": webhook_id}, query=query, **o)
287
+ def redeliver(self, webhook_id, delivery_id, **o): return self._c.request("redeliverWebhook", path={"id": webhook_id, "did": delivery_id}, **o)
288
+ def test(self, webhook_id, **o): return self._c.request("testWebhook", path={"id": webhook_id}, **o)
289
+
290
+
291
+ class _Events(_NS):
292
+ def list(self, query=None, **o): return self._c.request("listEvents", query=query, **o)
293
+ def get(self, event_id, **o): return self._c.request("getEvent", path={"id": event_id}, **o)
294
+
295
+
296
+ class _Sandbox(_NS):
297
+ def seed(self, **o):
298
+ """Test keys only: a demo business with customers, documents and expenses. Idempotent."""
299
+ return self._c.request("seedSandbox", **o)
300
+
301
+
302
+ def verify_webhook_signature(secret: str, header: str, raw_body: bytes, tolerance_seconds: int = 300, now: Optional[float] = None) -> bool:
303
+ """Verify ``X-BillOS-Signature`` ("t=<unix>,v1=<hex>"): HMAC-SHA256 over the bytes ``t + "." + raw_body``.
304
+
305
+ ``raw_body`` must be the request's raw bytes, not a parsed or re-serialized body."""
306
+ if not isinstance(raw_body, (bytes, bytearray)):
307
+ raise TypeError("billos: raw_body must be the raw request bytes")
308
+ parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
309
+ try:
310
+ t = int(parts.get("t", ""))
311
+ except ValueError:
312
+ return False
313
+ v1 = parts.get("v1")
314
+ if not v1:
315
+ return False
316
+ if abs((now if now is not None else time.time()) - t) > tolerance_seconds:
317
+ return False
318
+ expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + bytes(raw_body), hashlib.sha256).hexdigest()
319
+ # Bytes, so a forged header with non-ASCII characters is a False, not a TypeError.
320
+ return hmac.compare_digest(expected.encode("ascii"), v1.encode("utf-8"))
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: billos
3
+ Version: 0.1.0
4
+ Summary: BillOS for Python: Israeli tax documents, expenses, webhooks and the regulatory outputs, over one small client.
5
+ Author: BillOS
6
+ License: MIT
7
+ Project-URL: Documentation, https://docs.billos.co.il
8
+ Project-URL: Homepage, https://billos.co.il
9
+ Project-URL: Source, https://github.com/baraviz/pest-os/tree/main/packages/billos-python
10
+ Keywords: billos,invoice,bookkeeping,israel,tax,api
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # billos (Python)
21
+
22
+ BillOS from Python: legally valid Israeli tax documents (חשבונית מס, קבלה, זיכוי), the expense ledger, webhooks and the regulatory outputs, over one small client with no dependencies beyond the standard library (Python 3.9+).
23
+
24
+ ```bash
25
+ pip install billos
26
+ ```
27
+
28
+ ```python
29
+ from billos import BillOS, BillOSError
30
+
31
+ billos = BillOS(api_key=os.environ["BILLOS_API_KEY"])
32
+ bid = billos.businesses.list()["businesses"][0]["id"]
33
+
34
+ created = billos.documents.create(bid, {
35
+ "docType": 320,
36
+ "priceMode": "gross",
37
+ "party": {"name": "דנה לוי", "phone": "0521111111"},
38
+ "lines": [{"description": "איפור כלה", "quantity": 1, "unitPriceExVat": 120000}],
39
+ "payments": [{"method": "card", "amount": 120000}],
40
+ }, idempotency_key="order-8812")
41
+
42
+ issued = billos.documents.issue(bid, created["document"]["id"])
43
+ print(issued["document"]["docNumber"])
44
+
45
+ pdf = billos.documents.print(bid, created["document"]["id"]) # Binary: .bytes, .content_type, .variant
46
+ link = billos.documents.share(bid, created["document"]["id"]) # a public URL for the customer
47
+ ```
48
+
49
+ Money is **integer agorot** everywhere, exactly like the API (₪354.00 is `35400`); a foreign-currency document (`"currency": "USD"`) is in that currency's cents. Nothing in the client converts.
50
+
51
+ ## What you get
52
+
53
+ - Every operation in the spec as `namespace.method` (snake_case): `businesses`, `parties`, `documents`, `recurring`, `expenses`, `exports`, `files`, `webhooks`, `events`, `keys`, `usage`, `sandbox`. `OPERATIONS` (method and path per operation) is generated from the OpenAPI spec, and a test checks every operation has a method, so the client cannot lag behind the API.
54
+ - The conventions handled: `X-Api-Key`, the `{ok, ...}` envelope returned as a dict (plus `request_id`), `Idempotency-Key` from `idempotency_key=`, `Retry-After` honoured.
55
+ - Retries on 429 / 502 / 503 / 504 and network errors, up to `max_retries` (default 2), for GET/DELETE and for POSTs carrying an idempotency key; never for a POST without one.
56
+ - `BillOSError` with `status`, `reason`, `request_id`, `body`, `retryable`. Branch on `reason`.
57
+ - Binary answers (`documents.preview`, `documents.print`, `files.get`, a PDF report) come back as `Binary(bytes, content_type, variant, request_id)`.
58
+ - `verify_webhook_signature(secret, header, raw_body)` for the `X-BillOS-Signature` header.
59
+ - A `bk_test_` key answers from the sandbox with no change on your side (`billos.sandbox.seed()` fills it with demo data).
60
+
61
+ Documentation and the OpenAPI spec: https://docs.billos.co.il. MIT.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ billos/__init__.py
5
+ billos/_operations.py
6
+ billos/client.py
7
+ billos.egg-info/PKG-INFO
8
+ billos.egg-info/SOURCES.txt
9
+ billos.egg-info/dependency_links.txt
10
+ billos.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ billos
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "billos"
7
+ version = "0.1.0"
8
+ description = "BillOS for Python: Israeli tax documents, expenses, webhooks and the regulatory outputs, over one small client."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "BillOS" }]
13
+ keywords = ["billos", "invoice", "bookkeeping", "israel", "tax", "api"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Topic :: Office/Business :: Financial :: Accounting",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Documentation = "https://docs.billos.co.il"
24
+ Homepage = "https://billos.co.il"
25
+ Source = "https://github.com/baraviz/pest-os/tree/main/packages/billos-python"
26
+
27
+ [tool.setuptools.packages.find]
28
+ include = ["billos*"]
billos-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,133 @@
1
+ """The client against a fake transport: headers, path filling, query encoding, envelope,
2
+ errors with request ids, retries, binary answers, OCR raw upload, webhook signatures, and
3
+ that SURFACE names a live method for every operation in the generated table.
4
+ Run: python3 -m unittest discover -s packages/billos-python/tests"""
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import os
9
+ import sys
10
+ import time
11
+ import unittest
12
+
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
+ from billos import BillOS, BillOSError, OPERATIONS, SURFACE, verify_webhook_signature # noqa: E402
15
+
16
+
17
+ class Fake:
18
+ def __init__(self, handler):
19
+ self.calls = []
20
+ self.handler = handler
21
+
22
+ def __call__(self, method, url, headers, body, timeout):
23
+ self.calls.append((method, url, headers, body))
24
+ status, hdrs, data = self.handler(method, url, headers, body, len(self.calls))
25
+ h = {"content-type": "application/json", "x-request-id": "req-1"}
26
+ h.update(hdrs)
27
+ return status, h, data if isinstance(data, bytes) else json.dumps(data).encode()
28
+
29
+
30
+ class ClientTests(unittest.TestCase):
31
+ def test_call_shape(self):
32
+ fake = Fake(lambda *a: (200, {}, {"ok": True, "documents": []}))
33
+ c = BillOS("bk_test_abc", base_url="https://x.test/v1/", transport=fake)
34
+ out = c.documents.list("biz 1", {"status": "issued", "take": 5, "from": None})
35
+ method, url, headers, body = fake.calls[0]
36
+ self.assertEqual(url, "https://x.test/v1/businesses/biz%201/documents?status=issued&take=5")
37
+ self.assertEqual(method, "GET")
38
+ self.assertEqual(headers["X-Api-Key"], "bk_test_abc")
39
+ self.assertEqual(out["documents"], [])
40
+ self.assertEqual(out["request_id"], "req-1")
41
+
42
+ def test_create_sends_json_and_idempotency_key(self):
43
+ fake = Fake(lambda *a: (201, {}, {"ok": True, "document": {"id": "d1"}}))
44
+ c = BillOS("k", transport=fake)
45
+ c.documents.create("b1", {"docType": 400, "payments": [{"method": 1, "amount": 100}]}, idempotency_key="order-1")
46
+ _, _, headers, body = fake.calls[0]
47
+ self.assertEqual(headers["Idempotency-Key"], "order-1")
48
+ self.assertEqual(headers["Content-Type"], "application/json")
49
+ self.assertEqual(json.loads(body)["docType"], 400)
50
+ with self.assertRaisesRegex(TypeError, 'missing path parameter "bid"'):
51
+ c.documents.get("", "x")
52
+ self.assertEqual(len(fake.calls), 1)
53
+
54
+ def test_error_shape(self):
55
+ fake = Fake(lambda *a: (404, {"x-request-id": "req-404"}, {"error": "not found", "reason": "not_found"}))
56
+ c = BillOS("k", transport=fake)
57
+ with self.assertRaises(BillOSError) as cm:
58
+ c.businesses.get("nope")
59
+ e = cm.exception
60
+ self.assertEqual((e.status, e.reason, e.request_id, e.retryable), (404, "not_found", "req-404", False))
61
+ # a network failure is transient too
62
+ def boom(*a):
63
+ raise OSError("dns")
64
+ with self.assertRaises(BillOSError) as cm2:
65
+ BillOS("k", transport=boom, max_retries=0).documents.issue("b", "d")
66
+ self.assertTrue(cm2.exception.retryable and cm2.exception.status == 0)
67
+
68
+ def test_retries(self):
69
+ slept = []
70
+ state = {"n": 0}
71
+
72
+ def h(*a):
73
+ state["n"] += 1
74
+ return (429, {"retry-after": "0"}, {"error": "slow", "reason": "rate_limited"}) if state["n"] == 1 else (200, {}, {"ok": True, "businesses": []})
75
+ fake = Fake(h)
76
+ c = BillOS("k", transport=fake, sleep=slept.append)
77
+ self.assertTrue(c.businesses.list()["ok"])
78
+ self.assertEqual(len(fake.calls), 2)
79
+ # a POST without an idempotency key is never retried
80
+ fake2 = Fake(lambda *a: (502, {}, {"error": "x", "reason": "provider"}))
81
+ c2 = BillOS("k", transport=fake2, sleep=slept.append)
82
+ with self.assertRaises(BillOSError) as cm:
83
+ c2.documents.issue("b", "d")
84
+ self.assertTrue(cm.exception.retryable)
85
+ self.assertEqual(len(fake2.calls), 1)
86
+ # with one, it is
87
+ state["n"] = 0
88
+ fake3 = Fake(lambda m, u, hh, b, n: (502, {}, {"error": "x", "reason": "provider"}) if n == 1 else (201, {}, {"ok": True, "document": {}}))
89
+ c3 = BillOS("k", transport=fake3, sleep=slept.append)
90
+ c3.documents.create("b", {"docType": 400}, idempotency_key="k1")
91
+ self.assertEqual(len(fake3.calls), 2)
92
+
93
+ def test_binary_and_ocr(self):
94
+ pdf = b"%PDF-1.7"
95
+
96
+ def h(method, url, headers, body, n):
97
+ if url.endswith("/prints"):
98
+ return 200, {"content-type": "application/pdf", "x-print-variant": "origin"}, pdf
99
+ return 201, {}, {"ok": True, "expense": {"id": "e1"}}
100
+ fake = Fake(h)
101
+ c = BillOS("k", transport=fake)
102
+ out = c.documents.print("b", "d")
103
+ self.assertEqual((out.bytes, out.content_type, out.variant), (pdf, "application/pdf", "origin"))
104
+ self.assertEqual(json.loads(fake.calls[0][3])["kind"], "auto")
105
+ c.expenses.ocr("b", b"\x01\x02", mime="image/png")
106
+ _, url, headers, body = fake.calls[1]
107
+ self.assertEqual(headers["Content-Type"], "image/png")
108
+ self.assertEqual(body, b"\x01\x02")
109
+ self.assertTrue(url.endswith("/businesses/b/expenses/ocr"))
110
+
111
+ def test_surface_covers_every_generated_operation(self):
112
+ c = BillOS("k", transport=Fake(lambda *a: (200, {}, {"ok": True})))
113
+ for op, where in SURFACE.items():
114
+ ns, fn = where.split(".")
115
+ self.assertTrue(callable(getattr(getattr(c, ns), fn)), f"{op} -> {where}")
116
+ self.assertEqual(set(SURFACE), set(OPERATIONS))
117
+ self.assertEqual(len(set(SURFACE.values())), len(SURFACE))
118
+
119
+ def test_webhook_signature(self):
120
+ secret, body = "whsec_test", b'{"id":"del_1"}'
121
+ t = int(time.time())
122
+ v1 = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
123
+ self.assertTrue(verify_webhook_signature(secret, f"t={t},v1={v1}", body))
124
+ self.assertFalse(verify_webhook_signature(secret, f"t={t},v1={v1}", body + b" "))
125
+ self.assertFalse(verify_webhook_signature(secret, f"t={t - 1000},v1={v1}", body))
126
+ self.assertFalse(verify_webhook_signature(secret, "garbage", body))
127
+ self.assertFalse(verify_webhook_signature(secret, f"t={t},v1=abc\u05d0", body), "non-ASCII in the header is a False, not a crash")
128
+ with self.assertRaises(TypeError):
129
+ verify_webhook_signature(secret, f"t={t},v1={v1}", {"id": "del_1"})
130
+
131
+
132
+ if __name__ == "__main__":
133
+ unittest.main()