bee-coded/laravel-efactura-sdk

Laravel SDK for Romania's ANAF e-Factura electronic invoicing system

Maintainers

Package info

github.com/BEE-CODED/laravel-efactura-sdk

pkg:composer/bee-coded/laravel-efactura-sdk

Transparency log

Statistics

Installs: 139

Dependents: 1

Suggesters: 0

Stars: 3

Open Issues: 0

v3.1.1 2026-07-17 17:49 UTC

README

A Laravel package for integrating with Romania's ANAF e-Factura (electronic invoicing) system.

Features

  • OAuth 2.0 Authentication - Complete OAuth flow with JWT tokens and automatic token refresh
  • Document Operations - Upload, download, and check status of invoices
  • UBL 2.1 XML Generation - Generate CIUS-RO compliant invoice XML
  • Company Lookup - Query ANAF for company details (VAT status, addresses, etc.)
  • Validation - Validate XML against ANAF schemas before upload
  • PDF Conversion - Convert XML invoices to PDF format
  • Rate Limiting - Built-in protection against exceeding ANAF API quotas

Requirements

  • PHP 8.4+
  • Laravel 11.0+
  • Valid ANAF OAuth credentials

Installation

composer require bee-coded/laravel-efactura-sdk

Publish the configuration file:

php artisan vendor:publish --tag=efactura-sdk-config

Migrating from v2 to v3

v3.0.0 is a major release with breaking changes. Some fail loudly; several are silent and change the document you file with ANAF. Work through the steps in order.

Using an AI assistant? The MCP server exposes this as a full topic with every exception message and code change: get-sdk-docs with topic: "migration-v2-v3". See AI Assistant Integration (MCP).

1. Facade alias rename (loud)

The auto-discovered alias EFacturaSdkBeeCoded\EFacturaSdk\Facades\EFactura pointed at a class that never existed, so it was always broken. It is now EFacturaSdkAuthBeeCoded\EFacturaSdk\Facades\EFacturaSdkAuth. Delete any hand-written alias for the old name and import the facade directly.

2. PartyData::$isVatPayer is now required — and moved (loud, or silently wrong)

It lost its false default and moved from 5th to 4th constructor position, ahead of $registrationNumber. A default let a caller who simply forgot the flag file a VAT-registered company as not subject to VAT — a document ANAF accepts, with no error to notice.

$supplier = new PartyData(
    registrationName: 'Acme SRL',
    companyId: '49296198',
    address: $address,
    isVatPayer: true,                // ← REQUIRED, on BOTH supplier and customer
    registrationNumber: 'J40/1234/2020',
);

Every shape of the old call now fails loudly. Omitting the flag raises ArgumentCountError (direct construction), CannotCreateData (::from()), or The is vat payer field is required. (::validate()).

A positional v2 call passes $registrationNumber into $isVatPayer, and that was the dangerous case. In a file without declare(strict_types=1) — the Laravel app default — the ONRC string coerced to isVatPayer = true, silently flipping a non-VAT-payer supplier to a VAT payer: every line moved from VAT category O to Z, and the party gained a BT-31 seller VAT id it does not hold. The document stayed internally consistent, so ANAF accepted and filed it. v2 defaulted this flag to false, so the population most exposed is exactly the one that relied on that default: Romanian non-VAT-payers. $isVatPayer is therefore typed bool|string and rejects a string:

new PartyData('Acme SRL', '49296198', $address, 'J40/1234/2020');
// InvalidArgumentException: PartyData::$isVatPayer must be a bool, received the string
// "J40/1234/2020". Note that v3 moved $isVatPayer from the 5th constructor position to the
// 4th and removed its `false` default...

Only true, false, 1, 0, '1' and '0' are accepted — exactly what Laravel's boolean rule accepts, so ::from() and ::validateAndCreate() payloads are unaffected. Grep for new PartyData( and convert every call to named arguments.

3. Non-RON invoices must declare taxAmountRon (loud)

InvoiceData gained an optional trailing ?float $taxAmountRon (BT-111) that is required when currency !== 'RON' and rejected when it is — otherwise ValidationException.

v2 emitted the document-currency amount unchanged under currencyID="RON": a EUR invoice with 190.00 EUR of VAT filed 190.00 RON instead of ~945. ANAF cannot verify a conversion, so it was accepted and filed. Pass the converted amount (not a rate — the rate is never transmitted):

$invoice = new InvoiceData(
    // ...
    currency: 'EUR',
    taxAmountRon: 944.30,   // your ledger's BNR-rate figure for the VAT total
);

RON-only apps need no change.

4. A non-VAT-payer supplier may not charge VAT (loud)

Every line must be taxPercent: 0, taxAmount: 0.0, else ValidationException: Line N: A supplier that is not registered for VAT cannot charge VAT (BR-O-09).

5. XML output changes (silent — regenerate and diff)

Change v2 v3
Line cbc:Percent under category O emitted → ANAF rejected (BR-O-05) omitted (document-level TaxSubtotal keeps it)
Customer PartyTaxScheme, non-VAT supplier emitted → rejected (BR-O-02) suppressed
quantity / PriceAmount decimals always 2 — 1.3751.38, 0.00750.01 2–6 — filed exactly
Credit-note dueDate dropped entirely filed as PaymentMeans/PaymentDueDate
PaymentMeans emitted for IBAN only due date or IBAN
Greek/NI VAT ids EL123456789GREL123456789 recognised, left alone

