Metadata-Version: 2.5
Name: ethiopia-eims
Version: 0.1.0
Summary: Framework-agnostic Python client for the Ethiopian Ministry of Revenue Electronic Invoice Management System (EIMS).
Project-URL: Homepage, https://github.com/Kaleb-Abiy/ethiopia-eims-py
Project-URL: Issues, https://github.com/Kaleb-Abiy/ethiopia-eims-py/issues
Author: Kaleb Abiy
License: MIT
License-File: LICENSE
Keywords: e-invoice,eims,ethiopia,invoice,mor,tax
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.10
Requires-Dist: httpx>=0.24
Provides-Extra: dev
Requires-Dist: mypy>=1.5; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Provides-Extra: signing
Requires-Dist: cryptography>=41.0; extra == 'signing'
Description-Content-Type: text/markdown

# Ethiopia EIMS Python Client

A framework-agnostic Python client for the **Ethiopian Ministry of Revenue (MoR)
Electronic Invoice Management System (EIMS)**.

It lets any ERP, POS, or backend register invoices, receipts, and
query/verify/cancel documents against the EIMS API. The package speaks
**plain dataclasses and returns result objects** — it does not touch your
database or ORM. Mapping your domain (sales, customers, products) to an
`InvoiceRequest` and persisting results is your application's job.

