taxid-etims 0.2.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.
Files changed (35) hide show
  1. taxid_etims-0.2.0/PKG-INFO +538 -0
  2. taxid_etims-0.2.0/README.md +503 -0
  3. taxid_etims-0.2.0/pyproject.toml +59 -0
  4. taxid_etims-0.2.0/setup.cfg +4 -0
  5. taxid_etims-0.2.0/src/kra_etims/__init__.py +183 -0
  6. taxid_etims-0.2.0/src/kra_etims/_telemetry.py +62 -0
  7. taxid_etims-0.2.0/src/kra_etims/async_client.py +534 -0
  8. taxid_etims-0.2.0/src/kra_etims/client.py +536 -0
  9. taxid_etims-0.2.0/src/kra_etims/exceptions.py +230 -0
  10. taxid_etims-0.2.0/src/kra_etims/gateway.py +371 -0
  11. taxid_etims-0.2.0/src/kra_etims/middleware.py +42 -0
  12. taxid_etims-0.2.0/src/kra_etims/models.py +238 -0
  13. taxid_etims-0.2.0/src/kra_etims/py.typed +0 -0
  14. taxid_etims-0.2.0/src/kra_etims/qr.py +182 -0
  15. taxid_etims-0.2.0/src/kra_etims/reports.py +267 -0
  16. taxid_etims-0.2.0/src/kra_etims/tax.py +193 -0
  17. taxid_etims-0.2.0/src/taxid_etims.egg-info/PKG-INFO +538 -0
  18. taxid_etims-0.2.0/src/taxid_etims.egg-info/SOURCES.txt +33 -0
  19. taxid_etims-0.2.0/src/taxid_etims.egg-info/dependency_links.txt +1 -0
  20. taxid_etims-0.2.0/src/taxid_etims.egg-info/requires.txt +15 -0
  21. taxid_etims-0.2.0/src/taxid_etims.egg-info/top_level.txt +1 -0
  22. taxid_etims-0.2.0/tests/test_api_key_auth.py +183 -0
  23. taxid_etims-0.2.0/tests/test_async.py +82 -0
  24. taxid_etims-0.2.0/tests/test_concurrency.py +49 -0
  25. taxid_etims-0.2.0/tests/test_decorators.py +33 -0
  26. taxid_etims-0.2.0/tests/test_e2e.py +54 -0
  27. taxid_etims-0.2.0/tests/test_environment.py +36 -0
  28. taxid_etims-0.2.0/tests/test_idempotency.py +86 -0
  29. taxid_etims-0.2.0/tests/test_math_precision.py +42 -0
  30. taxid_etims-0.2.0/tests/test_payload_integrity.py +35 -0
  31. taxid_etims-0.2.0/tests/test_performance.py +7 -0
  32. taxid_etims-0.2.0/tests/test_phase2.py +430 -0
  33. taxid_etims-0.2.0/tests/test_resilience.py +58 -0
  34. taxid_etims-0.2.0/tests/test_schema.py +44 -0
  35. taxid_etims-0.2.0/tests/test_vscu_resilience.py +202 -0