If you worked around the 2-decimal truncation or the double-prefixing, remove the workaround — it will now double-apply.

6. Total helpers change at sub-cent boundaries (silent)

getTotalExcludingVat(), getTotalVat() and getTotalIncludingVat() now reproduce the filed XML exactly (per-line rounding, and per-tax-group rounding respectively). Only sub-cent inputs move: two lines of 0.5 × 0.01 file as 0.02, where v2's helper returned 0.01. Re-reconcile any invoice with sub-cent line amounts.

The credit-note sign convention is unchanged: the helpers report the positive sense the lines are supplied in, while the filed XML states the negation. Do not "fix" this by negating.

7. Runtime behaviour (silent — bites in production)

  • Uploads no longer auto-retry on 5xx, read timeouts, or unclassifiable transport errors. ANAF mints a fresh index_incarcare per accepted POST, so a blind retry files the invoice twice. A failed upload is not proof the invoice was not filed — remove blind retries and reconcile via getMessages() before re-sending. Reads still retry; only pre-send errors (DNS/connect/TLS) retry an upload.
  • Each retry consumes global rate-limit quota, so a retrying read can throw RateLimitExceededException where v2 raised ApiException. It does not extend ApiException — catch it first.
  • downloadDocument() throws ApiException on non-ZIP bodies. ANAF reports some errors as 200 + JSON; v2 handed that back as a "successful" download that wrote JSON into a .zip.
  • getCurrentDateForAnaf() / getDaysAgo() now resolve in Europe/Bucharest (new constant DateHelper::ANAF_TIMEZONE). On a UTC server between midnight and 02:00/03:00 Bucharest, v2 reported yesterday. toTimestamp() / getDayRange() still honour your timezone — you supply the date there.
  • AnafDetailsClient now honours http.retry_times / http.retry_delay (it ignored them before). If you tuned those keys, lookups now retry differently.

8. Additive — adopt if relevant

  • EFacturaClient::__construct() / ::fromTokens() gained an optional ?Closure $tokenReloader. ANAF rotates refresh tokens; in multi-worker deployments the worker that loses the refresh race otherwise spends a dead token. Recommended if you run more than one worker.
  • RateLimiter::getRemainingQuota('company_lookup') — new single-bucket arm, takes no identifier.
  • validateXml() / verifySignature() now populate $result->errors from ANAF's Messages[].message and $result->info from trace_id. Both previously came back null.
  • BaseApiClient::call() / callRaw() gained bool $idempotent = true before $tryCount — only relevant if you subclass, but a positional $tryCount now lands in $idempotent.

Not a v3 change: nesbot/carbon was already in require; taxAmount became required on InvoiceLineData back in v2.0; CompanyData::$isVatPayer (the lookup DTO) still defaults to false.

Configuration

Add the following to your .env file:

EFACTURA_SANDBOX=true
EFACTURA_CLIENT_ID=your-client-id
EFACTURA_CLIENT_SECRET=your-client-secret
EFACTURA_REDIRECT_URI=https://your-app.com/efactura/callback

Configuration Options

The keys you are most likely to change (excerpt — see below for the rest):

// config/efactura-sdk.php
return [
    'sandbox' => env('EFACTURA_SANDBOX', true),

    'oauth' => [
        'client_id' => env('EFACTURA_CLIENT_ID'),
        'client_secret' => env('EFACTURA_CLIENT_SECRET'),
        'redirect_uri' => env('EFACTURA_REDIRECT_URI'),
    ],

    'http' => [
        'timeout' => env('EFACTURA_TIMEOUT', 30),
        // Read by EFacturaClient and AnafDetailsClient alike (since v3.0.0).
        // Governs READS only — uploads do not auto-retry, see "Migrating from v2 to v3".
        'retry_times' => env('EFACTURA_RETRY_TIMES', 3),
        'retry_delay' => env('EFACTURA_RETRY_DELAY', 5),
    ],

    'logging' => [
        'channel' => env('EFACTURA_LOG_CHANNEL', 'efactura-sdk'),
        'debug' => env('EFACTURA_DEBUG', false), // logs full request/response bodies
    ],

    // ... plus 'endpoints' (ANAF base URLs — rarely changed) and 'rate_limits'
    // (documented under "Rate Limiting Configuration" below).
];

Publish the file to see every key with its comments:

php artisan vendor:publish --tag=efactura-sdk-config

Logging Channel (Recommended)

Add a dedicated logging channel in config/logging.php:

'efactura-sdk' => [
    'driver' => 'daily',
    'path' => storage_path('logs/efactura-sdk.log'),
    'level' => 'debug',
    'days' => 30,
],

Rate Limiting Configuration

The SDK includes built-in rate limiting to prevent exceeding ANAF API quotas. Most defaults are set to 50% of ANAF's actual limits for safety. The one exception is company_lookup_per_second, which defaults to 1 — 100% of ANAF's 1 request/second cap, since that limit admits no lower positive value.

# Enable/disable rate limiting (default: true)
EFACTURA_RATE_LIMIT_ENABLED=true

# Global API calls per minute (ANAF limit: 1000, default: 500)
EFACTURA_RATE_LIMIT_GLOBAL=500

