flavytech / laravel-etims
Production-grade Laravel SDK for Kenya's KRA eTIMS / Gava Connect API. Handles invoice submission, queue-based sync, retries, multi-tenancy, and offline resilience.
Requires
- php: ^8.1
- guzzlehttp/guzzle: ^7.8
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/events: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/queue: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.40
- larastan/larastan: ^2.9
- mockery/mockery: ^1.6
- orchestra/testbench: ^8.0|^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
- phpstan/phpstan: ^1.10
README
Production-grade Laravel SDK for Kenya's KRA eTIMS / Gava Connect API. Built for POS systems, ERP platforms, and SaaS products operating in Kenya and East Africa.
Why This SDK?
Integrating with KRA eTIMS is non-trivial in real production environments:
- The API goes down. Your invoices must not be lost.
- Networks in Kenya are intermittent. Retries must be smart, not dumb.
- POS systems process hundreds of transactions per hour. Your queue must be resilient.
- You may serve multiple businesses from one Laravel installation. Multi-tenancy must be first-class.
This SDK handles all of that so you can focus on your product.
Features
| Feature | Status |
|---|---|
| Invoice submission (sync + async) | ✅ Phase 1 |
| Auth token management + auto-refresh | ✅ Phase 1 |
| Exponential backoff retry | ✅ Phase 1 |
| Idempotency protection | ✅ Phase 1 |
| Audit trail (DB) | ✅ Phase 1 |
| Laravel event system integration | ✅ Phase 1 |
| Sandbox/production mode switching | ✅ Phase 1 |
| Testing fake + assertion API | ✅ Phase 1 |
| KRA PIN validation | ✅ Phase 1 |
| Dead letter queue + failed invoice recovery | ✅ Phase 1 |
| Multi-tenant SaaS support | ✅ Phase 1 |
| Stock synchronization | 🔜 Phase 2 |
| Credit/debit note handling | 🔜 Phase 2 |
| Webhook handling | 🔜 Phase 2 |
| QR code + thermal receipt support | 🔜 Phase 3 |
| Branch management | 🔜 Phase 3 |
Installation
composer require flavytech/laravel-etims
Publish the configuration and migrations:
php artisan vendor:publish --tag=etims-config php artisan vendor:publish --tag=etims-migrations php artisan migrate
Configuration
Add these variables to your .env file:
# Mode: sandbox | production ETIMS_MODE=sandbox # Your KRA-issued credentials ETIMS_PIN=P000000000A ETIMS_BRANCH_ID=00 ETIMS_DEVICE_SERIAL=your-device-serial ETIMS_SECRET=your-api-secret # Queue (recommended: use Redis) ETIMS_QUEUE_CONNECTION=redis ETIMS_QUEUE_NAME=etims ETIMS_MAX_TRIES=5 # Optional tuning ETIMS_TIMEOUT=30 ETIMS_LOG_CHANNEL=daily
Never commit real credentials to version control. Use
.envonly.
Quick Start
Submitting an Invoice Synchronously
Use this when you need an immediate KRA response (e.g. a POS checkout flow):
use Flavytech\Etims\Facades\Etims; use Flavytech\Etims\DTOs\InvoiceDTO; use Flavytech\Etims\DTOs\InvoiceLineDTO; $invoice = InvoiceDTO::make([ 'invoice_number' => 'INV-2024-001', 'supplier_pin' => config('etims.credentials.pin'), 'buyer_pin' => 'P000000000B', // buyer's KRA PIN 'buyer_name' => 'Acme Ltd', 'total_amount' => 11600.00, 'vat_amount' => 1600.00, 'taxable_amount' => 10000.00, 'invoice_date' => now()->toDateString(), 'invoice_type' => 'S', // S = Sale 'payment_type' => 'CASH', 'items' => [ InvoiceLineDTO::make([ 'item_number' => 1, 'item_code' => 'ITEM-001', 'item_name' => 'Widget Pro', 'quantity' => 2, 'unit_price' => 5000.00, 'taxable_amount' => 10000.00, 'vat_amount' => 1600.00, 'total_amount' => 11600.00, 'tax_type_code' => 'A', // A = Standard 16% VAT ]), ], ]); $response = Etims::submitInvoice($invoice); if ($response->isSuccessful()) { // Print the KRA receipt number and QR code $receiptNumber = $response->receiptNumber; $qrCode = $response->qrCode; }
Queuing an Invoice (Recommended for Most Cases)
This is the preferred approach for production. The invoice is dispatched to a durable background job with automatic retries:
// Fire and forget — returns immediately Etims::queueInvoice($invoice); // Your application continues while KRA processes in the background. // Listen to events to know the outcome:
Listen to the outcome events in your EventServiceProvider:
protected $listen = [ \Flavytech\Etims\Events\InvoiceSubmitted::class => [ \App\Listeners\GenerateKraReceipt::class, // generate receipt PDF \App\Listeners\UpdateOrderStatus::class, // mark order as fiscalized ], \Flavytech\Etims\Events\InvoiceFailed::class => [ \App\Listeners\AlertOperationsTeam::class, // notify via Slack/email \App\Listeners\FlagOrderForReview::class, // mark in your system ], \Flavytech\Etims\Events\InvoiceQueued::class => [ \App\Listeners\ShowPendingStatus::class, // update POS UI immediately ], ];
Validating a KRA PIN
$result = Etims::validatePin('P000000000B'); if ($result->isValid()) { echo "Buyer: {$result->taxpayerName}"; } else { // PIN not found in KRA registry return back()->withError('Invalid buyer PIN. Please verify and try again.'); }
Checking Invoice Status
$status = Etims::getInvoiceStatus('INV-2024-001'); if ($status->isSuccessful()) { echo "Receipt: {$status->receiptNumber}"; } elseif ($status->isPending()) { echo "KRA is still processing this invoice."; }
Working with Failed Invoices
// Get all permanently failed invoices for review $failedInvoices = Etims::failedInvoices(); foreach ($failedInvoices as $invoice) { echo "{$invoice->invoice_number}: {$invoice->failure_reason}"; } // Re-queue a failed invoice after fixing the issue Etims::retryFailedInvoice($invoice->id);
Invoice Types
| Code | Description | Use Case |
|---|---|---|
S |
Sale | Standard sales invoice |
C |
Credit Note | Refund / reversal of a sale |
D |
Debit Note | Additional charge on a sale |
Tax Type Codes
| Code | Description | VAT Rate |
|---|---|---|
A |
Standard rated | 16% |
B |
Zero rated | 0% |
C |
VAT exempt | N/A |
D |
Non-VATable | N/A |
E |
Excisable (with VAT) | 16% + Excise |
Payment Type Codes
| Code | Description |
|---|---|
CASH |
Cash payment |
CREDIT |
Credit terms |
MPESA |
M-Pesa mobile money |
BANK |
Bank transfer |
CHEQUE |
Cheque |
OTHER |
Other payment methods |
Multi-Tenant SaaS Setup
When serving multiple KRA-registered businesses from one Laravel installation:
Step 1: Enable multi-tenancy in config:
// config/etims.php 'multi_tenancy' => [ 'enabled' => true, 'tenant_resolver' => \App\Services\EtimsTenantResolver::class, ],
Step 2: Implement the TenantResolverContract:
use Flavytech\Etims\Contracts\TenantResolverContract; class EtimsTenantResolver implements TenantResolverContract { public function resolve(): array { // Resolve from your tenancy system $tenant = app('currentTenant'); // e.g. spatie/laravel-multitenancy return [ 'pin' => $tenant->kra_pin, 'branch_id' => $tenant->etims_branch_id, 'device_serial' => $tenant->etims_device_serial, 'secret' => decrypt($tenant->etims_secret), // store encrypted! 'mode' => $tenant->etims_mode, // per-tenant sandbox/prod ]; } public function tenantId(): string|int { return app('currentTenant')->id; } }
Step 3: Bind it in your AppServiceProvider:
$this->app->bind( \Flavytech\Etims\Contracts\TenantResolverContract::class, \App\Services\EtimsTenantResolver::class );
That's it. Every SDK call now automatically uses the correct credentials for the active tenant.
Testing
The SDK provides a first-class fake for testing without any real HTTP calls.
use Flavytech\Etims\Facades\Etims; beforeEach(function () { Etims::fake(); }); it('fiscalizes an order on checkout', function () { $order = Order::factory()->create(['total' => 11600]); $this->post("/orders/{$order->id}/checkout"); Etims::assertInvoiceSubmitted("INV-{$order->id}"); }); it('queues the invoice for background processing', function () { Queue::fake(); Etims::queueInvoice(makeTestInvoice('INV-001')); Etims::assertInvoiceQueued('INV-001'); }); it('handles KRA downtime gracefully', function () { Etims::fake()->failWith( new \Flavytech\Etims\Exceptions\EtimsApiException('KRA is down', 503) ); // Your app should still return 200 — it queues for retry $this->post('/checkout/1')->assertStatus(202); }); it('validates correct invoice data', function () { $fake = Etims::fake(); $this->post('/checkout/1'); $fake->assertSubmittedMatching( fn($invoice) => $invoice->totalAmount === 11600.00 && $invoice->invoiceType === 'S' ); }); it('rejects an invalid buyer PIN', function () { Etims::fake()->withInvalidPins(['P999999999Z']); $response = Etims::validatePin('P999999999Z'); expect($response->isValid())->toBeFalse(); });
Stubbing Specific Responses
use Flavytech\Etims\DTOs\InvoiceResponseDTO; $stubbedResponse = InvoiceResponseDTO::fromKraResponse([ 'resultCd' => '000', 'resultMsg' => 'OK', 'data' => [ 'rcptNo' => 'RCPT-MY-TEST', 'qrCodeUrl' => 'https://test.kra.go.ke/qr/test', ], ]); Etims::fake()->respondTo('INV-SPECIFIC-001', $stubbedResponse); $response = Etims::submitInvoice($invoice); // returns RCPT-MY-TEST
Running the SDK Test Suite
composer test composer test-coverage composer analyse # PHPStan static analysis composer format # PHP CS Fixer
Queue Worker Setup
Run a dedicated worker for the eTIMS queue in production:
# Supervisor config for dedicated eTIMS worker
php artisan queue:work redis \
--queue=etims \
--tries=5 \
--backoff=10,30,60,120,300 \
--timeout=60 \
--sleep=3
For failed job monitoring:
# View failed eTIMS jobs php artisan queue:failed | grep etims # Retry all failed jobs php artisan queue:retry all
Architecture Overview
Facade (Etims::)
└── EtimsManager (orchestration, idempotency, events, multi-tenancy)
└── EtimsClient (API contract implementation)
└── EtimsHttpClient (HTTP, auth tokens, retries, logging)
└── KRA Gava Connect API
Async path:
Etims::queueInvoice() → SubmitInvoiceJob → Queue Worker → EtimsClient
Error Handling Reference
| Exception | Cause | Retryable |
|---|---|---|
EtimsApiException |
KRA API error or network failure | Depends on HTTP status |
EtimsAuthException |
Invalid credentials or expired token | No — fix credentials |
EtimsValidationException |
Invalid DTO data (client-side) | No — fix data |
EtimsIdempotencyException |
Duplicate invoice detected | No — already submitted |
EtimsConfigException |
Missing or invalid SDK config | No — fix config |
Changelog
See CHANGELOG.md.
Contributing
Contributions welcome. Please read CONTRIBUTING.md first.
License
MIT. See LICENSE.
Built with care for the Kenyan and East African developer ecosystem. 🇰🇪