mattdi / ethiopia-eims
Framework-agnostic PHP client for the Ethiopian Ministry of Revenue Electronic Invoice Management System (EIMS), with an optional Laravel bridge.
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.5
- psr/log: ^2.0 || ^3.0
- psr/simple-cache: ^2.0 || ^3.0
Requires (Dev)
- pestphp/pest: ^2.0 || ^3.0
- phpstan/phpstan: ^1.11 || ^2.0
Suggests
- illuminate/support: Required to use the Laravel service provider and facade (^10.0 || ^11.0).
This package is not auto-updated.
Last update: 2026-08-11 12:26:50 UTC
README
A framework-agnostic PHP client for the Ethiopian Ministry of Revenue (MoR) Electronic Invoice Management System (EIMS), with an optional Laravel bridge.
It lets any ERP register invoices, receipts, and query/verify/cancel documents against the EIMS API. The package speaks plain DTOs and returns result objects — it does not touch your database, models, or tenancy. Mapping your domain (sales, customers, products) to an InvoiceRequest and persisting results is your application's job.
Status:
0.x, API may change. Ethiopian e-invoicing is pre-mandate; the request/response shapes reflect the current EIMS API and may evolve.
Requirements
- PHP 8.2+
guzzlehttp/guzzle^7.5- A PSR-16 cache (optional, for token caching) — Laravel's cache works out of the box.
Installation
composer require mattdi/ethiopia-eims
Plain PHP usage
use Mattdi\Eims\EimsClient; use Mattdi\Eims\Config\EimsCredentials; use Mattdi\Eims\Config\SellerConfig; use Mattdi\Eims\Data\{InvoiceRequest, BuyerDetails, InvoiceItem, ValueDetails, DocumentDetails, SourceSystem}; use Mattdi\Eims\Enums\TransactionType; $client = new EimsClient( credentials: new EimsCredentials( clientId: getenv('EIMS_CLIENT_ID'), clientSecret: getenv('EIMS_CLIENT_SECRET'), apiKey: getenv('EIMS_API_KEY'), baseUrl: 'http://core.mor.gov.et', ), seller: new SellerConfig( tin: '0001234567', legalName: 'Acme Pharmacy PLC', regionCode: '14', city: 'Addis Ababa', vatNumber: 'VAT-123', ), // cache: $psr16Cache, // optional // cacheNamespace: 'store_42', // optional; defaults to the TIN ); $result = $client->register(new InvoiceRequest( buyer: BuyerDetails::walkIn(), items: [ new InvoiceItem( productDescription: 'Paracetamol 500mg', quantity: 2, unitPrice: 25.00, preTaxValue: 50.00, taxAmount: 0.00, totalLineAmount: 50.00, ), ], values: new ValueDetails(totalValue: 50.00, taxValue: 0.00), document: new DocumentDetails(documentNumber: 1001, date: date('d-m-Y\TH:i:s')), sourceSystem: new SourceSystem(invoiceCounter: 1001, systemNumber: 'SYS-1'), transactionType: TransactionType::B2C, )); echo $result->irn; echo $result->qrCode; echo $result->signedInvoice;
Building invoices the easy way
Invoice + InvoiceItem::make() compute line totals and the ValueDetails
for you:
use Mattdi\Eims\Data\{Invoice, InvoiceItem, BuyerDetails}; use Mattdi\Eims\Enums\{TransactionType, PaymentMode, DocumentType}; $invoice = Invoice::for(BuyerDetails::walkIn()) ->document(1001, date('d-m-Y\TH:i:s'), DocumentType::Invoice) ->source(1001, systemNumber: 'SYS-1') ->transactionType(TransactionType::B2C) ->payment(PaymentMode::Cash) ->addItem(InvoiceItem::make('Paracetamol 500mg', quantity: 2, unitPrice: 25.00, taxRate: 0.15)) ->addItem(InvoiceItem::make('Vitamin C', quantity: 1, unitPrice: 20.00, taxRate: 0.15, discount: 5.00)) ->build(); // throws if missing items / document / source $result = $client->register($invoice);
InvoiceItem::make() returns an immutable InvoiceItem where preTaxValue,
taxAmount, and totalLineAmount are derived from the inputs.
Resilience: logging & retries
Pass a PSR-3 logger to see every request and response, and configure automatic retry on connection failures / 5xx:
use Mattdi\Eims\Config\EimsCredentials; use Mattdi\Eims\Support\HttpClientFactory; $credentials = new EimsCredentials( clientId: '...', clientSecret: '...', apiKey: '...', baseUrl: 'http://core.mor.gov.et', maxRetries: 2, // transient failures retried with exponential backoff retryDelayMs: 500, ); $http = HttpClientFactory::make($credentials, $psr3Logger); $client = new EimsClient(credentials: $credentials, seller: $seller, http: $http);
In Laravel, enable logging via config and the package wires the logger for you
(see below). EimsCredentials::validate() fails fast if any required value is
missing.
Testing your integration
Mattdi\Eims\Testing\FakeEimsClient records calls and lets you assert on them:
use Mattdi\Eims\Testing\FakeEimsClient; use Mattdi\Eims\Data\Responses\RegisterResult; $fake = new FakeEimsClient(); $fake->pushRegister(new RegisterResult('FAKE-IRN', null, null, null)); $result = $fake->register($invoice); $fake->assertRegistered(); $fake->assertRegisteredCount(1);
In Laravel
Eims::fake() swaps the resolved client for a FakeEimsClient:
use Mattdi\Eims\Laravel\Facades\Eims; Eims::fake(); // ... exercise your code that registers an invoice ... Eims::fake()->assertRegistered();
Laravel usage
Publish the config (optional):
php artisan vendor:publish --tag=eims-config
Set env vars:
EIMS_BASE_URL=http://core.mor.gov.et EIMS_CLIENT_ID=... EIMS_CLIENT_SECRET=... EIMS_API_KEY=... # optional EIMS_RETRY_TIMES=2 EIMS_RETRY_DELAY_MS=500 EIMS_LOGGING=false EIMS_LOG_CHANNEL= EIMS_SELLER_RESOLVER=App\Eims\StoreSellerResolver
Resolve a client for a taxpayer via the facade — global credentials and the
cache are wired for you; you supply the per-taxpayer SellerConfig:
use Mattdi\Eims\Laravel\Facades\Eims; use Mattdi\Eims\Config\SellerConfig; $client = Eims::for( new SellerConfig(tin: $store->tin, legalName: $store->legal_name, /* ... */), cacheNamespace: "store_{$store->id}", // keeps tokens isolated per taxpayer ); $result = $client->register($invoiceRequest);
Resolving the seller from your domain
Implement Mattdi\Eims\Contracts\SellerConfigResolver and register its class
via EIMS_SELLER_RESOLVER (or config('eims.seller_resolver')). Then call
Eims::client($key) and the package builds the SellerConfig for you — e.g.
from the current tenant/store:
use Mattdi\Eims\Contracts\SellerConfigResolver; use Mattdi\Eims\Config\SellerConfig; class StoreSellerResolver implements SellerConfigResolver { public function resolve(mixed $key = null): SellerConfig { $store = Store::findOrFail($key); // your model return new SellerConfig( tin: $store->tin, legalName: $store->legal_name, regionCode: $store->region_code, city: $store->city, ); } public function cacheNamespace(mixed $key = null): ?string { return 'store_' . $key; // isolates auth tokens per taxpayer } } // Elsewhere: $client = Eims::client($store->id);
Artisan commands
php artisan eims:test-connection [key] # authenticate to verify credentials php artisan eims:verify {irn} [key] # verify an invoice by IRN php artisan eims:status {irn} [key] # fetch registration status
[key] is forwarded to your SellerConfigResolver (omitted when using
Eims::for() directly).
Operations
| Method | Endpoint | Returns |
|---|---|---|
register(InvoiceRequest) |
POST /v1/register |
RegisterResult |
cancel(irn, reasonCode, remark) |
POST /v1/cancel |
CancelResult |
verify(irn) |
GET /v1/verify/ |
VerifyResult |
bulkVerify(irns[]) |
loops verify |
array<irn, VerifyResult|EimsException> |
registerReceipt(ReceiptRequest) |
POST /v1/receipt/sales |
ReceiptResult |
status(irn) |
GET /v1/invoices/status/{irn} |
StatusResult |
Enums
CancellationReason(Duplicate,DataEntryError,OrderCancelled,Other) can be passed tocancel()instead of a raw string.InvoiceStatus(Pending,Registered,Failed,Cancelled,Unknown) withInvoiceStatus::fromString()and predicatesisRegistered()/isCancelled(). Read it fromStatusResult::statusEnum().
use Mattdi\Eims\Enums\{CancellationReason, InvoiceStatus}; $client->cancel($irn, CancellationReason::Duplicate, 'issued in error'); $status = $client->status($irn); if ($status->statusEnum()->isCancelled()) { /* ... */ }
Error handling
use Mattdi\Eims\Exceptions\{EimsApiException, EimsAuthenticationException, EimsConnectionException}; try { $result = $client->register($invoice); } catch (EimsAuthenticationException $e) { // Bad/expired credentials } catch (EimsApiException $e) { // EIMS rejected the request: $e->getMessage(), $e->body, $e->statusCode } catch (EimsConnectionException $e) { // 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 cacheNamespace so their auth tokens never collide. The
package never needs to know about your "store"/"branch"/tenant concepts.
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. Support\Canonicalizer and
Support\Signer are provided for integrations issued signing keys, but are not
wired into the request flow.
License
MIT