# RASP file uploads per CUI per day (ANAF limit: 1000, default: 500)
EFACTURA_RATE_LIMIT_RASP_UPLOAD=500

# Status queries per message per day (ANAF limit: 100, default: 50)
EFACTURA_RATE_LIMIT_STATUS=50

# Simple list queries per CUI per day (ANAF limit: 1500, default: 750)
EFACTURA_RATE_LIMIT_SIMPLE_LIST=750

# Paginated list queries per CUI per day (ANAF limit: 100,000, default: 50,000)
EFACTURA_RATE_LIMIT_PAGINATED_LIST=50000

# Downloads per message per day (ANAF limit: 10, default: 5)
EFACTURA_RATE_LIMIT_DOWNLOAD=5

# Company lookups per second (ANAF limit: 1, default: 1 — per request, not per CUI)
EFACTURA_RATE_LIMIT_COMPANY_LOOKUP=1

ANAF Official Rate Limits:

Endpoint ANAF Limit SDK Default Scope
Global (all methods) 1,000/minute 500/minute All API calls
/upload (RASP) 1,000/day 500/day Per CUI
/stare (status) 100/day 50/day Per message ID
/lista (simple) 1,500/day 750/day Per CUI
/lista (paginated) 100,000/day 50,000/day Per CUI
/descarcare (download) 10/day 5/day Per message ID
PlatitorTvaRest (company lookup) 1/second 1/second Per request (global, not per CUI)

Usage

OAuth Authentication Flow

The SDK provides a stateless OAuth implementation. You are responsible for storing tokens in your database.

Step 1: Redirect User to ANAF Authorization

use BeeCoded\EFacturaSdk\Data\Auth\AuthUrlSettingsData;
use BeeCoded\EFacturaSdk\Facades\EFacturaSdkAuth;

// Generate authorization URL
$authUrl = EFacturaSdkAuth::getAuthorizationUrl();

// Or with custom state data
$authUrl = EFacturaSdkAuth::getAuthorizationUrl(new AuthUrlSettingsData(
    state: ['company_id' => 123, 'user_id' => 456],
    scope: 'custom-scope',
));

return redirect($authUrl);

Step 2: Handle OAuth Callback

use BeeCoded\EFacturaSdk\Facades\EFacturaSdkAuth;

public function handleCallback(Request $request)
{
    $code = $request->get('code');

    // Exchange authorization code for tokens
    $tokens = EFacturaSdkAuth::exchangeCodeForToken($code);

    // Store tokens in YOUR database
    YourTokenModel::create([
        'company_id' => $companyId,
        'access_token' => $tokens->accessToken,
        'refresh_token' => $tokens->refreshToken,
        'expires_at' => $tokens->expiresAt,
    ]);
}

Manual Token Refresh

use BeeCoded\EFacturaSdk\Facades\EFacturaSdkAuth;

$newTokens = EFacturaSdkAuth::refreshAccessToken($storedRefreshToken);

// Update stored tokens
$tokenModel->update([
    'access_token' => $newTokens->accessToken,
    'refresh_token' => $newTokens->refreshToken,
    'expires_at' => $newTokens->expiresAt,
]);

API Operations

Creating the Client

use BeeCoded\EFacturaSdk\Services\ApiClients\EFacturaClient;
use BeeCoded\EFacturaSdk\Data\Auth\OAuthTokensData;

// Retrieve your stored tokens
$storedTokens = YourTokenModel::where('company_id', $companyId)->first();

// Create tokens DTO
$tokens = new OAuthTokensData(
    accessToken: $storedTokens->access_token,
    refreshToken: $storedTokens->refresh_token,
    expiresAt: $storedTokens->expires_at,
);

// Create client
$client = EFacturaClient::fromTokens($vatNumber, $tokens);

Upload Invoice

use BeeCoded\EFacturaSdk\Data\Invoice\UploadOptionsData;
use BeeCoded\EFacturaSdk\Enums\StandardType;

// Basic upload
$result = $client->uploadDocument($xmlContent);

// With options
$result = $client->uploadDocument($xmlContent, new UploadOptionsData(
    standard: StandardType::UBL,
    extern: false,      // External invoice (non-Romanian supplier)
    selfBilled: false,  // Self-billed invoice (autofactura)
));

// B2C upload (to consumers)
$result = $client->uploadB2CDocument($xmlContent);

// Check result
if ($result->isSuccessful()) {
    $uploadId = $result->indexIncarcare;
    // Store uploadId for status checking
}

Check Processing Status

$status = $client->getStatusMessage($uploadId);

if ($status->isReady()) {
    $downloadId = $status->idDescarcare;
    // Document is ready for download
} elseif ($status->isInProgress()) {
    // Still processing, check again later
} elseif ($status->isFailed()) {
    // Processing failed
    $errors = $status->errors;
}

Download Document

$download = $client->downloadDocument($downloadId);

// Save to file
$download->saveTo('/path/to/invoice.zip');

// Or get content directly
$zipContent = $download->content;
$contentType = $download->contentType;

List Messages

use BeeCoded\EFacturaSdk\Data\Invoice\ListMessagesParamsData;
use BeeCoded\EFacturaSdk\Enums\MessageFilter;

