cambodia / e-invoice-sdk
CamInv (Cambodia E-Invoicing) SDK for Laravel — OAuth, UBL 2.1 XML generation, document submission, webhooks, and member management
Requires
- php: ^8.1
- ext-dom: *
- ext-libxml: *
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0
README
CamInv (Cambodia E-Invoicing) SDK for Laravel — OAuth 2.0 authentication, UBL 2.1 XML generation, document submission, webhook/polling event handling, and member management.
Table of Contents
- Requirements
- Installation
- Environment Variables
- Quick Start
- For-Merchant Pattern
- Implementing TokenStore
- Auto Token Refresh
- Facade API Reference
- Enums
- Exceptions
- Tax Categories
- Configuration Reference
- License
Requirements
- PHP 8.1+
- Laravel 10.x, 11.x, 12.x, or 13.x
- ext-dom, ext-libxml
Installation
composer require cambodia/e-invoice-sdk
Publish the configuration:
php artisan vendor:publish --tag=e-invoice-config
Environment Variables
CAMINV_ENVIRONMENT=sandbox CAMINV_SANDBOX_URL=https://api-sandbox.e-invoice.gov.kh CAMINV_PRODUCTION_URL=https://api.e-invoice.gov.kh CAMINV_CLIENT_ID=your-client-id CAMINV_CLIENT_SECRET=your-client-secret CAMINV_WEBHOOK_URL=https://your-app.com/api/e-invoice/webhook CAMINV_DEFAULT_CURRENCY=KHR
Quick Start
OAuth 2.0 Connection Flow
use CamInv\EInvoice\Facades\CamInv; use CamInv\EInvoice\Contracts\TokenStore; // Step 1: Configure redirect URL (service-level, called once — uses Basic Auth) CamInv::oauth()->configureRedirectUrl(['https://your-app.com/callback']); // Step 2: Generate connect URL (with CSRF state parameter) $result = CamInv::oauth()->generateConnectUrl('https://your-app.com/callback'); // Optionally pass your own state: ->generateConnectUrl($redirectUrl, $customState) // $result['url'] → redirect user to this URL // $result['state'] → store in session for callback validation // In your callback controller: public function callback(Request $request) { // Validate the CSRF state parameter CamInv::oauth()->validateState($request->input('state'), session('caminv_state')); // Step 3: Exchange authorization token (uses Basic Auth) $tokens = CamInv::oauth()->exchangeAuthToken($request->input('authToken')); // Persist tokens using your TokenStore implementation $merchantId = $tokens['business_info']['endpoint_id']; app(TokenStore::class)->put($merchantId, $tokens); // tokens contain: access_token, refresh_token, expires_in, business_info // business_info includes: endpoint_id, moc_id, company_name_en, company_name_kh, // tin, city, phone_number, email } // Revoke a connected member (uses Basic Auth) CamInv::oauth()->revokeConnectedMember($endpointId);
UBL 2.1 XML Generation
use CamInv\EInvoice\Facades\CamInv; $xml = CamInv::ubl()->invoice() ->setId('INV-2026-00123') ->setIssueDate('2026-05-06') ->setDueDate('2026-06-03') ->setInvoiceTypeCode('380') ->setDocumentCurrencyCode('KHR') ->setBuyerReference('PO-2026-00456') ->setNote('Payment due within 30 days') ->setSupplier([ 'endpoint_id' => 'KHUID00001234', 'party_name' => 'Your Company Ltd.', 'postal_address' => [ 'street_name' => '123 Main Street', 'city_name' => 'Phnom Penh', 'country' => ['identification_code' => 'KH'], ], 'party_tax_scheme' => [ 'company_id' => 'L001123456789', 'tax_scheme_id' => 'VAT', ], 'party_legal_entity' => [ 'registration_name' => 'Your Company Ltd.', ], ]) ->setCustomer([ 'endpoint_id' => 'KHUID00005678', 'party_name' => 'Customer Business Name', 'postal_address' => [ 'street_name' => '456 Another Street', 'city_name' => 'Siem Reap', 'country' => ['identification_code' => 'KH'], ], 'party_tax_scheme' => [ 'company_id' => 'L002987654321', 'tax_scheme_id' => 'VAT', ], ]) ->setTaxTotal([ [ 'taxable_amount' => 1000.00, 'tax_amount' => 100.00, 'tax_category_id' => 'S', 'percent' => 10.00, 'tax_scheme_id' => 'VAT', ], ]) ->setMonetaryTotal([ 'line_extension_amount' => 1000.00, 'tax_exclusive_amount' => 1000.00, 'tax_inclusive_amount' => 1100.00, 'payable_amount' => 1100.00, ]) ->setPaymentTerms([ 'note' => 'Payment due within 30 days', 'settlement_discount_percent' => 2.0, 'amount' => 22.0, ]) ->addLine([ 'id' => '1', 'quantity' => 10, 'unit_code' => 'EA', 'line_extension_amount' => 1000.00, 'item' => [ 'name' => 'Product A', 'description' => 'Sample product', ], 'price' => [ 'price_amount' => 100.00, ], 'tax_total' => [[ 'taxable_amount' => 1000.00, 'tax_amount' => 100.00, 'tax_category_id' => 'S', 'percent' => 10.00, ]], ]) ->build(); // Bulk add lines $xml = CamInv::ubl()->invoice() ->setId('INV-2026-00123') // ... ->addLines([ ['id' => '1', /* ... */], ['id' => '2', /* ... */], ]) ->build(); // Advance Options: Allowances/charges and additional document references $xml = CamInv::ubl()->invoice() ->setId('INV-2026-00124') // ... ->setAllowanceCharges([ [ 'charge_indicator' => false, 'amount' => 50.00, 'allowance_charge_reason' => 'Loyalty discount', ], ]) ->setAdditionalDocumentReferences([ [ 'id' => 'ATT-001', 'document_type' => 'Contract', 'attachment' => [ 'file_path' => '/path/to/file.pdf', 'mime_code' => 'application/pdf', 'filename' => 'contract.pdf', ], ], ]) ->build();
Credit/Debit Notes
// Credit Note (requires setOriginalInvoiceId and setNote) $xml = CamInv::ubl()->creditNote() ->setOriginalInvoiceId('INV-2026-00123') ->setId('CN-2026-00045') ->setNote('Refund for overcharge') // ... same builder API as invoice ->build(); // Debit Note (requires setOriginalInvoiceId and setNote) $xml = CamInv::ubl()->debitNote() ->setOriginalInvoiceId('INV-2026-00123') ->setId('DN-2026-00012') ->setNote('Additional charges for extended scope') // ... same builder API as invoice ->build();
Document Submission
All document operations support automatic token resolution when using forMerchant() (see For-Merchant Pattern) or you can pass an $accessToken explicitly.
use CamInv\EInvoice\Facades\CamInv; use CamInv\EInvoice\Enums\DocumentType; // Submit a document (must specify DocumentType) $result = CamInv::documents()->submit(DocumentType::INVOICE, $xml, $accessToken); // Returns: { documents: [{ document_id, verification_link, ... }] } // Send one or more documents to customer (batch) $result = CamInv::documents()->send([$documentId], $accessToken); // Accept one or more received documents (batch) CamInv::documents()->accept([$documentId], $accessToken); // Reject one or more documents with optional reason (batch) CamInv::documents()->reject([$documentId], $accessToken, 'Incorrect pricing'); // Update document status (batch) CamInv::documents()->updateStatus([$documentId], 'PAID', $accessToken); // Fetch document XML/PDF from CamInv $xml = CamInv::documents()->getXml($documentId, $accessToken); $pdf = CamInv::documents()->getPdf($documentId, $accessToken); // Get document detail/metadata $detail = CamInv::documents()->getDetail($documentId, $accessToken); // List your sent or received documents $documents = CamInv::documents()->list($accessToken, 'send', 1, 20); // $type: 'send' or 'receive', optional $documentType filter
Webhook Events
// In your Laravel controller public function receive(Request $request) { $event = CamInv::parseWebhook($request->all()); if ($event->isDocumentDelivered()) { // Update document status to DELIVERED } elseif ($event->isDocumentReceived()) { // Create new received document record } elseif ($event->isStatusUpdated()) { // Update document status $newStatus = $event->status; } elseif ($event->isEntityRevoked()) { // Mark connection as revoked } } // Configure webhook URL for an endpoint (uses Basic Auth) CamInv::webhooks()->configure($endpointId, $webhookUrl); // Remove webhook configuration for an endpoint (uses Basic Auth) CamInv::webhooks()->unset($endpointId);
Polling Events
As an alternative to webhooks, you can poll for document events. This is useful when a public webhook URL is not available.
use CamInv\EInvoice\Facades\CamInv; // Poll for recent document events since a given timestamp $lastSyncedAt = '2026-05-06T12:00:00Z'; // ISO 8601 format, or null for all $events = CamInv::polling()->poll($lastSyncedAt, $accessToken); foreach ($events as $event) { // Each event is a CamInv\EInvoice\Polling\PollEvent value object $event->documentId; // string $event->updatedAt; // string (ISO 8601) $event->type; // "SEND" or "RECEIVE" $event->payload; // array (full event data) if ($event->isSend()) { // Handle sent document event } elseif ($event->isReceive()) { // Handle received document event } } // With automatic token resolution $events = CamInv::polling()->forMerchant($merchantId)->poll($lastSyncedAt);
Member Management
use CamInv\EInvoice\Facades\CamInv; // Search members by company name, TIN, or endpoint ID $members = CamInv::members()->list($accessToken, 'CompanyName', 20); // Get detailed member information by endpoint ID $member = CamInv::members()->get('KHUID00001234', $accessToken); // Validate taxpayer information $result = CamInv::members()->validateTaxpayer( tin: 'L001123456789', singleId: 'KHUID00001234', companyNameEn: 'Your Company Ltd.', companyNameKh: 'ក្រុមហ៊ុន', accessToken: $accessToken, );
For-Merchant Pattern
The DocumentClient, MemberClient, and PollingClient support a forMerchant() pattern for automatic token resolution. When you set a merchant context, the SDK automatically fetches and manages tokens via your TokenStore implementation — no need to pass $accessToken manually.
use CamInv\EInvoice\Facades\CamInv; use CamInv\EInvoice\Enums\DocumentType; // Without forMerchant — pass token explicitly CamInv::documents()->submit(DocumentType::INVOICE, $xml, $accessToken); // With forMerchant — token resolved automatically from your TokenStore CamInv::documents()->forMerchant($merchantId)->submit(DocumentType::INVOICE, $xml); // All methods support this: submit, send, accept, reject, updateStatus, // getXml, getPdf, getDetail, list // Same for MemberClient and PollingClient CamInv::members()->forMerchant($merchantId)->list(null, 'CompanyName'); CamInv::polling()->forMerchant($merchantId)->poll($lastSyncedAt);
Implementing TokenStore
The SDK requires a TokenStore implementation for token persistence. Create one in your Laravel app:
namespace App\Services\EInvoice; use CamInv\EInvoice\Contracts\TokenStore; class EloquentTokenStore implements TokenStore { public function get(string $merchantId): ?array { $connection = \App\Models\EInvoice\Connection::where('merchant_id', $merchantId)->first(); if (! $connection || ! $connection->access_token) { return null; } return [ 'access_token' => $connection->access_token, 'refresh_token' => $connection->refresh_token, 'expires_at' => $connection->token_expires_at->timestamp, 'endpoint_id' => $connection->endpoint_id, 'business_info' => $connection->business_info, 'merchant_id' => $merchantId, ]; } public function put(string $merchantId, array $tokenResponse): void { $expiresIn = $tokenResponse['expires_in'] ?? 3600; \App\Models\EInvoice\Connection::updateOrCreate( ['merchant_id' => $merchantId], [ 'access_token' => $tokenResponse['access_token'], 'refresh_token' => $tokenResponse['refresh_token'], 'token_expires_at' => now()->addSeconds($expiresIn), 'endpoint_id' => $tokenResponse['business_info']['endpoint_id'] ?? null, 'business_info' => $tokenResponse['business_info'] ?? null, 'registration_status' => 'connected', 'last_token_refresh_at' => now(), ] ); } public function forget(string $merchantId): void { \App\Models\EInvoice\Connection::where('merchant_id', $merchantId)->update([ 'access_token' => null, 'refresh_token' => null, 'registration_status' => 'revoked', ]); } public function expiringWithin(int $seconds): array { return \App\Models\EInvoice\Connection::where('registration_status', 'connected') ->where('token_expires_at', '<', now()->addSeconds($seconds)) ->get() ->map(fn ($c) => [ 'merchant_id' => $c->merchant_id, 'access_token' => $c->access_token, 'refresh_token' => $c->refresh_token, 'expires_at' => $c->token_expires_at->timestamp, ]) ->toArray(); } }
Then bind it in your AppServiceProvider:
$this->app->bind(\CamInv\EInvoice\Contracts\TokenStore::class, \App\Services\EInvoice\EloquentTokenStore::class);
Auto Token Refresh
The SDK supports automatic token refresh via the HasTokenRefresh trait. The DocumentClient, MemberClient, and PollingClient automatically refresh expired tokens when a 401 is received if the forMerchant() context is set.
Additionally, you can manage tokens explicitly:
// Get a valid access token (auto-refreshes if expired) $token = CamInv::token()->getValidAccessToken($merchantId); // Manually refresh a merchant's access token $result = CamInv::token()->refreshAccessToken($merchantId); // Bulk refresh all tokens nearing expiry (useful for scheduled tasks) $refreshed = CamInv::token()->refreshExpiringTokens(); // Check if a token is expired (with configurable buffer) $isExpired = CamInv::token()->isTokenExpired($tokenData);
For scheduled token refresh, add to your app/Console/Kernel.php:
$schedule->call(function () { CamInv::token()->refreshExpiringTokens(); })->everyFiveMinutes();
Facade API Reference
use CamInv\EInvoice\Facades\CamInv; use CamInv\EInvoice\Enums\DocumentType; // Client — direct HTTP access CamInv::client()->withBearerToken($token)->get('/api/v1/...'); CamInv::client()->withBearerToken($token)->post('/api/v1/...'); CamInv::client()->withBearerToken($token)->put('/api/v1/...'); CamInv::client()->withBearerToken($token)->patch('/api/v1/...'); CamInv::client()->withBearerToken($token)->delete('/api/v1/...'); CamInv::client()->withBearerToken($token)->getRaw('/api/v1/...'); CamInv::client()->withBasicAuth()->post('/api/v1/...'); // OAuth (Basic Auth where noted) CamInv::oauth()->configureRedirectUrl([$url]); CamInv::oauth()->generateConnectUrl($redirectUrl, $state = null); CamInv::oauth()->exchangeAuthToken($authToken); CamInv::oauth()->refreshAccessToken($refreshToken); CamInv::oauth()->revokeConnectedMember($endpointId); // Token management CamInv::token()->getValidAccessToken($merchantId); CamInv::token()->isTokenExpired($token); CamInv::token()->refreshAccessToken($merchantId); CamInv::token()->refreshExpiringTokens(); CamInv::token()->calculateExpiresAt($expiresIn); // Documents (all support forMerchant auto-token) CamInv::documents()->submit(DocumentType::INVOICE, $xml, $token); CamInv::documents()->send([$docId], $token); CamInv::documents()->accept([$docId], $token); CamInv::documents()->reject([$docId], $token, $reason = null); CamInv::documents()->updateStatus([$docId], $status, $token); CamInv::documents()->getXml($docId, $token); CamInv::documents()->getPdf($docId, $token); CamInv::documents()->getDetail($docId, $token); CamInv::documents()->list($token, $type = 'send', $page = 1, $size = 20, $documentType = null); CamInv::documents()->forMerchant($merchantId)->submit(DocumentType::INVOICE, $xml); // UBL Builder CamInv::ubl()->invoice($options) ->setId(...) ->setIssueDate(...) ->setDueDate(...) ->setNote(...) ->setBuyerReference(...) ->setInvoiceTypeCode(...) ->setDocumentCurrencyCode(...) ->setSupplier(...) ->setCustomer(...) ->setPaymentTerms(...) ->setAdditionalDocumentReferences(...) ->setAllowanceCharges(...) ->setTaxExchangeRate(...) ->setTaxTotal(...) ->setMonetaryTotal(...) ->addLine(...) ->addLines([...]) ->build(); CamInv::ubl()->creditNote($options)->setOriginalInvoiceId(...)->build(); CamInv::ubl()->debitNote($options)->setOriginalInvoiceId(...)->build(); // Webhooks CamInv::webhooks()->configure($endpointId, $webhookUrl); CamInv::webhooks()->unset($endpointId); CamInv::parseWebhook($payload); // Members (supports forMerchant auto-token) CamInv::members()->list($token, $keyword = '', $limit = 10); CamInv::members()->get($endpointId, $token); CamInv::members()->validateTaxpayer($tin, $singleId, $companyNameEn, $companyNameKh, $token); CamInv::members()->forMerchant($merchantId)->list(null, 'CompanyName'); // Polling (supports forMerchant auto-token) CamInv::polling()->poll($lastSyncedAt, $token); CamInv::polling()->forMerchant($merchantId)->poll($lastSyncedAt);
Auto token resolution: When
$tokenisnullandforMerchant($merchantId)is set onDocumentClient,MemberClient, orPollingClient, theTokenManagerautomatically resolves a valid access token from yourTokenStoreand refreshes it on 401. You can still pass an explicit$accessTokento bypass this behavior.
Enums
use CamInv\EInvoice\Enums\DocumentType; use CamInv\EInvoice\Enums\DocumentStatus; use CamInv\EInvoice\Enums\DocumentDirection; use CamInv\EInvoice\Enums\WebhookEventType; use CamInv\EInvoice\Enums\RegistrationStatus; use CamInv\EInvoice\Enums\TaxCategory; // DocumentType DocumentType::INVOICE; // 'INVOICE' DocumentType::CREDIT_NOTE; // 'CREDIT_NOTE' DocumentType::DEBIT_NOTE; // 'DEBIT_NOTE' DocumentType::INVOICE->ublCode(); // '380' DocumentType::CREDIT_NOTE->ublCode(); // '381' DocumentType::DEBIT_NOTE->ublCode(); // '383' DocumentType::INVOICE->xmlns(); // 'urn:oasis:names:specification:ubl:schema:xsd:Invoice-2' // DocumentStatus DocumentStatus::DRAFT; DocumentStatus::SUBMITTING; DocumentStatus::VALID; DocumentStatus::DELIVERED; DocumentStatus::ACKNOWLEDGED; DocumentStatus::IN_PROCESS; DocumentStatus::UNDER_QUERY; DocumentStatus::CONDITIONALLY_ACCEPTED; DocumentStatus::ACCEPTED; DocumentStatus::REJECTED; DocumentStatus::PAID; DocumentStatus::ACCEPTED->isTerminal(); // true DocumentStatus::REJECTED->isTerminal(); // true DocumentStatus::PAID->isTerminal(); // true DocumentStatus::ACCEPTED->color(); // 'green' // DocumentDirection DocumentDirection::SENT; DocumentDirection::RECEIVED; // WebhookEventType WebhookEventType::DOCUMENT_DELIVERED; // 'DOCUMENT.DELIVERED' WebhookEventType::DOCUMENT_RECEIVED; // 'DOCUMENT.RECEIVED' WebhookEventType::DOCUMENT_STATUS_UPDATED; // 'DOCUMENT.STATUS_UPDATED' WebhookEventType::ENTITY_REVOKED; // 'ENTITY.REVOKED' // RegistrationStatus RegistrationStatus::PENDING; // 'pending' RegistrationStatus::CONNECTED; // 'connected' RegistrationStatus::REVOKED; // 'revoked' RegistrationStatus::EXPIRED; // 'expired' // TaxCategory TaxCategory::VAT; // 'VAT' TaxCategory::SPECIFIC_TAX; // 'SP' TaxCategory::PUBLIC_LIGHTING_TAX; // 'PLT' TaxCategory::ACCOMMODATION_TAX; // 'AT' TaxCategory::VAT->defaultRate(); // 10.0 TaxCategory::SPECIFIC_TAX->defaultRate(); // 0.0
Exceptions
All exceptions extend CamInv\EInvoice\Exceptions\CamInvException which provides getStatusCode() and getResponseBody() methods.
| Exception | Description |
|---|---|
AuthenticationException |
Invalid credentials, expired/invalid token, CSRF state mismatch, invalid auth token, revoke failure |
ConnectionException |
HTTP timeout, network error, SSL error |
TokenExpiredException |
Token expired and cannot be refreshed, no token stored |
ValidationException |
Invalid UBL XML, missing required fields, invalid document status, submission failure |
try { CamInv::documents()->submit(DocumentType::INVOICE, $xml, $token); } catch (\CamInv\EInvoice\Exceptions\AuthenticationException $e) { // Handle auth failure (401) } catch (\CamInv\EInvoice\Exceptions\ValidationException $e) { // Handle validation error (422) } catch (\CamInv\EInvoice\Exceptions\ConnectionException $e) { // Handle network error } catch (\CamInv\EInvoice\Exceptions\CamInvException $e) { // Generic fallback $statusCode = $e->getStatusCode(); $body = $e->getResponseBody(); }
Tax Categories
Configurable via config/e-invoice.php → ubl.tax_categories.
| Constant | Enum Value | Label | Default Rate |
|---|---|---|---|
TaxCategory::VAT |
VAT |
Value Added Tax | 10% |
TaxCategory::SPECIFIC_TAX |
SP |
Specific Tax | 0% |
TaxCategory::PUBLIC_LIGHTING_TAX |
PLT |
Public Lighting Tax | 0% |
TaxCategory::ACCOMMODATION_TAX |
AT |
Accommodation Tax | 0% |
Tax schemes are configured separately at ubl.tax_schemes:
| ID | Name |
|---|---|
S |
Standard |
Z |
Zero |
Configuration Reference
| Config Key | Env Variable | Default | Description |
|---|---|---|---|
default_environment |
CAMINV_ENVIRONMENT |
sandbox |
sandbox or production |
environments.sandbox.base_url |
CAMINV_SANDBOX_URL |
https://api-sandbox.e-invoice.gov.kh |
Sandbox API URL |
environments.production.base_url |
CAMINV_PRODUCTION_URL |
https://api.e-invoice.gov.kh |
Production API URL |
client_id |
CAMINV_CLIENT_ID |
— | OAuth client ID from CamInv |
client_secret |
CAMINV_CLIENT_SECRET |
— | OAuth client secret from CamInv |
webhook_url |
CAMINV_WEBHOOK_URL |
/api/e-invoice/webhook |
Default webhook URL |
token.refresh_buffer_minutes |
— | 5 |
Minutes before expiry to proactively refresh |
http.timeout |
— | 30 |
HTTP timeout in seconds |
http.retries |
— | 3 |
Number of retry attempts |
http.retry_delay |
— | 100 |
Delay between retries (ms) |
ubl.tax_categories |
— | VAT, SP, PLT, AT |
Tax category definitions |
ubl.tax_schemes |
— | S (Standard), Z (Zero) |
Tax scheme definitions |
ubl.default_currency |
CAMINV_DEFAULT_CURRENCY |
KHR |
Default currency code |
License
MIT