Descarcare factura de la furnizor din SPV ANAF folosind autentificarea OAuth in PHP

In doua articole anterioare am prezentat metode de autentificare prin OAuth si obtinere a tokenului de acces JWT, precum si metode pentru comunicarea cu platforma SPV ANAF in vederea incarcarii facturilor electronice in format xml.

Mai jos urmeaza metode pentru descarcarea unei facturi din SPV, factura care a fost incarcata de furnizor. Fluxul operatiunilor pentru descarcarea unei facturi este:

  • consultarea listei de raspunsuri
  • descarcarea facturii in format xml
  • conversia facturii xml in format pdf
  • interpretarea continutului facturii xml (optional)

1. Consultarea listei de mesaje

Este important de notat faptul ca lista cu raspunsuri returneaza maxim 500 de elemente (facturi, erori sau mesaje). De aceea, aceasta interogare se poate face in doua moduri: simplu sau cu paginatie. Eu am ales sa merg pe varianta cu paginatie, pentru ca acopera inclusiv situatia in care raspunsul contine mai putin de 500 de elemente (deci o singura pagina). Apelul acestui serviciu necesita autentificare.

La apelul acestui serviciu sunt necesari urmatorii parametri:

  • startTime – data de start pentru care se face interogarea, in format numeric (unixtimestamp cu milisecunde)
  • endTime – data de final pentru care se face interogarea, in format numeric (unixtimestamp cu milisecunde)
  • cif – codul fiscal (numeric) pentru care se face interogarea raspunsurilor
  • pagina – numarul paginii interogate
  • filtru (optional) – poate avea valorile urmatoare
    E = ERORI FACTURA
    T = FACTURA TRIMISA
    P = FACTURA PRIMITA
    R = MESAJ CUMPARATOR PRIMIT / MESAJ CUMPARATOR TRANSMIS

Se pot interoga raspunsurile pentru cel mult 60 de zile in urma. Pentru a nu ne lovi de problemele care apar chiar la limita intervalului de timp (datorita sincronizarii imperfecte a timpului intre serverul nostru si cel al ANAF) am ales ca parametrul endTime sa fie intotdeauna cu 10 minute in urma fata de momentul prezent. De asemenea, in webinariile organizate de ANAF am fost asigurati ca nu vor apare raspunsuri noi la o data din trecut, ci, toate raspunsurile sunt furnizate cronologic, in ordinea emiterii lor. Asta inseamna ca daca interogam lista de raspunsuri o data pe zi, nu are rost sa interogam un interval mai mare de 1 zi (sa zicem totusi 2 zile pentru siguranta).

Un aspect care merita notat este faptul ca in lista de raspunsuri mai intai sunt furnizate cele cu id impar, iar apoi cele cu id par. Chiar si asa, nu exista o ordonare utila pentru utilizator. Asta inseamna ca informatiile nu vor fi afisate utilizatorilor pentru fiecare pagina de raspunsuri interogata la ANAF, ci vor fi descarcate toate paginile, se va face ordonarea necesara si abia apoi se va face afisarea.

Fiecare pagina de raspunsuri contine si parametrul numar_total_pagini, asa incat chiar de la prima pagina returnata se va cunoaste numarul total de pagini care trebuie interogate.

Apelul de mai jos este facut pentru facturile primite, deci contine parametrul filtru=P.

public function getListaRaspunsuri($nr_zile = 60){
	global $db;
	$out = [];
	
	//verificare existenta token valid pentru ANAF
	if (!$this->get_last_valid_token()){
		return ['result' => 1, 'error' => 'Nu exista token valabil.'];
	}
	
	//initializari
	$startTime = strtotime("-{$zile} days") * 1000;
	$endTime = strtotime("-10 min") * 1000;
	$pagina = 1;
	$nr_pagini = 1;

	//ciclu de interogari
	while($pagina <= $nr_pagini){
		//interogare
		$ch = curl_init();
		curl_setopt($ch, CURLOPT_URL, "https://api.anaf.ro/prod/FCTEL/rest/listaMesajePaginatieFactura?startTime=$startTime&endTime=$endTime&cif=COD_FISCAL&pagina=$pagina&filtru=P");
		curl_setopt($ch, CURLOPT_POST, 0);
		curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
		curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
		curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
		curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
		curl_setopt($ch, CURLOPT_HTTPHEADER,
			array(
				"Authorization: Bearer {$anaf->token_info['access_token']}",
				"Content-Type: text/plain",
				"Accept-Encoding: gzip, deflate, br",
				"Connection: keep-alive"
			)
		);		

		//verbose output
		curl_setopt($ch, CURLOPT_VERBOSE, true);
		$streamVerboseHandle = fopen('php://temp', 'w+');
		curl_setopt($ch, CURLOPT_STDERR, $streamVerboseHandle);
		
		$resp = curl_exec($ch);
		
		rewind($streamVerboseHandle);
		$verboseLog = stream_get_contents($streamVerboseHandle);
		
		//mesaje
		$result = json_decode($resp, true);
		
		if ($result['eroare']){
			$errors[] = $result['eroare'];
		}
		else{
			//tratare alte erori
			if (!is_array($result['mesaje']) || !isset($result['numar_total_pagini'])){
				$errors[] = 'Eroare la interogarea ANAF';
				$errors[] = $verboseLog;
				return ['result' => 1, 'errors' => $errors];
			}

			foreach($result['mesaje'] AS $mesaj){
				$out[] = $mesaj;
			}
			$nr_pagini = $result['numar_total_pagini'];
			$pagina++;
		}
	}
	
	if (count($errors)){
		return ['result' => 1, 'errors' => $errors];
	}
	else{
		return ['result' => 0, 'mesaje' => $out];
	}
}