// List messages from last 30 days
$messages = $client->getMessages(new ListMessagesParamsData(
    cif: '12345678',
    days: 30,  // 1-60 days allowed
    filter: MessageFilter::InvoiceSent,  // Optional: T, P, E, R
));

foreach ($messages->mesaje as $message) {
    echo $message->id;
    echo $message->dataCreare;
    echo $message->tip;
}

Paginated Messages

use BeeCoded\EFacturaSdk\Data\Invoice\PaginatedMessagesParamsData;

// Using timestamps (milliseconds)
$messages = $client->getMessagesPaginated(new PaginatedMessagesParamsData(
    cif: '12345678',
    startTime: $startTimestampMs,
    endTime: $endTimestampMs,
    page: 1,
    filter: MessageFilter::InvoiceReceived,
));

// Or create from Carbon dates (mutable or immutable)
$messages = $client->getMessagesPaginated(
    PaginatedMessagesParamsData::fromDateRange(
        cif: '12345678',
        startDate: now()->subDays(30),
        endDate: now(),
        page: 1,
    )
);

// Pagination info
$messages->totalPages;
$messages->totalRecords;
$messages->currentPage;
$messages->hasNextPage();

Validate XML

use BeeCoded\EFacturaSdk\Enums\DocumentStandardType;

$validation = $client->validateXml($xmlContent, DocumentStandardType::FACT1);

if ($validation->valid) {
    // XML is valid
} else {
    // Validation errors
    $errors = $validation->errors;
    $details = $validation->details;
}

Convert to PDF

use BeeCoded\EFacturaSdk\Enums\DocumentStandardType;

// Convert without validation
$pdfContent = $client->convertXmlToPdf($xmlContent, DocumentStandardType::FACT1);

// Convert with validation first
$pdfContent = $client->convertXmlToPdf($xmlContent, DocumentStandardType::FACT1, validate: true);

file_put_contents('invoice.pdf', $pdfContent);

Verify Signature

$result = $client->verifySignature($signedXmlContent);

if ($result->valid) {
    // Signature is valid
}

Automatic Token Refresh

The SDK automatically refreshes tokens when they're about to expire (120-second buffer before expiration).

Important: ANAF uses rotating refresh tokens. When a token is refreshed, both the access token AND refresh token are replaced. The old refresh token becomes invalid.

$client = EFacturaClient::fromTokens($vatNumber, $tokens);

// Make API calls
$result = $client->uploadDocument($xml);
$status = $client->getStatusMessage($uploadId);

// IMPORTANT: Check if tokens were refreshed
if ($client->wasTokenRefreshed()) {
    $newTokens = $client->getTokens();

    // You MUST persist ALL new token values
    $storedTokens->update([
        'access_token' => $newTokens->accessToken,
        'refresh_token' => $newTokens->refreshToken,  // Critical! Old one is now invalid
        'expires_at' => $newTokens->expiresAt,
    ]);
}

Recommended Pattern:

public function uploadInvoice(string $xml, Company $company): UploadResponseData
{
    $tokens = $this->getTokensForCompany($company);
    $client = EFacturaClient::fromTokens($company->vat_number, $tokens);

    try {
        $result = $client->uploadDocument($xml);

        return $result;
    } finally {
        // Always check for token refresh, even on errors
        if ($client->wasTokenRefreshed()) {
            $this->persistTokens($company, $client->getTokens());
        }
    }
}

Rate Limiting

The SDK automatically enforces rate limits before each API call. When a limit is exceeded, a RateLimitExceededException is thrown.

use BeeCoded\EFacturaSdk\Exceptions\RateLimitExceededException;

try {
    $result = $client->uploadDocument($xml);
} catch (RateLimitExceededException $e) {
    // Rate limit exceeded
    $remaining = $e->remaining;              // 0 (no calls remaining)
    $retryAfter = $e->retryAfterSeconds;     // Seconds until reset
    $message = $e->getMessage();             // Human-readable message

    // Wait and retry, or queue for later
    Log::warning("Rate limit hit: {$message}. Retry in {$retryAfter}s");
}

Checking Remaining Quota

Before making API calls, you can check remaining quota:

$rateLimiter = $client->getRateLimiter();

// Check global limit (per minute)
$globalQuota = $rateLimiter->getRemainingQuota('global');
// ['limit' => 500, 'remaining' => 485, 'resetsIn' => 45]  // seconds until reset

// Check per-CUI limits
$listQuota = $rateLimiter->getRemainingQuota('simple_list', $vatNumber);
// ['limit' => 750, 'remaining' => 742, 'resetsIn' => 43200]  // seconds until reset

// Check per-message limits
$statusQuota = $rateLimiter->getRemainingQuota('status', $uploadId);
// ['limit' => 50, 'remaining' => 48, 'resetsIn' => 86400]

$downloadQuota = $rateLimiter->getRemainingQuota('download', $downloadId);
// ['limit' => 5, 'remaining' => 3, 'resetsIn' => 86400]

Disabling Rate Limiting

For testing or special cases, you can disable rate limiting:

EFACTURA_RATE_LIMIT_ENABLED=false

Or check status in code:

$rateLimiter = app(\BeeCoded\EFacturaSdk\Services\RateLimiter::class);

if ($rateLimiter->isEnabled()) {
    // Rate limiting is active
}

Generating Invoice XML

Using the UBL Builder

