mattdi/ethiopia-eims

Framework-agnostic PHP client for the Ethiopian Ministry of Revenue Electronic Invoice Management System (EIMS), with an optional Laravel bridge.

Maintainers

Package info

github.com/Matt-di/ethiopia-eims

pkg:composer/mattdi/ethiopia-eims

Transparency log

Statistics

Installs: 3

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-07-13 13:38 UTC

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 to cancel() instead of a raw string.
  • InvoiceStatus (Pending, Registered, Failed, Cancelled, Unknown) with InvoiceStatus::fromString() and predicates isRegistered() / isCancelled(). Read it from StatusResult::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