Fiecare pozitie din lista de mesaje returnate are structura urmatoare:

Array
(
    [data_creare] => 202403170835
    [cif] => COD_FISCAL
    [id_solicitare] => 1234567890
    [detalii] => Factura cu id_incarcare=1234567890 emisa de cif_emitent=COD_FISCAL_FURNIZOR pentru cif_beneficiar=COD_FISCAL
    [tip] => FACTURA PRIMITA
    [id] => 3124567890
)
  • data_creare – reprezinta data la care furnizorul a incarcat factura in SPV
  • cif – cod fiscal pentru care se face interogarea (clientul)
  • id_solicitare – reprezinta index_incarcare pe care l-a primit furnizorul la incarcarea facturii
  • detalii – un string cu cateva detalii din care putem extrage codul fiscal al furnizorului
  • tip – tipul mesajului (mai sus la pasul 1 apare lista cu valorile posibile ale acestui parametru)
  • id – id_descarcare

2. Descarcarea unei facturi de la furnizor

Pentru descarcarea unei facturi de la furnizor din platforma SPV se poate utiliza metoda de descarcare a unui mesaj (prezentata in articolul anterior), singura diferenta fiind aceea ca metoda nu poate fi apelata utilizand numarul facturii, ci parametrul id_descarcare. Acest parametru il obtinem din lista de mesaje de la pasul anterior. Apelul acestui serviciu necesita autentificare.

public function getFacturaFurnizor($id_descarcare){
	global $db;
	
	//verificare existenta token valid pentru ANAF
	if (!$this->get_last_valid_token()){
		return ['result' => 1, 'error' => 'Nu exista token valabil.'];
	}
	
	//interogare
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_URL, 'https://api.anaf.ro/prod/FCTEL/rest/descarcare?id=' . $id_descarcare);
	curl_setopt($ch, CURLOPT_POST, 0);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
	curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
	curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
	curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'GET');
	curl_setopt($ch, CURLOPT_HTTPHEADER,
		array(
			"Authorization: Bearer {$this->token_info['access_token']}",
			"Content-Type: text/plain",
			"Accept-Encoding: gzip, deflate, br",
			"Connection: keep-alive"
		)
	);		
	
	//verbose output
	curl_setopt($ch, CURLOPT_VERBOSE, true);
	$streamVerboseHandle = fopen('php://temp', 'w+');
	curl_setopt($ch, CURLOPT_STDERR, $streamVerboseHandle);
	
	$resp = curl_exec($ch);
	
	//testez daca raspunsul este continut de fisier zip
	if (substr($resp, 0, 4) == "\x50\x4b\x03\x04"){
		//salvez fisierul zip
		$filepath = LOCAL_PATH . "/documents/eFactura/$id_descarcare";
		$filename = "{$id_descarcare}.zip";
		
		//daca nu exista folderul destinatie il creez
		if (!is_dir($filepath)) mkdir($filepath);
		
		//scriere continut fisier zip
		$f = fopen($filepath . '/' . $filename, 'w');
		fwrite($f, $resp);
		fclose($f);
		chmod($filepath . '/' . $filename, 0755);
		
		//returnare rezultat
		return ['result' => 0, 'filename' => $filepath . '/' . $filename];
	}
	else{
		//NU este continut fisier zip
		return ['result' => 1, 'error' => $resp];
	}
}

3. Conversie xml in pdf

Acesta este un serviciu web pus la dispozitie de ANAF prin care factura in format xml poate fi convertita in format pdf. Structura si formatul (macheta) documentului pdf nu este una foarte placuta, dar…e singura varianta pe care o avem la dispozitie.

Apelul acestui serviciu nu necesita autentificare.

Parametrii pentru request sunt:

  • tip – tipul documentului, poate avea valorile FACT1 sau FCN
  • novld – optional, se accepta doar valoarea DA, caz in care fisierul xml transmis nu va fi validat