use BeeCoded\EFacturaSdk\Facades\UblBuilder;
use BeeCoded\EFacturaSdk\Data\Invoice\InvoiceData;
use BeeCoded\EFacturaSdk\Data\Invoice\PartyData;
use BeeCoded\EFacturaSdk\Data\Invoice\AddressData;
use BeeCoded\EFacturaSdk\Data\Invoice\InvoiceLineData;

$invoice = new InvoiceData(
    invoiceNumber: 'INV-2024-001',
    issueDate: now(),
    dueDate: now()->addDays(30),
    currency: 'RON',
    paymentIban: 'RO49AAAA1B31007593840000',

    supplier: new PartyData(
        registrationName: 'Supplier Company SRL',
        companyId: 'RO12345678',
        address: new AddressData(
            street: 'Str. Exemplu Nr. 1',
            city: 'Bucuresti',
            postalZone: '010101',
            county: 'Sector 1',  // Auto-sanitized to RO-B format
        ),
        registrationNumber: 'J40/1234/2020',
        isVatPayer: true,
    ),

    customer: new PartyData(
        registrationName: 'Customer Company SRL',
        companyId: 'RO87654321',
        address: new AddressData(
            street: 'Str. Client Nr. 2',
            city: 'Cluj-Napoca',
            postalZone: '400001',
            county: 'Cluj',  // Auto-sanitized to RO-CJ
        ),
        isVatPayer: true,
    ),

    lines: [
        new InvoiceLineData(
            name: 'Servicii consultanta',
            quantity: 10,
            unitPrice: 100.00,
            taxAmount: 190.00,   // Pre-computed: 10 * 100.00 * 0.19
            taxPercent: 19,
            unitCode: 'HUR',     // Hours
            description: 'Consultanta IT luna ianuarie',
        ),
        new InvoiceLineData(
            name: 'Licenta software',
            quantity: 1,
            unitPrice: 500.00,
            taxAmount: 95.00,    // Pre-computed: 1 * 500.00 * 0.19
            taxPercent: 19,
            unitCode: 'C62',     // Each
        ),
    ],
);

// Generate UBL 2.1 XML
$xml = UblBuilder::generateInvoiceXml($invoice);

Creating a Credit Note

use BeeCoded\EFacturaSdk\Enums\InvoiceTypeCode;

$creditNote = new InvoiceData(
    invoiceNumber: 'CN-2024-001',
    issueDate: now(),
    currency: 'RON',
    invoiceTypeCode: InvoiceTypeCode::CreditNote,
    precedingInvoiceNumber: 'INV-2024-001',  // BT-25: reference to the original invoice

    supplier: $supplier,
    customer: $customer,

    lines: [
        new InvoiceLineData(
            name: 'Returned product',
            quantity: -2,       // Negative = items being credited/returned
            unitPrice: 100.00,
            taxAmount: -38.00,  // Negative — sign follows quantity
            taxPercent: 19,
        ),
    ],
);

$xml = UblBuilder::generateInvoiceXml($creditNote);
Credit Note Quantity Handling (Breaking Change in v1.1)

The SDK automatically negates quantities for credit notes. ANAF treats the <CreditNote> document type as inherently negative, so line quantities must be positive in the XML. The SDK handles this sign-flip internally.

How it works: pass quantities with their business meaning, and the SDK converts them for ANAF:

You pass SDK sends to ANAF Meaning
quantity: -2 +2 Crediting 2 returned items
quantity: 1 -1 Debiting back a discount line

Example — credit note with a discount reversal:

$creditNote = new InvoiceData(
    invoiceNumber: 'CN-2024-002',
    issueDate: now(),
    currency: 'RON',
    invoiceTypeCode: InvoiceTypeCode::CreditNote,
    precedingInvoiceNumber: 'INV-2024-050',

    supplier: $supplier,
    customer: $customer,

    lines: [
        // Crediting 3 returned items (negative → becomes positive for ANAF)
        new InvoiceLineData(
            name: 'Returned product',
            quantity: -3,
            unitPrice: 150.00,
            taxAmount: -85.50,  // Pre-computed: -3 * 150.00 * 0.19 — sign follows quantity
            taxPercent: 19,
        ),
        // Reversing a discount that was on the original invoice (positive → becomes negative for ANAF)
        new InvoiceLineData(
            name: 'Discount reversal',
            quantity: 1,
            unitPrice: 50.00,
            taxAmount: 9.50,    // Pre-computed: 1 * 50.00 * 0.19
            taxPercent: 19,
        ),
    ],
);

$xml = UblBuilder::generateInvoiceXml($creditNote);

Upgrading from v1.0: If your code was passing positive quantities for credit note lines and relying on them going to ANAF as-is, you must now pass negative quantities instead (the SDK will negate them to positive). If you were already passing negative quantities (as documented), no changes are needed — the SDK now correctly converts them for ANAF.

Invoice Calculations

// Line-level calculations
$line = new InvoiceLineData(
    name: 'Product',
    quantity: 5,
    unitPrice: 100.00,
    taxAmount: 95.00,  // Pre-computed VAT for this line
    taxPercent: 19,
);

$line->getLineTotal();        // 500.00 (quantity * unitPrice)
$line->getTaxAmount();        // 95.00 (returns the pre-computed taxAmount)
$line->getLineTotalWithTax(); // 595.00

