netgescon-day0/app/Services/FattureElettroniche/FatturaElettronicaXmlParser.php

522 lines
26 KiB
PHP

<?php
namespace App\Services\FattureElettroniche;
use Carbon\Carbon;
use DOMDocument;
use DOMXPath;
class FatturaElettronicaXmlParser
{
/**
* @return array{
* tipo_documento: string|null,
* sdi_file: string|null,
* numero_fattura: string|null,
* data_fattura: Carbon|null,
* data_scadenza: Carbon|null,
* fornitore_piva: string|null,
* fornitore_cf: string|null,
* fornitore_denominazione: string|null,
* fornitore_nome: string|null,
* fornitore_cognome: string|null,
* fornitore_indirizzo: string|null,
* fornitore_civico: string|null,
* fornitore_cap: string|null,
* fornitore_citta: string|null,
* fornitore_provincia: string|null,
* fornitore_nazione: string|null,
* fornitore_telefono: string|null,
* fornitore_email: string|null,
* fornitore_fax: string|null,
* fornitore_regime_fiscale: string|null,
* imponibile: float|null,
* iva: float|null,
* totale: float|null,
* codice_destinatario: string|null,
* pec_destinatario: string|null,
* destinatario_cf: string|null,
* destinatario_piva: string|null,
* destinatario_denominazione: string|null,
* pagamento_modalita: string|null,
* pagamento_iban: string|null,
* pagamento_bic: string|null,
* consumo_valore: float|null,
* consumo_unita: string|null,
* consumo_riferimento: string|null,
* consumo_raw: string|null,
* trasmissione: array{
* id_paese: string|null,
* id_codice: string|null,
* progressivo_invio: string|null,
* formato_trasmissione: string|null,
* },
* ritenuta: array{
* tipo: string|null,
* importo: float|null,
* aliquota: float|null,
* causale: string|null,
* },
* righe: array<int, array{
* numero_linea: int|null,
* descrizione: string|null,
* quantita: float|null,
* prezzo_unitario: float|null,
* prezzo_totale: float|null,
* aliquota_iva: float|null,
* ritenuta: bool|null,
* }>,
* }
*/
public function parse(string $xml): array
{
$xml = $this->sanitizeXml($xml);
$dom = new DOMDocument();
$dom->preserveWhiteSpace = false;
$dom->formatOutput = false;
// Se l'XML è sporco, proviamo comunque a caricarlo
$loaded = @$dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
if (! $loaded) {
throw new \RuntimeException('XML non valido');
}
$xpath = new DOMXPath($dom);
// IdentificativoSdI non è sempre presente negli XML (dipende dal flusso/origine).
// Se presente, lo salviamo per matching deterministico con la “Lista AdE”.
$sdiFile = $this->firstString($xpath, "//*[local-name()='IdentificativoSdI']");
$tipoDocumento = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='TipoDocumento']");
$numero = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='Numero']");
$data = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='Data']");
$scadenza = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='DataScadenzaPagamento']");
$modalitaPagamento = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='ModalitaPagamento']");
$iban = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='IBAN']");
$bic = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiPagamento']//*[local-name()='DettaglioPagamento']/*[local-name()='BIC']");
$totaleDocumento = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']/*[local-name()='ImportoTotaleDocumento']");
$fornitorePiva = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']");
$fornitoreCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']");
$denominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']");
$nome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']");
$cognome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Cognome']");
$regimeFiscale = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='DatiAnagrafici']/*[local-name()='RegimeFiscale']");
$fornitoreIndirizzo = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Indirizzo']");
$fornitoreCivico = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='NumeroCivico']");
$fornitoreCap = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='CAP']");
$fornitoreCitta = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Comune']");
$fornitoreProvincia = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Provincia']");
$fornitoreNazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Sede']/*[local-name()='Nazione']");
$fornitoreTelefono = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Contatti']/*[local-name()='Telefono']");
$fornitoreFax = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Contatti']/*[local-name()='Fax']");
$fornitoreEmail = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CedentePrestatore']//*[local-name()='Contatti']/*[local-name()='Email']");
if (! $denominazione) {
$denominazione = trim(implode(' ', array_filter([$nome, $cognome])));
}
$codiceDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='CodiceDestinatario']");
$pecDest = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='PECDestinatario']");
$destinatarioCf = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']/*[local-name()='CodiceFiscale']");
$destinatarioPiva = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='IdFiscaleIVA']/*[local-name()='IdCodice']");
$destDenominazione = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Denominazione']");
$destNome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Nome']");
$destCognome = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='CessionarioCommittente']//*[local-name()='DatiAnagrafici']//*[local-name()='Anagrafica']/*[local-name()='Cognome']");
if (! $destDenominazione) {
$destDenominazione = trim(implode(' ', array_filter([$destNome, $destCognome])));
}
[$imponibile, $iva] = $this->sumRiepilogo($xpath);
$consumi = $this->extractConsumi($xpath);
$ritenuta = $this->extractRitenuta($xpath);
$righe = $this->extractRighe($xpath);
// Alcuni XML (soprattutto vecchi) esprimono componenti economiche fuori da DettaglioLinee,
// ad esempio Cassa Previdenziale e Bollo. Se non li trasformiamo in righe, la quadratura
// tra totale documento e somma righe risulta errata (tipico: delta = ImportoContributoCassa).
$righe = $this->appendExtraRigheFromGenerali($xpath, $righe);
$trasmissione = [
'id_paese' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']//*[local-name()='IdTrasmittente']/*[local-name()='IdPaese']"),
'id_codice' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']//*[local-name()='IdTrasmittente']/*[local-name()='IdCodice']"),
'progressivo_invio' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='ProgressivoInvio']"),
'formato_trasmissione' => $this->firstString($xpath, "//*[local-name()='FatturaElettronicaHeader']//*[local-name()='DatiTrasmissione']/*[local-name()='FormatoTrasmissione']"),
];
$totale = $this->toFloat($totaleDocumento);
if ($totale === null) {
$totale = null;
if ($imponibile !== null || $iva !== null) {
$totale = round((float) ($imponibile ?? 0) + (float) ($iva ?? 0), 2);
}
}
return [
'tipo_documento' => $tipoDocumento ?: null,
'sdi_file' => $sdiFile ?: null,
'numero_fattura' => $numero ?: null,
'data_fattura' => $this->toDate($data),
'data_scadenza' => $this->toDate($scadenza),
'fornitore_piva' => $this->normalizeId($fornitorePiva),
'fornitore_cf' => $this->normalizeId($fornitoreCf),
'fornitore_denominazione' => $denominazione ?: null,
'fornitore_nome' => $nome ?: null,
'fornitore_cognome' => $cognome ?: null,
'fornitore_indirizzo' => $fornitoreIndirizzo ?: null,
'fornitore_civico' => $fornitoreCivico ?: null,
'fornitore_cap' => $fornitoreCap ?: null,
'fornitore_citta' => $fornitoreCitta ?: null,
'fornitore_provincia' => $fornitoreProvincia ?: null,
'fornitore_nazione' => $fornitoreNazione ?: null,
'fornitore_telefono' => $fornitoreTelefono ?: null,
'fornitore_email' => $fornitoreEmail ?: null,
'fornitore_fax' => $fornitoreFax ?: null,
'fornitore_regime_fiscale' => $regimeFiscale ?: null,
'imponibile' => $imponibile,
'iva' => $iva,
'totale' => $totale,
'codice_destinatario' => $codiceDest ?: null,
'pec_destinatario' => $pecDest ?: null,
'destinatario_cf' => $this->normalizeId($destinatarioCf),
'destinatario_piva' => $this->normalizeId($destinatarioPiva),
'destinatario_denominazione' => $destDenominazione ?: null,
'pagamento_modalita' => $modalitaPagamento ?: null,
'pagamento_iban' => $this->normalizeIban($iban),
'pagamento_bic' => $this->normalizeId($bic),
'consumo_valore' => $consumi['valore'],
'consumo_unita' => $consumi['unita'],
'consumo_riferimento' => $consumi['riferimento'],
'consumo_raw' => $consumi['raw'],
'trasmissione' => $trasmissione,
'ritenuta' => $ritenuta,
'righe' => $righe,
];
}
private function sanitizeXml(string $xml): string
{
$xml = preg_replace('/^\xEF\xBB\xBF/', '', $xml) ?? $xml;
if (function_exists('mb_check_encoding') && ! mb_check_encoding($xml, 'UTF-8')) {
if (function_exists('mb_convert_encoding')) {
$xml = mb_convert_encoding($xml, 'UTF-8', 'UTF-8,ISO-8859-1,Windows-1252');
}
}
$fixed = @iconv('UTF-8', 'UTF-8//IGNORE', $xml);
if (is_string($fixed) && $fixed !== '') {
$xml = $fixed;
}
$xml = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', '', $xml) ?? $xml;
return $xml;
}
/**
* @param array<int, array{numero_linea: int|null, descrizione: string|null, quantita: float|null, prezzo_unitario: float|null, prezzo_totale: float|null, aliquota_iva: float|null, ritenuta: bool|null}> $righe
* @return array<int, array{numero_linea: int|null, descrizione: string|null, quantita: float|null, prezzo_unitario: float|null, prezzo_totale: float|null, aliquota_iva: float|null, ritenuta: bool|null}>
*/
private function appendExtraRigheFromGenerali(DOMXPath $xpath, array $righe): array
{
$nextNumero = 1;
foreach ($righe as $r) {
if (isset($r['numero_linea']) && is_int($r['numero_linea']) && $r['numero_linea'] >= $nextNumero) {
$nextNumero = $r['numero_linea'] + 1;
}
}
// DatiCassaPrevidenziale può comparire 1..N volte.
$nodes = $xpath->query("//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiCassaPrevidenziale']");
if ($nodes && $nodes->length > 0) {
foreach ($nodes as $node) {
$tipoCassa = $this->firstStringNode($xpath, "./*[local-name()='TipoCassa']", $node);
$aliquotaIva = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='AliquotaIVA']", $node));
$importo = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='ImportoContributoCassa']", $node));
if ($importo === null || abs($importo) < 0.00001) {
continue;
}
$label = 'Cassa previdenziale';
if (is_string($tipoCassa) && trim($tipoCassa) !== '') {
$label .= ' ' . trim($tipoCassa);
}
$righe[] = [
'numero_linea' => $nextNumero++,
'descrizione' => $label,
'quantita' => null,
'prezzo_unitario' => null,
'prezzo_totale' => $importo,
'aliquota_iva' => $aliquotaIva,
'ritenuta' => null,
];
}
}
// DatiBollo: in alcuni tracciati può essere conteggiato nel totale documento.
$bollo = $this->toFloat($this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiBollo']/*[local-name()='ImportoBollo']"));
if ($bollo !== null && abs($bollo) >= 0.00001) {
$righe[] = [
'numero_linea' => $nextNumero++,
'descrizione' => 'Bollo',
'quantita' => null,
'prezzo_unitario' => null,
'prezzo_totale' => $bollo,
'aliquota_iva' => 0.0,
'ritenuta' => null,
];
}
return $righe;
}
/**
* @return array{tipo: string|null, importo: float|null, aliquota: float|null, causale: string|null}
*/
private function extractRitenuta(DOMXPath $xpath): array
{
$tipo = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='TipoRitenuta']");
$importo = $this->toFloat($this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='ImportoRitenuta']"));
$aliquota = $this->toFloat($this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='AliquotaRitenuta']"));
$causale = $this->firstString($xpath, "//*[local-name()='FatturaElettronicaBody']//*[local-name()='DatiGeneraliDocumento']//*[local-name()='DatiRitenuta']/*[local-name()='CausalePagamento']");
return [
'tipo' => $tipo ?: null,
'importo' => $importo,
'aliquota' => $aliquota,
'causale' => $causale ?: null,
];
}
/**
* @return array<int, array{numero_linea: int|null, descrizione: string|null, quantita: float|null, prezzo_unitario: float|null, prezzo_totale: float|null, aliquota_iva: float|null, ritenuta: bool|null}>
*/
private function extractRighe(DOMXPath $xpath): array
{
$out = [];
$nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DettaglioLinee']");
if (! $nodes || $nodes->length < 1) {
return $out;
}
foreach ($nodes as $node) {
$numeroLinea = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='NumeroLinea']", $node));
$descrizione = $this->firstStringNode($xpath, "./*[local-name()='Descrizione']", $node);
$quantita = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='Quantita']", $node));
$prezzoUnitario = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='PrezzoUnitario']", $node));
$prezzoTotale = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='PrezzoTotale']", $node));
$aliquotaIva = $this->toFloat($this->firstStringNode($xpath, "./*[local-name()='AliquotaIVA']", $node));
$ritenuta = $this->firstStringNode($xpath, "./*[local-name()='Ritenuta']", $node);
$ritenutaBool = null;
if (is_string($ritenuta) && $ritenuta !== '') {
$ritenutaBool = strtoupper(trim($ritenuta)) === 'SI';
}
$out[] = [
'numero_linea' => $numeroLinea !== null ? (int) $numeroLinea : null,
'descrizione' => $descrizione ?: null,
'quantita' => $quantita,
'prezzo_unitario' => $prezzoUnitario,
'prezzo_totale' => $prezzoTotale,
'aliquota_iva' => $aliquotaIva,
'ritenuta' => $ritenutaBool,
];
}
return $out;
}
private function firstStringNode(DOMXPath $xpath, string $expr, \DOMNode $contextNode): ?string
{
$nodes = $xpath->query($expr, $contextNode);
if (! $nodes || $nodes->length < 1) {
return null;
}
$value = trim((string) $nodes->item(0)?->textContent);
return $value !== '' ? $value : null;
}
/**
* @return array{valore: float|null, unita: string|null, riferimento: string|null, raw: string|null}
*/
private function extractConsumi(DOMXPath $xpath): array
{
$texts = [];
$nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DettaglioLinee']/*[local-name()='Descrizione']");
if ($nodes) {
foreach ($nodes as $n) {
$v = trim((string) $n->textContent);
if ($v !== '') {
$texts[] = $v;
}
}
}
// AltriDatiGestionali spesso contiene codici (utenza/POD/PDR/letture) in forma testuale.
$nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DettaglioLinee']//*[local-name()='AltriDatiGestionali']//*[local-name()='RiferimentoTesto']");
if ($nodes) {
foreach ($nodes as $n) {
$v = trim((string) $n->textContent);
if ($v !== '') {
$texts[] = $v;
}
}
}
$raw = trim(implode("\n", array_values(array_unique($texts))));
if ($raw === '') {
return ['valore' => null, 'unita' => null, 'riferimento' => null, 'raw' => null];
}
$riferimento = null;
if (preg_match('/\butenza\s*([0-9]{3,})\b/i', $raw, $m)) {
$riferimento = 'utenza ' . $m[1];
} elseif (preg_match('/\bPOD\s*([A-Z0-9]{10,})\b/i', $raw, $m)) {
$riferimento = 'POD ' . strtoupper($m[1]);
} elseif (preg_match('/\bPDR\s*([0-9]{8,})\b/i', $raw, $m)) {
$riferimento = 'PDR ' . $m[1];
}
// Priorità: MC (acqua), kWh (energia), Smc (gas)
$valore = null;
$unita = null;
if (preg_match('/\bMC\s*([0-9]+(?:[\.,][0-9]+)?)\b/i', $raw, $m)) {
$valore = $this->toFloat($m[1]);
$unita = 'MC';
} elseif (preg_match('/\bkWh\s*([0-9]+(?:[\.,][0-9]+)?)\b/i', $raw, $m)) {
$valore = $this->toFloat($m[1]);
$unita = 'kWh';
} elseif (preg_match('/\bSmc\s*([0-9]+(?:[\.,][0-9]+)?)\b/i', $raw, $m)) {
$valore = $this->toFloat($m[1]);
$unita = 'Smc';
}
return [
'valore' => $valore,
'unita' => $unita,
'riferimento' => $riferimento,
'raw' => $raw,
];
}
private function firstString(DOMXPath $xpath, string $expr): ?string
{
$nodes = $xpath->query($expr);
if (! $nodes || $nodes->length < 1) {
return null;
}
$value = trim((string) $nodes->item(0)?->textContent);
return $value !== '' ? $value : null;
}
/**
* @return array{0: float|null, 1: float|null}
*/
private function sumRiepilogo(DOMXPath $xpath): array
{
$nodes = $xpath->query("//*[local-name()='DatiBeniServizi']//*[local-name()='DatiRiepilogo']");
if (! $nodes || $nodes->length < 1) {
return [null, null];
}
$imponibile = 0.0;
$iva = 0.0;
$hasAny = false;
foreach ($nodes as $node) {
$childXpath = new DOMXPath($node->ownerDocument);
$imp = $childXpath->query(".//*[local-name()='ImponibileImporto']", $node);
$tax = $childXpath->query(".//*[local-name()='Imposta']", $node);
$impVal = $imp && $imp->length ? $this->toFloat($imp->item(0)?->textContent) : null;
$taxVal = $tax && $tax->length ? $this->toFloat($tax->item(0)?->textContent) : null;
if ($impVal !== null) {
$imponibile += $impVal;
$hasAny = true;
}
if ($taxVal !== null) {
$iva += $taxVal;
$hasAny = true;
}
}
if (! $hasAny) {
return [null, null];
}
return [round($imponibile, 2), round($iva, 2)];
}
private function toDate(?string $value): ?Carbon
{
$value = $value ? trim($value) : '';
if ($value === '') {
return null;
}
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
private function toFloat(?string $value): ?float
{
$value = $value ? trim($value) : '';
if ($value === '') {
return null;
}
$value = str_replace(['.', ','], ['.', '.'], $value);
if (! is_numeric($value)) {
return null;
}
return (float) $value;
}
private function normalizeId(?string $value): ?string
{
$value = $value ? trim($value) : '';
if ($value === '') {
return null;
}
$value = strtoupper(preg_replace('/\s+/', '', $value));
return $value !== '' ? $value : null;
}
private function normalizeIban(?string $value): ?string
{
$value = $this->normalizeId($value);
if (! $value) {
return null;
}
// IBAN tipico: 15-34 caratteri alfanumerici
if (! preg_match('/^[A-Z0-9]{15,34}$/', $value)) {
return null;
}
return $value;
}
}