A framework-free PHP kernel for Romanian e-invoicing: the EN 16931 document model, a CIUS-RO UBL 2.1 writer and reader, layered validation that predicts what ANAF's validator will say, and a PSR-18 client for every endpoint of ANAF's e-Factura API.
It computes nothing. You supply every amount as a decimal string, the kernel verifies the arithmetic to the cent, writes the XML, and talks to ANAF. Money is brick/math inside; a float is refused at the door.
use AtlasFlow\EFacturaRo\Ubl\UblWriter;
use AtlasFlow\EFacturaRo\Validation\LocalValidator;
$xml = (new UblWriter)->write($document); // CIUS-RO UBL 2.1
$result = (new LocalValidator)->validate($document); // XSD + BR-* rules, offline
$result = $anaf->validateDocument($document); // ANAF's own validator, no credentialsTwo release tiers, deliberately. Everything that ANAF's public validator can prove — the document model, the writer and reader, the local rules, validate, render, verifySignature — is tagged v0.1.0 and runs green against ANAF on every fixture in CI. The OAuth client and the authenticated endpoints (upload, status, messages, download) are built from the Ministry of Finance's published API pages and covered by recorded-response tests, but have not yet been run against a live ANAF session. tests/Live/UploadTest.php is the gate that promotes them; it needs a certificate holder with SPV rights.
The Laravel bridge — persisted authorisations, submissions, the inbound mailbox, jobs and events — is the separate package atlasflow/efactura-ro-laravel.
- PHP 8.4 with
ext-dom,ext-libxml,ext-zip - A PSR-18 HTTP client and PSR-17 factories for anything that talks to ANAF (Guzzle, Symfony HttpClient, Laravel's
Httpthrough the bridge, …)
composer require atlasflow/efactura-roEvery class in Document\ is final readonly. Amounts are Amount::of('12.50'); identifiers are Cui::of('RO12345674') (prefix stripped, check digit verified), Cnp::of(...), or ForeignIdentifier::of('DE123456789', 'DE').
use AtlasFlow\EFacturaRo\Document\{Address, AllowanceCharge, Document, DocumentType, Line, LineVat, Party, PaymentMeans, TaxSubtotal, Totals};
use AtlasFlow\EFacturaRo\Support\{Amount, Cui};
use AtlasFlow\EFacturaRo\Vat\VatCategory;
$seller = new Party(
name: 'Pepiniera Verde SRL',
address: new Address('Str. Florilor 10', 'Cluj-Napoca', 'RO', 'RO-CJ', '400001'),
taxIdentifier: Cui::of('RO12345674'),
vatRegistered: true,
registrationNumber: 'J12/345/2015',
);
$buyer = new Party(
name: 'Grădina Albastră SRL',
address: new Address('Bd. Unirii 5', 'SECTOR3', 'RO', 'RO-B', '030167'), // Bucharest: RO-B + SECTOR1…6
taxIdentifier: Cui::of('40000000'),
vatRegistered: true,
);
$s21 = LineVat::standard('21');
$invoice = new Document(
type: DocumentType::INVOICE,
number: 'PV-2026-000123',
issueDate: new DateTimeImmutable('2026-09-10'),
currency: 'RON',
seller: $seller,
buyer: $buyer,
lines: [
new Line('1', 'Buxus sempervirens 30-40 cm', Amount::of('100'), Amount::of('12.50'), Amount::of('1250.00'), $s21),
new Line('2', 'Transport', Amount::of('1'), Amount::of('150.00'), Amount::of('150.00'), $s21),
],
taxSubtotals: [
new TaxSubtotal(VatCategory::S, Amount::of('21'), Amount::of('1350.00'), Amount::of('283.50')),
],
totals: new Totals(
lineExtensionAmount: Amount::of('1400.00'),
taxExclusiveAmount: Amount::of('1350.00'),
taxAmount: Amount::of('283.50'),
taxInclusiveAmount: Amount::of('1633.50'),
payableAmount: Amount::of('1633.50'),
allowanceTotal: Amount::of('50.00'),
),
dueDate: new DateTimeImmutable('2026-10-10'),
paymentMeans: [PaymentMeans::bankTransfer('RO49AAAA1B31007593840000')],
allowancesCharges: [AllowanceCharge::allowance('50.00', 'Discount volum', '95', $s21)],
);A credit note is DocumentType::CREDIT_NOTE with precedingDocuments: [new PrecedingDocument('PV-2026-000123', $date)]; it is written as a UBL CreditNote. A consumer is Party::consumer('Ion Popescu', $address, Cnp::of('…')) — or without the CNP, in which case the writer emits ANAF's placeholder 0000000000000 — and routes the upload to /uploadb2c. A foreign-currency document needs taxCurrency: 'RON' and taxTotalInTaxCurrency (BR-RO-030).
tests/Fixtures/Documents.php holds eight worked documents — standard, prepayment and rounding, credit note, intra-EU supply in EUR, reverse charge, B2C with and without CNP, VAT-exempt seller — all accepted by ANAF's validator.
VAT goes on the wire as category + rate (+ VATEX code and reason for the exempt-like categories). If your application persists a neutral treatment instead, RomanianVatMapping turns it into the codes, with the legal basis for each pairing in its docblock:
use AtlasFlow\EFacturaRo\Vat\{RomanianVatMapping, VatTreatment};
$k = LineVat::fromCode(RomanianVatMapping::for(VatTreatment::INTRA_EU_SUPPLY)); // K, VATEX-EU-IC
$ae = LineVat::fromCode(RomanianVatMapping::for(VatTreatment::REVERSE_CHARGE)); // AE, VATEX-EU-AE
$e = LineVat::fromCode(RomanianVatMapping::for(VatTreatment::EXEMPT, 'VATEX-EU-F')); // E, the article is yours to nameuse AtlasFlow\EFacturaRo\Ubl\{UblReader, UblWriter};
$xml = (new UblWriter)->write($invoice);
$document = (new UblReader)->read($xml); // tolerant: unknown elements ignored, bad CUIs kept as ForeignIdentifierHow parties are identified on the wire, verified against ANAF's validator on 2026-09-16:
| Party | PartyTaxScheme |
PartyLegalEntity/CompanyID |
|---|---|---|
| VAT-registered | RO + CUI, scheme VAT (BT-31/48) |
bare CUI (BT-30/47) |
| not registered | bare CUI, scheme TAX (BT-32) |
bare CUI |
| consumer | none | CNP or 0000000000000 |
| foreign, VAT-registered | prefixed VAT id, scheme VAT |
the same id |
The seller's trade register number goes in CompanyLegalForm; the buyer's is not written (UBL-CR-244 refuses it). A delivery address needs a country subdivision even outside Romania (BR-RO-211).
Three layers, in the order ANAF finds things:
use AtlasFlow\EFacturaRo\Validation\{LocalValidator, RemoteValidator, Validator};
$local = new LocalValidator; // XSD → arithmetic, identity, EN 16931, CIUS-RO rules
$remote = new RemoteValidator($anafClient); // POST validare/{FACT1|FCN}, no credentials
$result = (new Validator($local, $remote))->full($invoice);
foreach ($result->errors as $error) {
echo $error->source->value, ' ', $error->code, ' at ', $error->path, ': ', $error->message, PHP_EOL;
// arithmetic BR-CO-13 at totals.taxExclusiveAmount: BT-106 − BT-107 + BT-108 is 1350.00, BT-109 says 1400.00.
// anaf BR-RO-110 at /Invoice/cac:AccountingSupplierParty/cac:Party/cac:PostalAddress: …
}Each local rule is one class under Validation/Rules/, named for the schematron id it mirrors, with the rule text and ANAF's tolerance in its docblock. Sums are exact to the cent; BR-CO-17, BR-S-08 and BR-S-09 accept less than one unit of drift, as MF's schematron does. LINE-NET-AMOUNT (quantity × price ± line allowances = BT-131) is stricter than ANAF, which never checks it; pass your own rules: list to LocalValidator to drop it. PartiesIdentified predicts ANAF's post-schematron ERRIdentif layer, which MF's own sample invoice fails.
validate() at ANAF has no extern=DA, so a buyer with no Romanian CUI passes the schematron and then fails the identity layer with nu a fost identificat cui cumparator; only an upload can prove that last step for a foreign buyer.
AnafClient is stateless. Every authenticated call takes the bearer token as an argument; nothing is cached, nothing is persisted.
use AtlasFlow\EFacturaRo\Anaf\{AnafClient, Environment, PollSchedule, UploadOptions};
$anaf = new AnafClient($psr18Client, $psr17Factory, $psr17Factory, Environment::TEST);
$receipt = $anaf->uploadDocument($invoice, $token); // POST /upload or /uploadb2c, standard UBL|CN, extern=DA when implied
$status = $anaf->status($receipt->index, $token); // PROCESSING | OK | NOK | REFUSED (+ downloadId once terminal)
sleep(PollSchedule::nextDelay($attempt)); // 30 s, 1, 2, 5, 15, 30 min, then hourly — under 100/message/day
$bundle = $anaf->download($status->downloadId, $token); // the ZIP unpacked: payloadXml + MF's signatureXml
$anaf->verifySignature($bundle->payloadXml, $bundle->signatureXml); // true when MF signed exactly this payload
$list = $anaf->messages($cui, days: 5, token: $token); // FACTURA TRIMISA / PRIMITA, ERORI FACTURA, MESAJ CUMPARATOR …
$page = $anaf->messagesBetween($cui, $from, $to, page: 1, token: $token);
$pdf = $anaf->render($xml); // MF's PDF rendering, publicErrors are typed: Unauthorised (401/403 and "Nu aveti drept in SPV…"), RateLimited (429 and ANAF's daily-quota texts, with retryAfter and the quota hit), UploadRefused (ExecutionStatus 1, with ANAF's messages), NotFound, TransportFailure (5xx or the network — ANAF is down, the document is not wrong), UnexpectedResponse (with the body). Quotas holds MF's published limits for your rate limiter.
The certificate holder authorises once a year; the server keeps itself authorised with the refresh token, which needs no certificate.
use AtlasFlow\EFacturaRo\Anaf\OAuth\{AuthorizationUrl, OAuthClient};
$url = AuthorizationUrl::build($clientId, $redirectUri); // send the certificate holder here
$oauth = new OAuthClient($psr18Client, $psr17Factory, $psr17Factory, $clientId, $clientSecret);
$pair = $oauth->exchange($code, $redirectUri); // within ANAF's 60-second window
$pair = $oauth->refresh($pair); // rotates both tokens: persist the whole pairTokenPair reads exp, iat and the certificate serial from the JWTs without verifying them (ANAF verifies on use). No JWT claim naming the CUIs the holder has rights for has been confirmed, so coveredCuis is [] and an authorisation must be treated as covering exactly the CUI it was started for.
composer test # everything offline (~2 s)
composer test:live # the eight fixtures against ANAF's public validator
ANAF_TEST_TOKEN=… ANAF_TEST_CIF=… vendor/bin/pest --group=anaf-test # upload → poll → download, ANAF test environmenttests/fixtures/anaf/ holds ANAF's documented responses; tests/fixtures/mf/ holds the Ministry of Finance's sample invoice and credit note (EUPL 1.2). resources/xsd/ is the OASIS UBL 2.1 schema set.
- ANAF e-Factura API pages under
https://mfinante.gov.ro/static/10/eFactura/—upload.html,staremesaj.html,listamesaje.html,descarcare.html,validare.html,xmltopdf.html,validaresemnatura.html,limiteApeluriAPI.txt,ro16931-ubl-1.0.9.zip,exemple_Invoice_CreditNote.zip - ANAF OAuth:
Oauth_procedura_inregistrare_aplicatii_portal_ANAF.pdf - EN 16931-1 and the CEF validation artefacts as shipped in the MF schematron
MIT. Copyright (c) 2026 AtlasFlow.