bee-coded / laravel-efactura
Laravel wrapper for e-Factura SDK with token storage, job scheduling, and easy model integration
Requires
- php: ^8.4
- bee-coded/laravel-efactura-sdk: ^2.1.1
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
README
A Laravel package that wraps bee-coded/laravel-efactura-sdk to provide token storage, job scheduling, and easy model integration for Romanian e-Factura (ANAF SPV) compliance.
Features
- Token Management - OAuth token storage per CUI with automatic refresh
- Background Jobs - Ready-to-use jobs for invoice uploads, status checks, and message syncing
- Model Integration - Simple interface + trait pattern for your invoice models
- Event-Driven - Events for all key operations (uploads, failures, received invoices)
- Minimal Setup - Auto-discovery, publishable config and migrations
Requirements
- PHP 8.4+
- Laravel 11.x or 12.x
- ANAF SPV OAuth credentials
Installation
composer require bee-coded/laravel-efactura
Publish the configuration and migrations:
php artisan vendor:publish --tag=efactura-config php artisan vendor:publish --tag=efactura-migrations php artisan migrate
Configuration
Environment Variables
# SDK Configuration (required) EFACTURA_SANDBOX=true EFACTURA_CLIENT_ID=your-anaf-client-id EFACTURA_CLIENT_SECRET=your-anaf-client-secret EFACTURA_REDIRECT_URI=https://your-app.com/efactura/callback # Package Configuration EFACTURA_ENABLED=true EFACTURA_UPLOAD_ENABLED=true EFACTURA_DOWNLOAD_RECEIVED=false EFACTURA_SYNC_MESSAGES=true # Storage EFACTURA_STORAGE_DISK=local EFACTURA_STORAGE_PATH=efactura # Queue (null = default queue) EFACTURA_QUEUE=null # Rate Limit Handling EFACTURA_RATE_LIMIT_RETRY_HOURS=24 EFACTURA_RATE_LIMIT_RETRY_BATCH=250 EFACTURA_RATE_LIMIT_RETRY_MAX_DAYS=7 # Routes EFACTURA_ROUTES_ENABLED=true EFACTURA_ROUTES_PREFIX=efactura EFACTURA_SUCCESS_REDIRECT=/ EFACTURA_ERROR_REDIRECT=/
Config File
The configuration file (config/efactura.php) allows you to:
- Enable/disable the entire package or specific features
- Configure file storage for XML and ZIP files
- Set job schedules using cron expressions
- Customize OAuth callback routes
Model Integration
1. Implement the Interface
Your invoice model must implement EFacturaUploadableInterface:
<?php namespace App\Models; use BeeCoded\EFactura\Contracts\EFacturaUploadableInterface; use BeeCoded\EFactura\Traits\HasEfacturaUpload; use BeeCoded\EFacturaSdk\Data\Invoice\InvoiceData; use BeeCoded\EFacturaSdk\Data\Invoice\PartyData; use BeeCoded\EFacturaSdk\Data\Invoice\AddressData; use BeeCoded\EFacturaSdk\Data\Invoice\InvoiceLineData; use Illuminate\Database\Eloquent\Model; class Invoice extends Model implements EFacturaUploadableInterface { use HasEfacturaUpload; /** * Transform this model into SDK's InvoiceData DTO. */ public function toEfacturaData(): InvoiceData { return new InvoiceData( invoiceNumber: $this->number, issueDate: $this->issued_at, dueDate: $this->due_at, currency: $this->currency, supplier: new PartyData( registrationName: $this->company->name, companyId: $this->company->vat_number, address: new AddressData( street: $this->company->address, city: $this->company->city, postalZone: $this->company->postal_code, countryCode: $this->company->country_code, ), isVatPayer: $this->company->is_vat_payer, ), customer: new PartyData( registrationName: $this->customer->name, companyId: $this->customer->vat_number, address: new AddressData( street: $this->customer->address, city: $this->customer->city, postalZone: $this->customer->postal_code, countryCode: $this->customer->country_code, ), isVatPayer: $this->customer->is_vat_payer, ), lines: $this->lines->map(fn ($line) => new InvoiceLineData( name: $line->description, quantity: $line->quantity, unitPrice: $line->unit_price, taxAmount: $line->vat_amount, // Pre-computed VAT for this line taxPercent: $line->vat_rate, ))->all(), paymentIban: $this->company->iban, ); } /** * Get the CUI for this invoice (determines which token to use). */ public function getEfacturaCui(): string { return $this->company->cui; // Without RO prefix } }
2. Available Trait Methods
The HasEfacturaUpload trait provides:
// Relationship $invoice->efacturaUpload; // The EfacturaUpload model // Status checks $invoice->isUploadedToEfactura(); // bool $invoice->getEfacturaStatus(); // ?UploadStatus enum $invoice->isEfacturaProcessed(); // bool (completed or failed) // File paths $invoice->getEfacturaXmlPath(); // ?string $invoice->getEfacturaResponsePath(); // ?string $invoice->getEfacturaErrors(); // ?array // Query scopes Invoice::notUploadedToEfactura()->get(); // Not yet queued Invoice::efacturaPending()->get(); // Queued, awaiting upload Invoice::efacturaInProgress()->get(); // Currently uploading/processing Invoice::efacturaCompleted()->get(); // Successfully processed Invoice::efacturaFailed()->get(); // Failed Invoice::efacturaProcessed()->get(); // Terminal state (completed or failed) Invoice::efacturaAwaitingResponse()->get(); // Completed but response not downloaded Invoice::withEfacturaStatus(UploadStatus::Pending)->get(); // Specific status
Usage
Queue an Invoice for Upload
use BeeCoded\EFactura\Facades\EFactura; // Standard B2B upload $upload = EFactura::queueUpload($invoice); // With options $upload = EFactura::queueUpload($invoice, [ 'standard' => 'UBL', // UBL, CN, CII, RASP 'extern' => false, // External/non-Romanian supplier 'self_billed' => false, // Self-billed/autofactura ]); // B2C upload $upload = EFactura::queueB2CUpload($invoice);
Queue a Credit Note for Upload
Credit notes work the same way as invoices — just set invoiceTypeCode to CreditNote and reference the original invoice:
use BeeCoded\EFacturaSdk\Data\Invoice\InvoiceData; use BeeCoded\EFacturaSdk\Data\Invoice\InvoiceLineData; use BeeCoded\EFacturaSdk\Enums\InvoiceTypeCode; $creditNote = new InvoiceData( invoiceNumber: 'CN-2024-001', issueDate: now(), currency: 'RON', invoiceTypeCode: InvoiceTypeCode::CreditNote, precedingInvoiceNumber: 'INV-2024-001', supplier: $supplier, customer: $customer, lines: [ new InvoiceLineData( name: 'Returned product', quantity: -3, // negative = items being credited unitPrice: 150.00, taxAmount: -85.50, // sign follows quantity: -3 * 150.00 * 0.19 taxPercent: 19, ), ], );
Note: The SDK (v1.1+) automatically negates credit note line quantities before sending to ANAF (which expects positive values in
<CreditNote>documents). Pass negative quantities for items being credited and positive for debit-back lines (e.g., discount reversals).
$upload = EFactura::queueUpload($creditNoteModel);
The taxAmount Parameter (Required since SDK v2.0)
Every InvoiceLineData requires a taxAmount — the pre-computed VAT amount for that line. The SDK uses this value directly in the XML instead of recalculating VAT internally.
Why this matters: In v1.x, the SDK grouped lines by tax rate and recalculated VAT as sum_of_bases × rate. This caused rounding discrepancies (typically 0.01 RON) when your application used tax-included pricing, because extracting VAT by subtraction (gross - net) can produce different results than multiplying (net × rate) after rounding. By passing your pre-computed taxAmount, the XML total matches your application's total exactly.
How to compute it:
// Tax-exclusive pricing (you store the net unit price): $taxAmount = round(round($quantity * $unitPrice, 2) * $vatRate / 100, 2); // Tax-inclusive pricing (you store the gross price and extract the net): $basePrice = round($grossPrice / (1 + $vatRate / 100), 2); $taxAmount = $grossPrice - $basePrice; // This is what you should pass
Sign convention: The taxAmount sign follows the quantity — negative for credit note lines (negative qty), positive for regular lines.
Upgrading from SDK v1.x: Add
taxAmountto everyInvoiceLineDatain yourtoEfacturaData()method. Pass the VAT amount your application already computes for each line item.
Process Upload Immediately
// Queue and process immediately $upload = EFactura::queueUpload($invoice); EFactura::processUpload($upload);
Access the SDK Client
For advanced operations, get an authenticated SDK client:
use BeeCoded\EFacturaSdk\Enums\DocumentStandardType; $client = EFactura::client('12345678'); // CUI // Validate XML $result = $client->validateXml($xml, DocumentStandardType::FACT1); // Convert to PDF $pdf = $client->convertXmlToPdf($xml, DocumentStandardType::FACT1); // Get messages $messages = $client->getMessages($params);
Company Lookup (No Auth Required)
Use the SDK directly for company lookups:
use BeeCoded\EFacturaSdk\Facades\AnafDetails; $company = AnafDetails::getCompanyData('12345678'); $companies = AnafDetails::batchGetCompanyData(['12345678', '87654321']);
OAuth Flow
1. Redirect to ANAF
The package provides routes for OAuth:
GET /efactura/auth/{cui} → Redirects to ANAF OAuth
GET /efactura/callback → Handles OAuth callback
In your application:
// In a controller or Livewire component return redirect()->route('efactura.auth', ['cui' => '12345678']);
Or generate the URL manually:
$url = EFactura::getAuthorizationUrl('12345678');
2. Handle the Callback
The package automatically:
- Validates the OAuth state (CSRF protection)
- Exchanges the code for tokens
- Stores the tokens in the database
- Fires the
TokenStoredevent - Redirects to your configured success/error URL
3. Listen to Events
// In EventServiceProvider or a listener use BeeCoded\EFactura\Events\TokenStored; Event::listen(TokenStored::class, function (TokenStored $event) { $token = $event->token; // Notify user, log, etc. Log::info("e-Factura authorized for CUI: {$token->cui}"); });
Job Scheduling (Required)
Important: This package provides jobs but does NOT schedule them automatically. You must register the job schedules in your application.
Register Jobs in Your Scheduler
Add the following to your bootstrap/app.php:
use BeeCoded\EFactura\Jobs\ProcessPendingUploads; use BeeCoded\EFactura\Jobs\CheckUploadStatuses; use BeeCoded\EFactura\Jobs\DownloadResponses; use BeeCoded\EFactura\Jobs\DownloadReceivedInvoices; use BeeCoded\EFactura\Jobs\SyncMessages; use BeeCoded\EFactura\Jobs\RetryRateLimitedUploads; ->withSchedule(function (Schedule $schedule): void { // Upload pending invoices to ANAF $schedule->job(new ProcessPendingUploads)->everyFiveMinutes(); // Check processing status at ANAF $schedule->job(new CheckUploadStatuses)->everyTenMinutes(); // Download response ZIPs for completed uploads $schedule->job(new DownloadResponses)->everyFifteenMinutes(); // Retry uploads that failed due to rate limiting $schedule->job(new RetryRateLimitedUploads)->everyTenMinutes(); // Download received invoices (if feature enabled) $schedule->job(new DownloadReceivedInvoices)->everyFourHours(); // Sync message list from ANAF $schedule->job(new SyncMessages)->hourly(); })
Adjust the schedules to fit your application's needs. All jobs accept an optional $cui parameter to process only a specific CUI:
Schedule::job(new ProcessPendingUploads('12345678'))->everyFiveMinutes();
Dispatch a Single Upload or Status Check
For immediate processing of a single upload, dispatch the single-model jobs:
use BeeCoded\EFactura\Jobs\ProcessSingleUpload; use BeeCoded\EFactura\Jobs\CheckSingleUploadStatus; // Queue and dispatch a single upload immediately $upload = EFactura::queueUpload($invoice); ProcessSingleUpload::dispatch($upload); // Check status for a specific upload CheckSingleUploadStatus::dispatch($upload);
Available Jobs
Batch Jobs (Scheduled)
| Job | Purpose | Suggested Schedule |
|---|---|---|
ProcessPendingUploads |
Upload all pending invoices to ANAF | Every 5 minutes |
CheckUploadStatuses |
Check processing status at ANAF | Every 10 minutes |
DownloadResponses |
Download response ZIPs | Every 15 minutes |
DownloadReceivedInvoices |
Download received invoices | Every 4 hours |
SyncMessages |
Sync message list from ANAF | Every hour |
RetryRateLimitedUploads |
Reset rate-limited failures back to pending | Every 10 minutes |
Single-Model Jobs (On-Demand)
| Job | Purpose |
|---|---|
ProcessSingleUpload |
Process a single upload immediately |
CheckSingleUploadStatus |
Check status for a single upload |
Job Configuration
Standard jobs (batch processing, status checks, downloads):
- Tries: 3
- Timeout: 120 seconds
- Backoff: 60s, 180s, 300s (progressive)
Upload jobs (ProcessSingleUpload) have rate-limit-aware retry:
- Timeout: 120 seconds
- Max Exceptions: 3 (actual errors only — rate-limit releases don't count)
- Retry Window: 24 hours (configurable via
EFACTURA_RATE_LIMIT_RETRY_HOURS) - When the SDK's global rate limit quota is exhausted, the job releases itself back to the queue with a delay matching the quota reset time, instead of failing
- If a race condition causes a rate-limit failure during upload, the job resets the upload to pending and releases with a 60-second delay
Queue Configuration
Jobs are dispatched to the queue specified in config/efactura.php:
'queue' => env('EFACTURA_QUEUE', null), // null = default queue
Set EFACTURA_QUEUE=efactura in your .env to use a dedicated queue. This allows you to run a separate worker for e-Factura jobs:
php artisan queue:work --queue=efactura
Rate Limit Configuration
The SDK provides client-side rate limiting to stay within ANAF quotas. Upload
jobs are rate-limit-aware and will delay instead of failing when quotas are
exhausted. For uploads that do fail due to rate limiting (e.g., race conditions),
schedule RetryRateLimitedUploads to automatically reset them.
# How long upload jobs can keep retrying (hours, default: 24) EFACTURA_RATE_LIMIT_RETRY_HOURS=24 # Max failed uploads to reset per retry run (default: 250) EFACTURA_RATE_LIMIT_RETRY_BATCH=250 # Don't retry uploads older than this many days (default: 7) EFACTURA_RATE_LIMIT_RETRY_MAX_DAYS=7
Queue Worker
Ensure your queue worker is running:
php artisan queue:work
Artisan Commands
# Display OAuth URL for a CUI php artisan efactura:auth 12345678 # Process pending uploads php artisan efactura:upload php artisan efactura:upload --cui=12345678 # Check statuses and download responses php artisan efactura:status php artisan efactura:status --cui=12345678 # Sync messages from ANAF php artisan efactura:sync php artisan efactura:sync --cui=12345678
Events
Listen to these events for custom logic:
| Event | Fired When | Payload |
|---|---|---|
TokenStored |
OAuth callback stores new token | EfacturaToken $token |
TokenRefreshed |
Token auto-refreshed | EfacturaToken $token |
InvoiceUploaded |
Invoice successfully uploaded | EfacturaUpload $upload |
InvoiceProcessed |
Response downloaded (success) | EfacturaUpload $upload |
InvoiceFailed |
Upload or processing failed | EfacturaUpload $upload, array $errors |
InvoiceReceived |
Received invoice downloaded | EfacturaMessage $message |
Example Listener
<?php namespace App\Listeners; use BeeCoded\EFactura\Events\InvoiceFailed; use App\Notifications\EfacturaFailedNotification; class HandleFailedInvoice { public function handle(InvoiceFailed $event): void { $upload = $event->upload; $errors = $event->errors; // Get the original invoice $invoice = $upload->uploadable; // Notify someone $invoice->company->owner->notify( new EfacturaFailedNotification($invoice, $errors) ); } }
Database Schema
The package creates three tables:
efactura_tokens
Stores OAuth tokens per CUI.
efactura_uploads
Tracks upload status for your invoices (polymorphic relationship).
efactura_messages
Stores synced messages from ANAF (sent, received, errors).
Upload Status Flow
Pending → Uploading → Processing → Completed
↘ ↘
→ Failed ←────┘
- Pending: Queued, waiting to be uploaded
- Uploading: Currently being uploaded to ANAF
- Processing: Uploaded, waiting for ANAF to process
- Completed: Successfully processed, response available
- Failed: Upload or processing failed
Logging
The SDK logs all API calls to a dedicated logging channel. Add the following channel to your config/logging.php:
'channels' => [ // ... other channels 'efactura-sdk' => [ 'driver' => 'daily', 'path' => storage_path('logs/efactura-sdk.log'), 'level' => 'debug', 'days' => 30, ], ],
You can customize the channel name via the EFACTURA_LOG_CHANNEL environment variable (defaults to efactura-sdk).
Testing
For testing, use the SDK's sandbox mode:
EFACTURA_SANDBOX=true
Troubleshooting
Token Not Found
Ensure you've completed the OAuth flow for the CUI:
php artisan efactura:auth 12345678
Jobs Not Running
- Verify the scheduler is registered
- Check
config('efactura.enabled')istrue - Check feature flags are enabled
- Review Laravel queue worker logs
Upload Failures
Check the errors column in efactura_uploads table or listen to the InvoiceFailed event.
AI Assistant Integration (MCP)
This package and its SDK dependency both include MCP servers that help AI coding assistants understand the full e-Factura integration.
Setup: Add both to your AI tool's MCP configuration:
{
"mcpServers": {
"efactura-sdk": {
"command": "node",
"args": ["vendor/bee-coded/laravel-efactura-sdk/mcp/dist/index.js"]
},
"efactura": {
"command": "node",
"args": ["vendor/bee-coded/laravel-efactura/mcp/dist/index.js"]
}
}
}
Requires Node.js 18+.
The wrapper MCP server provides these tools:
| Tool | Description |
|---|---|
get-wrapper-docs |
Documentation topics: overview, setup, upload-pipeline, token-management, commands |
get-model-integration-guide |
Complete guide for integrating a model with e-Factura |
get-event-reference |
All events: TokenStored, TokenRefreshed, InvoiceUploaded, InvoiceProcessed, InvoiceFailed, InvoiceReceived |
get-job-reference |
All 8 background jobs with scheduling guidance |
get-wrapper-config-reference |
Full configuration schema with env vars and defaults |
The SDK MCP server (efactura-sdk) provides DTOs, enums, API reference, and SDK-level documentation.
License
This package is open-sourced software licensed under the Apache 2.0 License.