public function convertXML2PDF($tip, $xml_filepath, $invoice_no){
	global $db;
	
	$pathinfo = pathinfo($xml_filepath);
	$filepath = $pathinfo['dirname'];
	$xml_filename = basename($xml_filepath);
	$pdf_filename = $invoice_no . '.pdf';
	
	//preluare continut fisier xml
	$xml_file_content = file_get_contents($filepath . '/' . $xml_filename);
	//elimin headerul BOM (UTF-8) din continutul fisierului
	$xml_file_content = remove_bom($xml_file_content);
	//elimin secventa care poate genera erori
	$xml_file_content = str_replace('xsi:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 ../../UBL-2.1(1)/xsd/maindoc/UBL-Invoice-2.1.xsd"', '', $xml_file_content);

	//interogare
	$ch = curl_init();
	curl_setopt($ch, CURLOPT_URL, "https://webservicesp.anaf.ro/prod/FCTEL/rest/transformare/$tip");
	curl_setopt($ch, CURLOPT_POST, 1);
	curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
	curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1);
	curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
	curl_setopt($ch, CURLOPT_POSTFIELDS, $xml_file_content);
	curl_setopt($ch, CURLOPT_HTTPHEADER,
		array(
			"Content-Type: text/plain",
			"Accept-Encoding: gzip, deflate, br",
			"Connection: keep-alive"
		)
	);		
	
	//verbose output
	curl_setopt($ch, CURLOPT_VERBOSE, true);
	$streamVerboseHandle = fopen('php://temp', 'w+');
	curl_setopt($ch, CURLOPT_STDERR, $streamVerboseHandle);
	
	$resp = curl_exec($ch);
	
	rewind($streamVerboseHandle);
	$verboseLog = stream_get_contents($streamVerboseHandle);
	
	//testez daca este continut de fisier pdf
	if (substr($resp, 0, 4) == "\x25\x50\x44\x46"){
		//salvez fisierul pdf
		
		//daca nu exista folderul destinatie il creez
		if (!is_dir($filepath)) mkdir($filepath);
		
		//scriere continut fisier zip
		$f = fopen($filepath . '/' . $pdf_filename, 'w');
		fwrite($f, $resp);
		fclose($f);
		chmod($filepath . '/' . $pdf_filename, 0755);
		
		//returnare rezultat
		return ['result' => 0, 'filename' => $filepath . '/' . $pdf_filename];
	}
	else{
		//NU este continut fisier zip
		return ['result' => 1, 'error' => $resp];
	}
}

4. Interpretarea facturii xml de la furnizor (optional)

Pentru interpretarea continutului unui document xml am utilizat clasa UBLParser pe care am obtinut-o de aici: https://github.com/ahmeti/ubl-parser-php/blob/master/UBLParser.php si pe care am adaptat-o situatiei mele. Aceasta clasa permite traversarea elementelor DOM ale fisierului xml si transformarea acestuia intr-un array. Secventa de cod de mai jos citeste continutul fisierului XML, iar apoi il interpreteaza (parseaza) in functie de tipul documentului: Factura sau Credit Note. La final, in variabila $f vom avea continutul documentului in format Array din care putem extrage mai usor informatiile necesare.

Fisierul xml trebuie extras din arhiva zip descarcata din SPV. Pentru dezarhivare am folosit clasa ZipArchive din PHP.

//DEZARHIVARE
$zip = new ZipArchive();
if ($zip->open($zip_filename)){
	//extrag fisierul factura
	$zip->extractTo(LOCAL_PATH . "/documents/eFactura/{$id_descarcare}", "{$id_solicitare}.xml");

	//extrag fisierul semnatura
	$zip->extractTo(LOCAL_PATH . "/documents/eFactura/{$id_descarcare}", "semnatura_{$id_solicitare}.xml");

	$zip->close();
}

//citire continut fisier
$xml = file_get_contents(LOCAL_PATH . "/documents/eFactura/{$id_descarcare}/{$id_solicitare}.xml");

//eliminarea din continutul fisierului a secventei care poate genera erori de interpretare a fisierului XML
$xml = str_replace('xmlns:schemaLocation="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2 http://docs.oasis-open.org/ubl/os-UBL-2.1/xsd/maindoc/UBL-Invoice-2.1.xsd"', '', $xml);

//INTERPRETARE CONTINUT XML
$ubl = new UBLParser;
$ubl->getDocDetails($xml);
if ($ubl->docType == 'CreditNote'){
	$tipDocument = 'FCN';
	$ubl->parseCreditNote($xml);
	$f = $ubl->getCreditNote();
}
else{
	$tipDocument = 'FACT1';
	$ubl->parseInvoice($xml);
	$f = $ubl->getInvoice();
}

//numarul documentului
$invoice_no = $f['ID'];