**Credit:** this is a Python port of, and was entirely inspired by,
[`mattdi/ethiopia-eims`](https://github.com/Matt-di/ethiopia-eims) — the
original PHP client. All credit for the API design, the EIMS request/response
shapes, and the groundwork of figuring out how to talk to EIMS at all belongs
to that project; this port exists to bring the same client to Python
services, with the wire format (JSON shapes, field names, enum values) kept
identical between the two so the two clients can be mixed across a system
without surprises.

> **Status:** `0.x`, API may change. Ethiopian e-invoicing is pre-mandate;
> the request/response shapes reflect the current EIMS API as reverse
> engineered from the PHP package and may evolve. **There is no official
> EIMS API specification backing this client** — treat the shapes here as a
> best-effort draft, and validate against a real sandbox before relying on
> this in production.

## Requirements

- Python 3.10+
- `httpx` (installed automatically)

## Installation

```bash
pip install ethiopia-eims
```

For RSA signing / AES encryption support (experimental, see below):

```bash
pip install "ethiopia-eims[signing]"
```

## Usage

```python
import os
from eims import (
    EimsClient,
    EimsCredentials,
    SellerConfig,
    BuyerDetails,
    InvoiceItem,
    InvoiceRequest,
    ValueDetails,
    DocumentDetails,
    SourceSystem,
)
from eims.enums import TransactionType

client = EimsClient(
    credentials=EimsCredentials(
        client_id=os.environ["EIMS_CLIENT_ID"],
        client_secret=os.environ["EIMS_CLIENT_SECRET"],
        api_key=os.environ["EIMS_API_KEY"],
        base_url="http://core.mor.gov.et",
    ),
    seller=SellerConfig(
        tin="0001234567",
        legal_name="Acme Pharmacy PLC",
        region_code="14",
        city="Addis Ababa",
        vat_number="VAT-123",
    ),
    # cache=my_cache,              # optional, see "Token caching" below
    # cache_namespace="store_42",  # optional; defaults to the TIN
)

result = client.register(
    InvoiceRequest(
        buyer=BuyerDetails.walk_in(),
        items=[
            InvoiceItem(
                product_description="Paracetamol 500mg",
                quantity=2,
                unit_price=25.00,
                pre_tax_value=50.00,
                tax_amount=0.00,
                total_line_amount=50.00,
            ),
        ],
        values=ValueDetails(total_value=50.00, tax_value=0.00),
        document=DocumentDetails(document_number=1001, date="02-10-2026T10:00:00"),
        source_system=SourceSystem(invoice_counter=1001, system_number="SYS-1"),
        transaction_type=TransactionType.B2C,
    )
)

print(result.irn, result.qr_code, result.signed_invoice)
```

### Building invoices the easy way

`Invoice` + `InvoiceItem.make()` compute line totals and the `ValueDetails`
for you:

```python
from eims.data import Invoice, InvoiceItem, BuyerDetails
from eims.enums import TransactionType, PaymentMode, DocumentType

invoice = (
    Invoice.for_buyer(BuyerDetails.walk_in())
    .document(1001, "02-10-2026T10:00:00", DocumentType.INVOICE)
    .source(1001, system_number="SYS-1")
    .transaction_type(TransactionType.B2C)
    .payment(PaymentMode.CASH)
    .add_item(InvoiceItem.make("Paracetamol 500mg", quantity=2, unit_price=25.00, tax_rate=0.15))
    .add_item(InvoiceItem.make("Vitamin C", quantity=1, unit_price=20.00, tax_rate=0.15, discount=5.00))
    .build()  # raises EimsConfigurationException if missing items / document / source
)

result = client.register(invoice)
```

`InvoiceItem.make()` returns an immutable `InvoiceItem` where `pre_tax_value`,
`tax_amount`, and `total_line_amount` are derived from the inputs.

## Token caching

Pass anything implementing `get(key, default=None)` / `set(key, value, ttl=None)`
as `cache` to persist OAuth tokens across processes (the default is an
in-memory, per-process cache). This matches the shape of Django's cache
framework and most third-party caches (`diskcache`, a thin `redis` wrapper,
etc.) — adapt yours with a few lines if it doesn't already match.

## Resilience: logging & retries

Pass a standard library `logging.Logger` and `EimsCredentials(max_retries=...,
retry_delay_ms=...)` to see every request/response and retry transient
connection failures / 5xx responses with exponential backoff:

```python
import logging
from eims import EimsCredentials
from eims.support.http_client_factory import make as make_http_client

credentials = EimsCredentials(
    client_id="...", client_secret="...", api_key="...",
    base_url="http://core.mor.gov.et",
    max_retries=2, retry_delay_ms=500,
)

http = make_http_client(credentials, logging.getLogger("eims"))
client = EimsClient(credentials=credentials, seller=seller, http=http)
```

`EimsCredentials.validate()` fails fast if any required value is missing.

## Testing your integration

`eims.testing.FakeEimsClient` records calls and lets you assert on them
(assertions use plain `assert`, so they work with pytest or any other
runner):

```python
from eims.testing import FakeEimsClient
from eims.data.responses import RegisterResult

fake = FakeEimsClient()
fake.push_register(RegisterResult("FAKE-IRN", None, None, None))

result = fake.register(invoice)

fake.assert_registered()
fake.assert_registered_count(1)
```

## Operations

| Method | Endpoint | Returns |
|---|---|---|
| `register(InvoiceRequest)` | `POST /v1/register` | `RegisterResult` |
| `bulk_register(list[InvoiceRequest])` | `POST /v1/bulkRegister` | `BulkRegisterResult` |
| `cancel(irn, reason_code, remark)` | `POST /v1/cancel` | `CancelResult` |
| `bulk_cancel(list[dict])` | `POST /v1/bulkCancel` | `BulkCancelResult` |
| `verify(irn)` | `POST /v1/verify` | `VerifyResult` |
| `bulk_verify(irns)` | loops `verify` | `dict[irn, VerifyResult \| EimsException]` |
| `register_receipt(ReceiptRequest)` | `POST /v1/receipt/sales` | `ReceiptResult` |
| `register_withholding_receipt(WithholdingReceiptRequest)` | `POST /v1/receipt/withholding` | `ReceiptResult` |
| `status(irn)` | `GET /v1/invoices/status/{irn}` | `StatusResult` |

### Enums

- `CancellationReason` (`DUPLICATE`, `DATA_ENTRY_ERROR`, `ORDER_CANCELLED`,
  `CALCULATION_ERROR`) can be passed to `cancel()` instead of a raw string.
- `InvoiceStatus` (`PENDING`, `REGISTERED`, `FAILED`, `CANCELLED`, `UNKNOWN`)
  with `InvoiceStatus.from_string()` and predicates `is_registered()` /
  `is_cancelled()`. Read it from `StatusResult.status_enum()`.

## Error handling

```python
from eims.exceptions import EimsApiException, EimsAuthenticationException, EimsConnectionException

try:
    result = client.register(invoice)
except EimsAuthenticationException:
    ...  # bad/expired credentials
except EimsApiException as e:
    ...  # EIMS rejected the request: e.args[0], e.body, e.status_code
except EimsConnectionException:
    ...  # network/transport failure or non-JSON response
```

## Multi-tenant note

The client is stateless per instance and represents a single taxpayer. To
serve many taxpayers (e.g. a chain of stores), create one client per taxpayer
and pass a unique `cache_namespace` so their auth tokens never collide. The
package never needs to know about your "store"/"branch"/tenant concepts —
`eims.contracts.SellerConfigResolver` is a `Protocol` you can implement in a
multi-tenant service to resolve a `SellerConfig` from a request-scoped key.

## Bring your own persistence

The client returns result objects only. Store the IRN, QR code, signed
invoice, and raw responses in whatever table/model your app uses.

## Signing / encryption (experimental)

The current EIMS register flow accepts plain JSON. `eims.support.Canonicalizer`
and `eims.support.Signer` are provided for integrations issued signing keys,
but are not wired into the request flow. `Signer` requires the optional
`cryptography` dependency (`pip install "ethiopia-eims[signing]"`).

## Differences from the PHP package

- Identifiers are `snake_case` (idiomatic Python) instead of `camelCase`; the
  JSON wire format sent to EIMS is unchanged.
- `Invoice.for(...)` is `Invoice.for_buyer(...)` — `for` is a reserved word
  in Python.
- No Laravel bridge. If you need a multi-tenant HTTP service wrapping this
  client (e.g. a FastAPI gateway), build it on top of `EimsClient` +
  `SellerConfigResolver` the same way the PHP package's Laravel bridge does.
- Test assertions use plain `assert` (pytest-compatible) instead of a
  PHPUnit bridge.

## License

MIT