// Invoice-level calculations. These reproduce the filed XML totals exactly:
// getTotalExcludingVat() sums per-line ROUNDED net amounts (as cac:LegalMonetaryTotal does),
// and getTotalVat() rounds once per tax-rate group (as the cac:TaxSubtotal breakdown does).
$invoice->getTotalExcludingVat(); // Sum of all line totals
$invoice->getTotalVat();          // Sum of all per-line taxAmount values
$invoice->getTotalIncludingVat(); // Total with VAT

Why taxAmount is Required (Breaking Change in v2.0)

In v1.x, the SDK calculated VAT amounts internally by grouping lines by tax rate and multiplying sum_of_base_amounts × tax_rate. This caused rounding discrepancies when your application used tax-included pricing.

The problem:

When a line item has a tax-included price (e.g., 100.00 RON including 19% VAT), your application extracts the base price by subtraction:

base = round(100.00 / 1.19, 2) = 84.03
vat  = 100.00 - 84.03 = 15.97

But when the SDK grouped multiple such lines and recalculated VAT from the grouped base:

grouped_base = 84.03 + 84.03 = 168.06
grouped_vat  = round(168.06 × 0.19, 2) = 31.93

Your application computed 15.97 + 15.97 = 31.94. The SDK computed 31.93. This 0.01 RON difference meant the XML total sent to ANAF didn't match your local invoice total.

The fix:

Starting in v2.0, taxAmount is a required parameter on InvoiceLineData. You pass the VAT amount you already computed for each line, and the SDK uses it directly instead of recalculating. This guarantees the XML total matches your application's total exactly.

How to compute taxAmount:

Pricing model Formula Example
Tax-exclusive (net price) round(quantity × unitPrice × taxPercent / 100, 2) qty=2, price=100, 19% → 38.00
Tax-inclusive (gross price) grossTotal - round(grossTotal / (1 + taxPercent / 100), 2) gross=200, 19% → 200 - 168.07 = 31.93

The key rule: whatever VAT amount your application stores for the line item, pass that exact value as taxAmount. The SDK will use it as-is.

taxAmount sign convention:

The taxAmount sign must follow the quantity:

  • Positive quantity → positive taxAmount
  • Negative quantity (credit note lines) → negative taxAmount

The SDK's credit note sign-flip (negating quantities for ANAF) also applies to taxAmount internally — you don't need to handle this yourself.

Upgrading from v1.x: Add taxAmount to every new InvoiceLineData(...) call. If you were using net pricing (tax-exclusive unitPrice), compute it as round(round(quantity * unitPrice, 2) * taxPercent / 100, 2). If you were using tax-included pricing, pass the VAT amount you already extracted from the gross total.

Address Sanitization

Romanian addresses are automatically sanitized to ISO 3166-2:RO format:

// County names are normalized
'Cluj' -> 'RO-CJ'
'Judetul Cluj' -> 'RO-CJ'
'BUCURESTI' -> 'RO-B'

// Bucharest sectors are extracted
'Sector 3' -> 'RO-B' (with sector in address)
'Sectorul 1, Str. Exemplu' -> extracts sector

// Diacritics are handled
'Brașov' -> 'RO-BV'
'Constanța' -> 'RO-CT'

Company Lookup

Query ANAF for company information (no authentication required):

use BeeCoded\EFacturaSdk\Facades\AnafDetails;

// Single company lookup
$result = AnafDetails::getCompanyData('RO12345678');

if ($result->success) {
    $company = $result->first();

    echo $company->name;              // Company name
    echo $company->cui;               // CUI without RO prefix
    echo $company->getVatNumber();    // CUI with RO prefix
    echo $company->address;           // General address
    echo $company->registrationNumber; // J40/1234/2020

    // VAT status
    $company->isVatPayer;
    $company->vatRegistrationDate;
    $company->vatDeregistrationDate;

    // Special regimes
    $company->isSplitVat;      // Split VAT payment
    $company->isRtvai;         // VAT on collection

    // Status
    $company->isActive();      // Not inactive and not deregistered (radiat)
    $company->isInactive;
    $company->isDeregistered;

    // Trade-registry status (stare_inregistrare)
    $company->isRegistered();           // status is "INREGISTRAT"
    $company->registrationStatus;       // RegistrationStatus enum (Registered|Deregistered|Unknown)
    $company->registrationStatusRaw;    // e.g. "RADIERE din data 29.03.2024"
    $company->registrationStatusDate;   // Carbon date parsed from the status string
    $company->registrationDate;         // Fiscal registration date (data_inregistrare)

    // RO e-Factura registry
    $company->isRegisteredInEFactura;   // enrolled in Registrul RO e-Factura
    $company->eFacturaRegistrationDate;

    // VAT registration history
    $company->vatPeriods;               // VatPeriodData[] (perioade_TVA)

    // Detailed addresses
    $company->headquartersAddress;     // AddressData object
    $company->fiscalDomicileAddress;   // AddressData object
    $company->getPrimaryAddress();     // Returns headquarters or fiscal
}

// Batch lookup (up to 100 companies — ANAF v9 limit)
$result = AnafDetails::batchGetCompanyData([
    'RO12345678',
    'RO87654321',
    '11223344',  // RO prefix is optional
]);

foreach ($result->companies as $company) {
    // Process each company
}