@@ -0,0 +1,538 @@
1
+ Metadata-Version: 2.4
2
+ Name: taxid-etims
3
+ Version: 0.2.0
4
+ Summary: Enterprise Python SDK for KRA eTIMS via TIaaS Middleware — idempotency engine with offline resilience
5
+ Author-email: TaxID Engineering <support@taxid.co.ke>
6
+ License: Proprietary
7
+ Keywords: kra,etims,kenya,tax,invoicing,vscu,tiims
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Financial and Insurance Industry
11
+ Classifier: License :: Other/Proprietary License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Office/Business :: Financial
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: pydantic>=2.0.0
24
+ Requires-Dist: requests>=2.32.4
25
+ Requires-Dist: httpx>=0.25.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
29
+ Requires-Dist: pytest-httpx>=0.30.0; extra == "dev"
30
+ Requires-Dist: responses>=0.23.0; extra == "dev"
31
+ Provides-Extra: qr
32
+ Requires-Dist: qrcode[pil]>=7.4.2; extra == "qr"
33
+ Provides-Extra: otel
34
+ Requires-Dist: opentelemetry-api<2,>=1.20.0; extra == "otel"
35
+
36
+ # KRA eTIMS SDK (Python) `v0.2.0`
37
+
38
+ ```bash
39
+ pip install taxid-etims # core
40
+ pip install "taxid-etims[qr]" # + offline QR code image generation
41
+ pip install "taxid-etims[otel]" # + OpenTelemetry spans
42
+ pip install "taxid-etims[dev]" # + pytest, pytest-asyncio, pytest-httpx
43
+ ```
44
+
45
+ Requires **Python 3.10+**.
46
+
47
+ ---
48
+
49
+ ## Error Code Reference
50
+
51
+ Hitting a `resultCd` you don't recognize?
52
+
53
+ → **[Complete KRA eTIMS Error Code Reference](https://linkd-taxid.github.io/taxid-etims/)**
54
+
55
+ Covers all official OSCU/VSCU spec codes plus production-observed codes absent
56
+ from the official KRA documentation — including the critical success code
57
+ normalization issue (`"00"` vs `"000"` vs `"0000"`).
58
+
59
+ ---
60
+
61
+ ## Two ways to use this SDK
62
+
63
+ ### Track 1 — Tax Calculator (no account required)
64
+
65
+ `calculate_item` and `build_invoice_totals` are pure math functions. They work offline with no credentials, no network, and no TaxID account. Any Python developer in Kenya who needs KRA-compliant VAT arithmetic can use them independently.
66
+
67
+ ```python
68
+ from kra_etims import calculate_item, build_invoice_totals
69
+
70
+ items = [
71
+ calculate_item("MacBook Pro M3", "HS847130", 5800, "B"), # 16% Standard VAT
72
+ calculate_item("Maize Flour 2kg", "HS110100", 200, "A"), # 0% Exempt
73
+ calculate_item("Diesel 1L", "HS270900", 216, "E"), # 8% Special Rate (petroleum)
74
+ ]
75
+ totals = build_invoice_totals(items)
76
+
77
+ print(items[0].taxblAmt) # Decimal("5000.00")
78
+ print(items[0].taxAmt) # Decimal("800.00")
79
+ print(totals["totAmt"]) # Decimal("6216.00")
80
+ ```
81
+
82
+ No configuration needed. The calculator handles all five KRA tax bands, inclusive and exclusive pricing, 4dp quantity precision for fuel/pharmaceuticals, and invoice-level residual absorption so KRA never rejects with result code 20.
83
+
84
+ ### Track 2 — Full KRA Submission via TaxID (requires account)
85
+
86
+ The full platform adds KRA invoice submission, digital signing via the VSCU JAR, durable offline queuing, idempotency, and the supplier onboarding gateway — none of which exist client-side.
87
+
88
+ ```python
89
+ from kra_etims import KRAeTIMSClient, SaleInvoice, calculate_item, build_invoice_totals
90
+
91
+ client = KRAeTIMSClient(client_id="TIaaS_ID", client_secret="TIaaS_SEC")
92
+
93
+ items = [calculate_item("MacBook Pro M3", "HS847130", 5800, "A")]
94
+ invoice = SaleInvoice(
95
+ tin="P051234567X", bhfId="00", invcNo="INV-2026-001",
96
+ custNm="Acacia Enterprises Ltd", confirmDt="20260311120000",
97
+ itemList=items, **build_invoice_totals(items),
98
+ )
99
+ response = client.submit_sale(invoice, idempotency_key="INV-2026-001")
100
+ print(response["invoiceSignature"])
101
+ ```
102
+
103
+ `confirmDt` format: `yyyyMMddHHmmss` — e.g. `"20260311120000"` = 2026-03-11 12:00:00.
104
+
105
+ ---
106
+
107
+ ## Legal Foundation
108
+
109
+ > **Statutory Notice:** Section 16(1)(c) of the Income Tax Act (Cap 470), as amended by the Finance Act (2023/2025), disallows business expense deductions not supported by a valid eTIMS invoice transmitted via a compliant VSCU/OSCU architecture.
110
+
111
+ ---
112
+
113
+ ## Architecture: The Middleware Moat
114
+
115
+ | Layer | Responsibility |
116
+ |---|---|
117
+ | **This SDK** | Auth, payload validation, tax math, QR rendering, idempotency headers |
118
+ | **TIaaS Middleware** | VSCU JAR orchestration, AES-256 `cmcKey` encryption, KRA GavaConnect communication, 24-hour offline signing window |
119
+
120
+ The VSCU JAR is KRA's proprietary device credential program — it cannot be called directly without device initialization and cryptographic key management. TIaaS handles all of that. The SDK is the remote control; TIaaS is the engine.
121
+
122
+ ---
123
+
124
+ ## Authentication
125
+
126
+ Two modes, in priority order:
127
+
128
+ ```python
129
+ # Mode 1: API Key (preferred for production B2B — skips OAuth round-trip)
130
+ client = KRAeTIMSClient(client_id="ID", client_secret="SEC", api_key="your_key")
131
+ # Or via environment variable (takes priority over constructor arg):
132
+ # export TAXID_API_KEY=your_key
133
+
134
+ # Mode 2: OAuth 2.0 Client Credentials (auto-refresh with 60s expiry buffer)
135
+ client = KRAeTIMSClient(client_id="ID", client_secret="SEC")
136
+
137
+ # Custom middleware URL (defaults to https://taxid-production.up.railway.app)
138
+ client = KRAeTIMSClient("ID", "SEC", base_url="https://your-instance.railway.app")
139
+ # Or via environment variable (takes priority over constructor base_url):
140
+ # export TAXID_API_URL=https://your-instance.railway.app
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Tax Bands (KRA eTIMS v2.0)
146
+
147
+ | Band | Rate | Description |
148
+ |---|---|---|
149
+ | `A` | 0% | Exempt (basic foodstuffs, medicine — no input VAT credit) |
150
+ | `B` | 16% | Standard VAT (most goods & services) |
151
+ | `C` | 0% | Zero-Rated (exports, certain food — input credit allowed) |
152
+ | `D` | 0% | Non-VAT (outside VAT Act entirely) |
153
+ | `E` | 8% | Special Rate (petroleum products, LPG per Kenya VAT Act) |
154
+
155
+ > **Warning:** A≠16% and B≠0%. This ordering is counterintuitive but is explicit in KRA VSCU/OSCU Specification v2.0 §4.1. Swapping A and B is the single most common integration error and results in incorrect Z-Report aggregation.
156
+
157
+ ```python
158
+ from kra_etims import calculate_item
159
+
160
+ # Inclusive pricing (default) — SDK back-calculates net from retail
161
+ laptop = calculate_item("MacBook Pro M3", "HS847130", 5800, "B")
162
+ # B=16% Standard VAT: taxblAmt=5000.00, taxAmt=800.00, totAmt=5800.00
163
+
164
+ diesel = calculate_item("Diesel 1L", "HS270900", 216, "E")
165
+ # E=8% Special Rate: taxblAmt=200.00, taxAmt=16.00, totAmt=216.00
166
+
167
+ maize = calculate_item("Maize Flour 2kg", "HS110100", 200, "A")
168
+ # A=0% Exempt: taxblAmt=200.00, taxAmt=0.00, totAmt=200.00
169
+
170
+ # Exclusive pricing — net price supplied, SDK adds VAT on top
171
+ fee = calculate_item("Consulting Fee", "SRV001", 1000, "B", price_is_inclusive=False)
172
+ # B=16% exclusive: taxblAmt=1000.00, taxAmt=160.00, totAmt=1160.00
173
+ ```
174
+
175
+ ### Quantity Precision — Fuel, Weight, Pharmaceuticals
176
+
177
+ ```python
178
+ # Fuel: 15.456L — truncating to 2dp would understate the taxable amount
179
+ diesel = calculate_item("Diesel", "HS270900", 3236.57, "E", qty=15.456)
180
+ # Band E (8% Special Rate — petroleum products)
181
+ # qty stored as Decimal("15.4560") — transmitted to KRA exactly
182
+ ```
183
+
184
+ ### Residual Drift — Invoice Integrity
185
+
186
+ `ROUND_HALF_UP` applied independently to each line can leave a 1-cent gap at invoice level. The SDK absorbs this residual into `totTaxAmt`, preventing KRA result code 20 rejections.
187
+
188
+ ```python
189
+ items = [calculate_item("Item A", "SKU001", 999.99, "A"),
190
+ calculate_item("Item B", "SKU002", 1999.99, "A")]
191
+ totals = build_invoice_totals(items)
192
+ # totals["totTaxblAmt"] + totals["totTaxAmt"] == totals["totAmt"] ← always true
193
+ ```
194
+
195
+ > All inputs are coerced through `Decimal(str(value))` before any arithmetic. Floating-point intermediates are never used.
196
+
197
+ ---
198
+
199
+ ## Idempotency & Resilience
200
+
201
+ ### Preventing Double Taxation — Schrödinger's Invoice
202
+
203
+ When a network timeout interrupts a POST in-flight, the invoice state is unknown. `TIaaSAmbiguousStateError` carries the `idempotency_key` that was in-flight:
204
+
205
+ ```python
206
+ from kra_etims import TIaaSAmbiguousStateError, KRADuplicateInvoiceError
207
+ import time
208
+
209
+ IDEMPOTENCY_KEY = "INV-2026-001"
210
+
211
+ try:
212
+ result = client.submit_sale(invoice, idempotency_key=IDEMPOTENCY_KEY)
213
+
214
+ except TIaaSAmbiguousStateError as exc:
215
+ # Request sent; connection dropped before response arrived.
216
+ time.sleep(2)
217
+ try:
218
+ result = client.submit_sale(invoice, idempotency_key=exc.idempotency_key)
219
+ except KRADuplicateInvoiceError:
220
+ # First attempt succeeded — middleware deduplicated it.
221
+ print(f"Invoice {exc.idempotency_key} already processed.")
222
+
223
+ except KRADuplicateInvoiceError:
224
+ print("Already processed — retrieve original receipt.")
225
+ ```
226
+
227
+ ### Exception Taxonomy
228
+
229
+ | Exception | Trigger |
230
+ |---|---|
231
+ | `KRAeTIMSAuthError` | Bad credentials or token refresh failure |
232
+ | `KRAConnectivityTimeoutError` | 24-hour VSCU offline ceiling breached (HTTP 503) |
233
+ | `TIaaSUnavailableError` | Middleware instance unreachable (TCP failure) |
234
+ | `TIaaSAmbiguousStateError` | Network dropped mid-POST; state unknown — carries `idempotency_key` |
235
+ | `KRAInvalidPINError` | Invalid TIN format (code 10) |
236
+ | `KRAVSCUMemoryFullError` | VSCU storage at capacity — sync before invoicing (code 11) |
237
+ | `KRADuplicateInvoiceError` | Already processed; retrieve original receipt (code 12) |
238
+ | `KRAInvalidItemCodeError` | Item not registered on eTIMS (code 13) |
239
+ | `KRAInvalidBranchError` | Branch not registered for this TIN (code 14) |
240
+ | `KRAServerError` | Transient KRA server error (codes 20/96/99) |
241
+ | `CreditNoteConflictError` | Credit note already issued for this sale (HTTP 409); carries `original_purchase_id` |
242
+ | `KRAeTIMSError` | Base class for all SDK exceptions; also raised directly for unexpected HTTP 4xx/5xx responses from the middleware (message contains only the status code — no request URLs or PII) |
243
+
244
+ ---
245
+
246
+ ## Thread Safety & Concurrency
247
+
248
+ The sync client is safe to share across Celery workers and FastAPI request handlers. The async client is safe for concurrent `asyncio` tasks.
249
+
250
+ | Concern | Mechanism |
251
+ |---|---|
252
+ | OAuth token refresh | `threading.Lock` (sync) / `asyncio.Lock` (async) with double-checked locking |
253
+ | Sub-interface init (`client.reports`, `client.gateway`) | Double-checked locking prevents duplicate initialisation under concurrent first-access |
254
+ | `requests.Session` connection pool | One session per thread via `threading.local()` — each Celery worker gets its own pool, preventing urllib3 connection corruption under concurrent access |
255
+
256
+ ### Celery worker pattern
257
+
258
+ ```python
259
+ # One client instance per worker process — initialise at module level.
260
+ from kra_etims import KRAeTIMSClient
261
+
262
+ etims_client = KRAeTIMSClient(
263
+ client_id=os.environ["TIIMS_CLIENT_ID"],
264
+ client_secret=os.environ["TIIMS_CLIENT_SECRET"],
265
+ )
266
+
267
+ @celery_app.task
268
+ def submit_invoice_task(invoice_data: dict):
269
+ invoice = SaleInvoice(**invoice_data)
270
+ return etims_client.submit_sale(invoice, idempotency_key=invoice.invcNo)
271
+ ```
272
+
273
+ ### Credential sanitization
274
+
275
+ `client_secret` and `api_key` are never emitted by `__repr__`, `__str__`, or exception messages:
276
+
277
+ ```python
278
+ print(client)
279
+ # KRAeTIMSClient(client_id='TIaaS_ID', base_url='https://...', auth_mode='api_key')
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Observability
285
+
286
+ ```bash
287
+ pip install "taxid-etims[otel]"
288
+ ```
289
+
290
+ The SDK emits [OpenTelemetry](https://opentelemetry.io/) spans when `opentelemetry-api` is installed. Without it, every span call is a no-op — existing integrations are unaffected.
291
+
292
+ ### Spans emitted
293
+
294
+ | Span name | Emitted by | Key attributes |
295
+ |---|---|---|
296
+ | `kra_etims.submit_sale` | `submit_sale()` | `invoice.no`, `invoice.tin` |
297
+ | `kra_etims.issue_credit_note` | `issue_credit_note()` | `sale.id` |
298
+ | `kra_etims.flush_offline_queue` | `flush_offline_queue()` | `queue.size` |
299
+ | `kra_etims.request` | `_request()` (internal) | `http.method`, `http.path`, `idempotency_key` |
300
+
301
+ On exception, the span is marked `ERROR` and the exception recorded before re-raising — failures always appear in traces regardless of how the caller handles them.
302
+
303
+ ### Wiring spans to an exporter
304
+
305
+ The SDK only depends on `opentelemetry-api`. To see spans in a backend (Jaeger, Grafana Tempo, Honeycomb, etc.) your application configures the SDK and exporter — the SDK never touches that layer.
306
+
307
+ ```python
308
+ # Minimal setup — add this once at application startup, before any SDK calls.
309
+ # Example uses the OTLP exporter; swap for your backend's exporter package.
310
+ #
311
+ # pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-grpc
312
+
313
+ from opentelemetry import trace
314
+ from opentelemetry.sdk.trace import TracerProvider
315
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
316
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
317
+
318
+ provider = TracerProvider()
319
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
320
+ trace.set_tracer_provider(provider)
321
+
322
+ # From here, all kra_etims.* spans flow to your backend automatically.
323
+ from kra_etims import KRAeTIMSClient
324
+ client = KRAeTIMSClient(client_id="...", client_secret="...")
325
+ ```
326
+
327
+ The same setup works for the async client — `opentelemetry-api` is context-propagation-aware and works across `asyncio` task boundaries without any changes.
328
+
329
+ ---
330
+
331
+ ## Async Client (FastAPI / Starlette)
332
+
333
+ Full API parity with the sync client.
334
+
335
+ ```python
336
+ from kra_etims import AsyncKRAeTIMSClient
337
+
338
+ async def process_checkout(invoice):
339
+ async with AsyncKRAeTIMSClient(client_id="ID", client_secret="SEC") as client:
340
+ return await client.submit_sale(invoice, idempotency_key="INV-001")
341
+ ```
342
+
343
+ ### Concurrent Offline Queue Flush
344
+
345
+ When your application loses connectivity and queues invoices locally, flush them once the middleware is reachable again. Uses `asyncio.gather` with `asyncio.Semaphore(50)` — a single failed invoice never aborts the batch.
346
+
347
+ ```python
348
+ async with AsyncKRAeTIMSClient("ID", "SEC") as client:
349
+ results = await client.flush_offline_queue(locally_queued_invoices)
350
+ failed = [r for r in results if r["status"] == "error"]
351
+ ```
352
+
353
+ > Note: This flushes invoices your application queued locally when the middleware was unreachable. The middleware also maintains its own durable server-side queue for VSCU outages — that queue drains automatically without SDK involvement.
354
+
355
+ ---
356
+
357
+ ## Offline QR Code Generator
358
+
359
+ ```python
360
+ from kra_etims import render_kra_qr_string, generate_qr_bytes
361
+
362
+ response = client.submit_sale(invoice)
363
+ qr_string = render_kra_qr_string(response)
364
+ png_bytes = generate_qr_bytes(qr_string)
365
+ thermal_printer.write(png_bytes)
366
+ ```
367
+
368
+ > Requires `pip install "taxid-etims[qr]"`
369
+
370
+ ---
371
+
372
+ ## Gateway: Supplier Onboarding (TaxID Links)
373
+
374
+ Enables buyers to obtain KRA Category 5 (Reverse Invoice) receipts for purchases from **informal suppliers** (kiosks, jua kali, market vendors) who have no eTIMS software.
375
+
376
+ **Why this exists:** Finance Act 2023 §16(1)(c) disallows expense deductions for purchases not backed by a valid eTIMS invoice. KRA's Category 5 spec allows the buyer to issue the invoice — but only with the supplier's explicit consent, obtained via SMS or WhatsApp.
377
+
378
+ **Flow:**
379
+ 1. Buyer calls `onboard_supplier()` with the supplier's phone and transaction amount
380
+ 2. TIaaS sends the supplier an SMS/WhatsApp message with the amount and a confirmation token
381
+ 3. Supplier replies `YES {token}` (or `YES {KRA-PIN} {token}` if registered)
382
+ 4. TIaaS raises a KRA Category 5 Reverse Invoice and signs it via the VSCU JAR
383
+ 5. Buyer polls `get_status()` until `status == "SIGNED"`
384
+
385
+ ```python
386
+ # Single supplier
387
+ result = client.gateway.onboard_supplier(
388
+ phone="+254712345678",
389
+ amount=5000,
390
+ buyer_pin="A000123456B",
391
+ buyer_name="Acme Superstore",
392
+ item_description="Maize supply — March 2026",
393
+ )
394
+ print(result.request_id) # 42 — use to poll status
395
+ print(result.token) # "XK9T" — embedded in the outbound SMS
396
+ print(result.channel) # "whatsapp" | "sms"
397
+
398
+ # Poll until signed
399
+ status = client.gateway.get_status(result.request_id)
400
+ print(status.status) # PENDING → CONFIRMED → SIGNED
401
+ print(status.purchase_id) # set once VSCU signing completes
402
+ ```
403
+
404
+ ```python
405
+ # Bulk — multiple suppliers in one call
406
+ from kra_etims import SupplierEntry
407
+
408
+ result = client.gateway.onboard_suppliers(
409
+ suppliers=[
410
+ SupplierEntry(phone="+254712345678", amount=5000, item_description="Produce"),
411
+ SupplierEntry(phone="+254798765432", amount=12000, item_description="Hardware"),
412
+ ],
413
+ buyer_pin="A000123456B",
414
+ buyer_name="Acme Superstore",
415
+ )
416
+ print(result.initiated, result.failed) # 2, 0
417
+ ```
418
+
419
+ ```python
420
+ # Async
421
+ result = await client.gateway.onboard_supplier(
422
+ phone="+254712345678", amount=5000,
423
+ buyer_pin="A000123456B", buyer_name="Acme Superstore",
424
+ )
425
+ ```
426
+
427
+ **Status lifecycle:** `PENDING` → `CONFIRMED` → `SIGNED` (success), or `EXPIRED` (no reply within window) / `FAILED` (VSCU error).
428
+
429
+ ---
430
+
431
+ ## Reports (X/Z)
432
+
433
+ ```python
434
+ # X Report — interim read-only snapshot (safe at any time, no VSCU state change)
435
+ x = client.reports.get_x_report("2026-03-11")
436
+ print(x.band_a.taxable_amount) # Decimal("43103.45")
437
+ print(x.band_a.tax_amount) # Decimal("6896.55")
438
+ print(x.total_amount) # Decimal("52340.00")
439
+
440
+ # Z Report — closes the VSCU fiscal period (POST internally — call once per day)
441
+ z = client.reports.get_daily_z("2026-03-11")
442
+ print(z.vscu_acknowledged) # True when VSCU day-reset completed
443
+ print(z.invoice_count)
444
+ print(z.total_vat)
445
+
446
+ # A second call for the same date raises KRAeTIMSError (middleware returns 409 Conflict).
447
+
448
+ # Async
449
+ x = await client.reports.get_x_report("2026-03-11")
450
+ z = await client.reports.get_daily_z("2026-03-11")
451
+ ```
452
+
453
+ ---
454
+
455
+ ## Credit Notes (Category 7)
456
+
457
+ Issue a credit note against a previously signed sale. The middleware sources the original amount from the signed receipt — callers cannot supply amounts, preventing manipulation.
458
+
459
+ ```python
460
+ from kra_etims import CreditNoteConflictError
461
+
462
+ # Full reversal
463
+ result = client.issue_credit_note(original_purchase_id=42, reason="Customer return")
464
+ print(result["cuInvoiceNumber"]) # Signed credit note CU number
465
+
466
+ # Partial reversal — supply specific line items to reverse
467
+ result = client.issue_credit_note(
468
+ original_purchase_id=42,
469
+ reason="Partial return",
470
+ items=[{"itemCd": "SKU-001", "qty": 1}],
471
+ )
472
+
473
+ # Async
474
+ result = await client.issue_credit_note(original_purchase_id=42, reason="Return")
475
+ ```
476
+
477
+ ```python
478
+ try:
479
+ client.issue_credit_note(original_purchase_id=42)
480
+ except CreditNoteConflictError as exc:
481
+ # HTTP 409 — a credit note already exists for this sale.
482
+ print(f"Already reversed: purchase {exc.original_purchase_id}")
483
+ ```
484
+
485
+ > `submit_reverse_invoice()` is deprecated and targets a removed endpoint. Use `issue_credit_note()` instead.
486
+
487
+ ---
488
+
489
+ ## Stock Adjustments (Category 8)
490
+
491
+ Submit stock movements (imports, write-offs, transfers) to `POST /v2/etims/stock/adjustment`. Financial totals are computed server-side from `qty` and `prc` — do not supply them.
492
+
493
+ ```python
494
+ from kra_etims import StockAdjustmentLine
495
+
496
+ lines = [
497
+ StockAdjustmentLine(
498
+ itemCd="HS847130",
499
+ itemNm="MacBook Pro M3",
500
+ ioType="M", # M=Import/IN, A=Adjustment/OUT, I=Issue/OUT
501
+ qty=10,
502
+ prc=5000, # unit price excl. VAT
503
+ totDcAmt=0,
504
+ taxTyCd="B", # 16% Standard VAT
505
+ ),
506
+ ]
507
+
508
+ # 201 = VSCU signed synchronously; 202 = queued for retry
509
+ result = client.submit_stock_adjustment(lines, remark="March stock receive")
510
+ print(result["sarNo"]) # KRA Stock Adjustment Receipt number
511
+
512
+ # B2B movement — include counterparty TIN
513
+ result = client.submit_stock_adjustment(
514
+ lines,
515
+ cust_tin="A000123456B",
516
+ cust_nm="Supplier Ltd",
517
+ )
518
+
519
+ # Async
520
+ result = await client.submit_stock_adjustment(lines)
521
+ ```
522
+
523
+ ---
524
+
525
+ ## Sovereignty & Data Protection
526
+
527
+ This SDK and the TIaaS Middleware comply with the **Kenya Data Protection Act (2019)**.
528
+
529
+ ---
530
+
531
+ > [!CAUTION]
532
+ > This SDK is a technical implementation tool, not tax advice. The authors are not responsible for KRA penalties, non-deductible expenses, or financial losses resulting from user error, misconfigured payloads, or middleware misapplication.
533
+
534
+ ---
535
+
536
+ ## Support
537
+
538
+ For architectural escalations or middleware orchestration support: `support@taxid.co.ke`