// Check for not found
foreach ($result->notFound as $cui) {
    echo "Company not found: $cui";
}

// Validate VAT code format + mod-11 checksum
$isValid = AnafDetails::isValidVatCode('RO14399840'); // true
$isValid = AnafDetails::isValidVatCode('RO12345678'); // false — checksum fails

Note (v2.2.0): a lookup whose CUIs are all not-found now returns success === true with the CUIs in $result->notFound (previously a single not-found CUI returned a failure result). Branch on hasNotFound() / hasCompanies(), not on success, to distinguish outcomes. ANAF returns this case as HTTP 404 with a {found, notFound} body — the SDK treats that documented "none found" response as a not-found result, not an error.

Detecting radiated (struck-off) companies: ANAF reports trade-registry strike-off in date_generale.stare_inregistrare ("RADIERE din data ..."), which the SDK maps to registrationStatus, flips isDeregistered to true, and makes isActive() return false. This is distinct from fiscal inactivity (isInactive) and from leaving the TVA-la-încasare registry (rtvaiDetails->actType === 'Radiere').

Rate limit: ANAF limits the company-lookup endpoint to 1 request/second. The SDK enforces this and throws BeeCoded\EFacturaSdk\Exceptions\RateLimitExceededException (HTTP 429, with ->retryAfterSeconds) when exceeded. This is independent of the per-request payload cap of 100 CUIs. Disable via efactura-sdk.rate_limits.enabled = false.

Validators

VAT Number Validation

use BeeCoded\EFacturaSdk\Support\Validators\VatNumberValidator;

// isValid() checks format AND the mod-11 checksum
VatNumberValidator::isValid('RO14399840');  // true
VatNumberValidator::isValid('14399840');    // true (RO prefix is optional)
VatNumberValidator::isValid('RO12345678');  // false — well-formed, but the checksum fails
VatNumberValidator::isValid('invalid');     // false

// isValidFormat() skips the checksum (2-10 digits, optional RO prefix)
VatNumberValidator::isValidFormat('12345678'); // true

VatNumberValidator::normalize('12345678');  // 'RO12345678' (format-checked only, no checksum)
VatNumberValidator::stripPrefix('RO14399840'); // '14399840'

CNP Validation

use BeeCoded\EFacturaSdk\Support\Validators\CnpValidator;

CnpValidator::isValid('1800101221144'); // true (valid date + checksum)
CnpValidator::isValid('1234567890123'); // false (month '45' is not a valid date)
CnpValidator::isValid('0000000000000'); // true (special ANAF case)

Date Helpers

use BeeCoded\EFacturaSdk\Support\DateHelper;

// Format for ANAF API
DateHelper::formatForAnaf(now());           // '2024-01-15'
DateHelper::formatForAnaf('2024-01-15');    // '2024-01-15'

// Timestamps in milliseconds (for paginated messages)
DateHelper::toTimestamp(now());             // 1705312800000

// Day range for queries — returns a STRING-KEYED array: ['start' => int, 'end' => int]
['start' => $start, 'end' => $end] = DateHelper::getDayRange('2024-01-15');
// Boundaries are resolved in your app's timezone (config/app.php 'timezone'),
// so the exact millisecond values depend on it:
//
//   UTC (Laravel's default):  $start = 1705276800000, $end = 1705363199999
//   Europe/Bucharest:         $start = 1705269600000, $end = 1705355999999
//
// $start is 00:00:00.000 and $end is 23:59:59.999 of that local day either way.

// Validate days parameter
DateHelper::isValidDaysParameter(30);  // true (1-60 allowed)
DateHelper::isValidDaysParameter(100); // false

Immutable Dates (CarbonImmutable)

Since v2.3.0: the date fields you pass data into accept any Carbon\CarbonInterface implementation — see the list below.

Apps that call Date::use(CarbonImmutable::class) get a CarbonImmutable from every Eloquent datetime cast. CarbonImmutable is not a subclass of Carbon — both implement CarbonInterface — so before v2.3.0 passing a cast in either threw a TypeError (on the Carbon-only params) or, where the field also accepted a string and the caller had no declare(strict_types=1), was silently coerced to a timezone-less string via __toString(). You can now pass model casts straight in:

use BeeCoded\EFacturaSdk\Data\Auth\OAuthTokensData;
use BeeCoded\EFacturaSdk\Data\Invoice\InvoiceData;
use BeeCoded\EFacturaSdk\Data\Invoice\PaginatedMessagesParamsData;
use BeeCoded\EFacturaSdk\Support\DateHelper;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;

Date::use(CarbonImmutable::class);

new InvoiceData(
    invoiceNumber: $invoice->number,
    issueDate: $invoice->issued_at,   // CarbonImmutable — accepted
    dueDate: $invoice->due_at,
    // ...
);

new OAuthTokensData(
    accessToken: $token->access_token,
    refreshToken: $token->refresh_token,
    expiresAt: $token->expires_at,    // CarbonImmutable — accepted
);

DateHelper::formatForAnaf(CarbonImmutable::now());
PaginatedMessagesParamsData::fromDateRange(cif: '12345678', startDate: $from, endDate: $to);

Where it applies. These are the entry points that take a date from you:

Entry point Date parameters
OAuthTokensData::__construct $expiresAt
EFacturaClient::__construct $expiresAt
InvoiceData::__construct $issueDate, $dueDate
PaginatedMessagesParamsData::fromDateRange $startDate, $endDate
DateHelper::formatForAnaf, toTimestamp, getDayRange, daysBetween date arguments

The company-lookup DTOs (CompanyData, VatRegistrationData, SplitVatData, InactiveStatusData, VatPeriodData) are not included: they are built by the SDK from ANAF responses and never receive a date from you, so their date parameters remain ?Carbon.

What you get back is unchanged. An immutable date is converted to a mutable Carbon on the way in (timezone and microseconds preserved); a date that is already a mutable Carbon is stored as the same instance, and a string is left as a string. Reading never gives you a CarbonImmutable: $tokens->expiresAt is ?Carbon, InvoiceData::$issueDate is Carbon|string, and getIssueDateAsCarbon() returns a concrete Carbon.

Enums

StandardType

StandardType::UBL   // 'UBL' - UBL 2.1 format
StandardType::CN    // 'CN' - Credit Note
StandardType::CII   // 'CII' - Cross Industry Invoice
StandardType::RASP  // 'RASP' - Response

DocumentStandardType

DocumentStandardType::FACT1  // 'FACT1' - Invoice
DocumentStandardType::FCN    // 'FCN' - Credit Note

MessageFilter

MessageFilter::InvoiceSent     // 'T' - Sent invoices
MessageFilter::InvoiceReceived // 'P' - Received invoices
MessageFilter::InvoiceErrors   // 'E' - Errors
MessageFilter::BuyerMessage    // 'R' - Buyer messages

InvoiceTypeCode

Valid codes per ANAF BR-RO-020 schematron rule:

// Invoice document types (generates <Invoice> XML)
InvoiceTypeCode::CommercialInvoice  // '380' - Standard commercial invoice
InvoiceTypeCode::CorrectedInvoice   // '384' - Corrected invoice
InvoiceTypeCode::SelfBilledInvoice  // '389' - Self-billed invoice (autofactura)
InvoiceTypeCode::AccountingInvoice  // '751' - Invoice for accounting purposes

// Credit note (generates <CreditNote> XML)
InvoiceTypeCode::CreditNote         // '381' - Credit note

// Helper methods
$type->isCreditNote();  // true for 381
$type->isInvoice();     // true for 380, 384, 389, 751

Note: The SDK automatically generates the correct UBL document type. Code 381 generates a <CreditNote> document with <CreditNoteTypeCode> and <CreditNoteLine> elements, while all other codes generate an <Invoice> document.

Exception Handling

use BeeCoded\EFacturaSdk\Exceptions\AuthenticationException;
use BeeCoded\EFacturaSdk\Exceptions\ValidationException;
use BeeCoded\EFacturaSdk\Exceptions\ApiException;
use BeeCoded\EFacturaSdk\Exceptions\RateLimitExceededException;
use BeeCoded\EFacturaSdk\Exceptions\XmlParsingException;

try {
    $result = $client->uploadDocument($xml);
} catch (AuthenticationException $e) {
    // OAuth token invalid or expired (and refresh failed)
    // User needs to re-authenticate
} catch (RateLimitExceededException $e) {
    // Rate limit exceeded
    $retryAfter = $e->retryAfterSeconds;  // Seconds until limit resets
    // Queue for later or wait
} catch (ValidationException $e) {
    // Input validation failed (empty XML, invalid parameters)
    $message = $e->getMessage();
} catch (ApiException $e) {
    // API call failed
    $statusCode = $e->statusCode;
    $details = $e->details;
} catch (XmlParsingException $e) {
    // Failed to parse XML response from ANAF
}

Testing

When testing your application, you can mock the SDK services:

use BeeCoded\EFacturaSdk\Contracts\AnafAuthenticatorInterface;
use BeeCoded\EFacturaSdk\Contracts\AnafDetailsClientInterface;

// In your test
$this->mock(AnafAuthenticatorInterface::class, function ($mock) {
    $mock->shouldReceive('exchangeCodeForToken')
        ->andReturn(new OAuthTokensData(
            accessToken: 'test-token',
            refreshToken: 'test-refresh',
            expiresAt: now()->addHour(),
        ));
});

AI Assistant Integration (MCP)

This package includes an MCP server that helps AI coding assistants understand the SDK's DTOs, API methods, and conventions.

Setup: Add to your AI tool's MCP configuration:

{
  "mcpServers": {
    "efactura-sdk": {
      "command": "node",
      "args": ["vendor/bee-coded/laravel-efactura-sdk/mcp/dist/index.js"]
    }
  }
}

Requires Node.js 18+.

The MCP server provides these tools:

Tool Description
get-sdk-docs Documentation for topics: overview, invoice-flow, credit-notes, tax-calculation, oauth-flow, error-handling, address-sanitization, rate-limiting, company-lookup
get-dto-structure Complete structure of any DTO (InvoiceData, InvoiceLineData, PartyData, etc.)
get-enum-values All values for any enum (InvoiceTypeCode, MessageFilter, etc.)
get-config-reference Full configuration schema with env vars and defaults
get-api-reference API documentation for services (EFacturaClient, AnafAuthenticator, etc.)

License

Licensed under the Apache License, Version 2.0. See LICENSE for details.