Skip to content
Open
12 changes: 12 additions & 0 deletions admin/setup.php
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,18 @@
* Actions
*/

// Reject a duplicated taxpayer identity before FormSetup persists any value.
// This is the earliest safeguard and complements activation/runtime checks.
if ($action == 'update' && !empty($user->admin)) {
$postedTaxId = GETPOST('VERIFACTU_HOLDER_NIF', 'alphanohtml');
$conflictEntity = null;
if (!isVerifactuTaxIdentityUnique($postedTaxId, (int) $conf->entity, $conflictEntity)) {
setEventMessages($langs->trans('VERIFACTU_TAX_IDENTITY_ALREADY_USED', $conflictEntity), null, 'errors');
$action = '';
$error++;
}
}

// For retrocompatibility Dolibarr < 15.0
if (versioncompare(explode('.', DOL_VERSION), array(15)) < 0 && $action == 'update' && !empty($user->admin)) {
$formSetup->saveConfFromPost();
Expand Down
3 changes: 2 additions & 1 deletion class/verifactu.utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,8 @@ private function getInvoicesWithConnectionErrors()
$sql = "SELECT f.rowid, f.ref, f.datef, f.entity";
$sql .= " FROM " . MAIN_DB_PREFIX . "facture f";
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "facture_extrafields fe ON f.rowid = fe.fk_object";
$sql .= " WHERE f.entity = " . getEntity('invoice');
// Retry with the current entity configuration and certificate only.
$sql .= " WHERE f.entity = " . ((int) $conf->entity);
$sql .= " AND f.fk_statut > 0"; // Only validated invoices
$sql .= " AND f.ref NOT LIKE '%PROV%'"; // Exclude provisional invoices
$sql .= " AND (fe.verifactu_error LIKE '%NO_INTERNET_CONNECTION%' OR fe.verifactu_error LIKE '%SERVICE_UNAVAILABLE%')"; // With connection error
Expand Down
23 changes: 21 additions & 2 deletions core/modules/modVerifactu.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,23 @@ public function init($options = '')
return -1;
}

// VeriFactu fiscal resources must remain isolated for the current legal entity.
dol_include_once('/verifactu/lib/functions/functions.configuration.php');
$sharedElement = null;
if (!isVerifactuEntityIsolated((int) $conf->entity, $sharedElement)) {
$this->error = $langs->trans('VERIFACTU_ENTITY_SHARING_NOT_ALLOWED', $sharedElement);
return -1;
}

// The NIF is the primary identity of the taxpayer. Never allow a second
// VeriFactu entity to activate with an identity already in use.
$conflictEntity = null;
$taxId = $conf->global->VERIFACTU_HOLDER_NIF ?? '';
if (!isVerifactuTaxIdentityUnique($taxId, (int) $conf->entity, $conflictEntity)) {
$this->error = $langs->trans('VERIFACTU_TAX_IDENTITY_ALREADY_USED', $conflictEntity);
return -1;
}

// Include VeriFactu data types
require_once(dol_buildpath('/verifactu/lib/verifactu-types.array.php', 0));

Expand Down Expand Up @@ -1174,7 +1191,7 @@ public function init($options = '')

//Default values
$dFalues = [
['type' => 'mandatory', 'entity' => 1, 'page' => 'societe/card.php', 'param' => 'country_id', 'value' => '']
['type' => 'mandatory', 'entity' => $conf->entity, 'page' => 'societe/card.php', 'param' => 'country_id', 'value' => '']

];

Expand All @@ -1194,7 +1211,9 @@ public function init($options = '')
// Permissions
$this->remove($options);
$badge = '<div class="center"><span class="badge badge-status8 classfortooltip badge-status" attr-status="' . $langs->trans('VERIFACTU_STATUS_NOT_SEND') . '">' . $langs->trans('VERIFACTU_STATUS_NOT_SEND') . '</span></div>';
$sql1 = "UPDATE " . MAIN_DB_PREFIX . "facture_extrafields SET verifactu_estado='$badge' WHERE verifactu_estado IS NULL";
$sql1 = "UPDATE " . MAIN_DB_PREFIX . "facture_extrafields SET verifactu_estado='$badge'";
$sql1 .= " WHERE verifactu_estado IS NULL AND fk_object IN (";
$sql1 .= "SELECT rowid FROM " . MAIN_DB_PREFIX . "facture WHERE entity = " . ((int) $conf->entity) . ")";

$sql = array($sql1);
dol_include_once('/verifactu/lib/verifactu.lib.php');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,30 @@ public function billCreate($action, $object, User $user, Translate $langs, Conf
}
public function billValidate($action, $object, User $user, Translate $langs, Conf $conf)
{
dol_include_once('/verifactu/lib/functions/functions.configuration.php');
$langs->load('verifactu@verifactu');

// Never let the active entity validate an invoice owned by another legal entity.
if ((int) $object->entity !== (int) $conf->entity) {
$this->errors[] = $langs->trans('VERIFACTU_FOREIGN_ENTITY_INVOICE_NOT_ALLOWED');
return -1;
}

// Recheck at validation time in case MultiCompany sharing changed after activation.
$sharedElement = null;
if (!isVerifactuEntityIsolated((int) $object->entity, $sharedElement)) {
$this->errors[] = $langs->trans('VERIFACTU_ENTITY_SHARING_NOT_ALLOWED', $sharedElement);
return -1;
}

// A MultiCompany entity enabled for VeriFactu must represent a distinct taxpayer.
$conflictEntity = null;
$taxId = $conf->global->VERIFACTU_HOLDER_NIF ?? '';
if (!isVerifactuTaxIdentityUnique($taxId, (int) $object->entity, $conflictEntity)) {
$this->errors[] = $langs->trans('VERIFACTU_TAX_IDENTITY_ALREADY_USED', $conflictEntity);
return -1;
}

// Include utilities class to process pending invoices
dol_include_once('/verifactu/class/verifactu.utils.php');

Expand Down Expand Up @@ -446,7 +470,7 @@ public function companyModify($action, $object, User $user, Translate $langs, Co
$sql = "SELECT COUNT(*) as count FROM " . MAIN_DB_PREFIX . "facture f ";
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "facture_extrafields fe ON f.rowid = fe.fk_object ";
$sql .= "WHERE fk_soc = " . $object->id . " AND fe.verifactu_csv_factura IS NOT NULL AND fe.verifactu_huella IS NOT NULL";
$sql .= " AND f.entity IN (" . getEntity('invoice') . ")";
$sql .= " AND f.entity = " . ((int) $conf->entity);

$resql = $this->db->query($sql);
if ($resql) {
Expand Down
5 changes: 3 additions & 2 deletions integrity.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@
// Check if invoices have already been sent to VeriFactu
$sql = "SELECT COUNT(*) as count FROM " . MAIN_DB_PREFIX . "facture f";
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "facture_extrafields fe ON f.rowid = fe.fk_object";
$sql .= " WHERE 1=1 ";
$sql .= " WHERE f.entity = " . ((int) $conf->entity);
$sql .= " AND fe.verifactu_csv_factura IS NOT NULL AND fe.verifactu_csv_factura != ''";
$sql .= " AND fe.verifactu_estado IS NOT NULL AND fe.verifactu_estado != ''";
$sql .= " LIMIT 1";
Expand All @@ -106,7 +106,8 @@
// Count invoices with VeriFactu errors
$sqlErrors = "SELECT COUNT(*) as count FROM " . MAIN_DB_PREFIX . "facture f";
$sqlErrors .= " INNER JOIN " . MAIN_DB_PREFIX . "facture_extrafields fe ON f.rowid = fe.fk_object";
$sqlErrors .= " WHERE fe.verifactu_error IS NOT NULL AND fe.verifactu_error != ''";
$sqlErrors .= " WHERE f.entity = " . ((int) $conf->entity);
$sqlErrors .= " AND fe.verifactu_error IS NOT NULL AND fe.verifactu_error != ''";
$sqlErrors .= " AND f.fk_statut > 0";
$sqlErrors .= " LIMIT 1";

Expand Down
3 changes: 3 additions & 0 deletions langs/en_US/verifactu.lang
Original file line number Diff line number Diff line change
Expand Up @@ -718,3 +718,6 @@ verifactu_OPERACION_Exenta = Exempt Operation
verifactu_OPERACION_ExentaTooltip = Exemption type (E1-E6) when operation is VAT/IGIC/IPSI exempt.
verifactu_INCIDENCIA = Incident
verifactu_INCIDENCIATooltip = Indicates technical incident (no electricity, no internet, system failure). Defaults to "N".
VERIFACTU_ENTITY_SHARING_NOT_ALLOWED=VeriFactu requires the MultiCompany resource "%s" to remain entity-specific, but it is shared from or with this entity. Disable sharing before continuing.
VERIFACTU_FOREIGN_ENTITY_INVOICE_NOT_ALLOWED=VeriFactu cannot validate an invoice that belongs to another entity.
VERIFACTU_TAX_IDENTITY_ALREADY_USED=The configured taxpayer identity is already used by VeriFactu entity %s. Each entity must represent a different taxpayer.
3 changes: 3 additions & 0 deletions langs/es_ES/verifactu.lang
Original file line number Diff line number Diff line change
Expand Up @@ -718,3 +718,6 @@ verifactu_OPERACION_Exenta = Operación Exenta
verifactu_OPERACION_ExentaTooltip = Tipo de exención (E1-E6) cuando la operación está exenta de IVA/IGIC/IPSI.
verifactu_INCIDENCIA = Incidencia
verifactu_INCIDENCIATooltip = Indica si hubo incidencia técnica (sin electricidad, sin internet, fallo del sistema). Por defecto "N".
VERIFACTU_ENTITY_SHARING_NOT_ALLOWED=Verifactu requiere que el recurso Multicompany "%s" sea independiente, pero está compartido por esta entidad o con esta entidad. Desactive la compartición antes de continuar.
VERIFACTU_FOREIGN_ENTITY_INVOICE_NOT_ALLOWED=Verifactu no puede validar una factura que pertenece a otra entidad.
VERIFACTU_TAX_IDENTITY_ALREADY_USED=La identidad fiscal configurada ya está siendo utilizada por la entidad Verifactu %s. Cada entidad debe representar un obligado tributario diferente.
21 changes: 21 additions & 0 deletions lib/functions/functions.certificates.php
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,27 @@ function getCertificateOptions()
return false;
}

// A certificate identifies the taxpayer and must not be reused by another
// VeriFactu-enabled MultiCompany entity.
$certificateContent = @file_get_contents($certPath);
$certificate = ($certificateContent !== false ? @openssl_x509_read($certificateContent) : false);
$fingerprint = ($certificate ? @openssl_x509_fingerprint($certificate, 'sha256') : false);
if (empty($fingerprint)) {
$GLOBALS['verifactu_cert_error'] = 'Unable to calculate the X.509 certificate fingerprint';
return false;
}

$conflictEntity = null;
if (!isVerifactuCertificateFingerprintUnique($fingerprint, (int) $conf->entity, $conflictEntity)) {
$GLOBALS['verifactu_cert_error'] = 'Certificate already used by VeriFactu entity ' . (int) $conflictEntity;
return false;
}
$result = dolibarr_set_const($db, 'VERIFACTU_CERTIFICATE_FINGERPRINT_SHA256', strtolower($fingerprint), 'chaine', 0, '', $conf->entity);
if ($result < 0) {
$GLOBALS['verifactu_cert_error'] = 'Unable to register the entity certificate fingerprint';
return false;
}

dol_syslog("VERIFACTU: Certificate options prepared successfully", LOG_DEBUG);
return $certOptions;
}
Expand Down
201 changes: 198 additions & 3 deletions lib/functions/functions.configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,213 @@ function calculateVerifactuIntegrityChecksums($moduleDirectory)
return hash('sha256', json_encode($files));
}

if (!function_exists('isEntitySharingAllowed')) {
/**
* Check that a MultiCompany element is not shared from or with an entity.
*
* The function is declared conditionally so a future Dolibarr core implementation
* can provide the same API without a function name collision.
*
* @param string $element Element key used by MultiCompany
* @param int|null $entity Entity to check, current entity by default
* @return bool True when the element is isolated
*/
function isEntitySharingAllowed($element, $entity = null)
{
global $conf, $db;

if (!isModEnabled('multicompany')) {
return true;
}
$sharingConstant = 'MULTICOMPANY_' . strtoupper($element) . '_SHARING_ENABLED';
if (!getDolGlobalInt($sharingConstant)) {
return true;
}

$entity = ($entity === null ? (int) $conf->entity : (int) $entity);
$sql = "SELECT rowid, options FROM " . MAIN_DB_PREFIX . "entity";
$resql = $db->query($sql);
if (!$resql) {
dol_syslog(__FUNCTION__ . ': unable to inspect MultiCompany sharing configuration: ' . $db->lasterror(), LOG_ERR);
return false;
}

$allowed = true;
while ($obj = $db->fetch_object($resql)) {
$options = (!empty($obj->options) ? json_decode($obj->options, true) : array());
$sharedEntities = $options['sharings'][$element] ?? array();
if (!is_array($sharedEntities)) {
continue;
}

// Outgoing sharing from the checked entity or incoming sharing to it.
if (((int) $obj->rowid === $entity && !empty($sharedEntities))
|| ((int) $obj->rowid !== $entity && in_array((string) $entity, array_map('strval', $sharedEntities), true))) {
$allowed = false;
break;
}
}

$db->free($resql);
return $allowed;
}
}

/**
* Check all resources that VeriFactu requires to be isolated per legal entity.
*
* @param int|null $entity Entity to check, current entity by default
* @param string|null $sharedElement Receives the first incompatible element
* @return bool True when every fiscal resource is isolated
*/
function isVerifactuEntityIsolated($entity = null, &$sharedElement = null)
{
global $conf;

$entity = ($entity === null ? (int) $conf->entity : (int) $entity);
$sharedElement = null;
foreach (array('invoice', 'invoicenumber', 'bankaccount') as $element) {
if (!isEntitySharingAllowed($element, $entity)) {
$sharedElement = $element;
return false;
}
}

return true;
}

/**
* Normalize a taxpayer identifier for comparisons between entities.
*
* @param string $taxId Tax identifier
* @return string Normalized identifier
*/
function normalizeVerifactuTaxIdentifier($taxId)
{
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim((string) $taxId)));
}

/**
* Check that no other VeriFactu-enabled entity uses the same taxpayer NIF.
*
* @param string $taxId Taxpayer NIF to check
* @param int|null $entity Current entity by default
* @param int|null $conflictEntity Receives the conflicting entity id
* @return bool True when the NIF is unique
*/
function isVerifactuTaxIdentityUnique($taxId, $entity = null, &$conflictEntity = null)
{
global $conf, $db;

$entity = ($entity === null ? (int) $conf->entity : (int) $entity);
$taxId = normalizeVerifactuTaxIdentifier($taxId);
$conflictEntity = null;
if ($taxId === '' || !isModEnabled('multicompany')) {
return true;
}

$sql = "SELECT taxpayer.entity, taxpayer.value";
$sql .= " FROM " . MAIN_DB_PREFIX . "const AS taxpayer";
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "const AS module ON module.entity = taxpayer.entity";
$sql .= " AND module.name = 'MAIN_MODULE_VERIFACTU' AND module.value = '1'";
$sql .= " WHERE taxpayer.name = 'VERIFACTU_HOLDER_NIF'";
$sql .= " AND taxpayer.entity <> " . $entity;
$resql = $db->query($sql);
if (!$resql) {
dol_syslog(__FUNCTION__ . ': unable to inspect taxpayer identities: ' . $db->lasterror(), LOG_ERR);
$conflictEntity = -1;
return false;
}

$unique = true;
while ($obj = $db->fetch_object($resql)) {
if (normalizeVerifactuTaxIdentifier($obj->value) === $taxId) {
$conflictEntity = (int) $obj->entity;
$unique = false;
break;
}
}
$db->free($resql);

return $unique;
}

/**
* Check that no other VeriFactu-enabled entity uses the same X.509 certificate.
*
* @param string $fingerprint SHA-256 certificate fingerprint
* @param int|null $entity Current entity by default
* @param int|null $conflictEntity Receives the conflicting entity id
* @return bool True when the certificate is unique
*/
function isVerifactuCertificateFingerprintUnique($fingerprint, $entity = null, &$conflictEntity = null)
{
global $conf, $db;

$entity = ($entity === null ? (int) $conf->entity : (int) $entity);
$fingerprint = strtolower(preg_replace('/[^a-f0-9]/i', '', (string) $fingerprint));
$conflictEntity = null;
if ($fingerprint === '' || !isModEnabled('multicompany')) {
return true;
}

$sql = "SELECT certificate.entity, certificate.value";
$sql .= " FROM " . MAIN_DB_PREFIX . "const AS certificate";
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "const AS module ON module.entity = certificate.entity";
$sql .= " AND module.name = 'MAIN_MODULE_VERIFACTU' AND module.value = '1'";
$sql .= " WHERE certificate.name = 'VERIFACTU_CERTIFICATE_FINGERPRINT_SHA256'";
$sql .= " AND certificate.entity <> " . $entity;
$resql = $db->query($sql);
if (!$resql) {
dol_syslog(__FUNCTION__ . ': unable to inspect certificate fingerprints: ' . $db->lasterror(), LOG_ERR);
$conflictEntity = -1;
return false;
}

$unique = true;
while ($obj = $db->fetch_object($resql)) {
$otherFingerprint = strtolower(preg_replace('/[^a-f0-9]/i', '', (string) $obj->value));
if ($otherFingerprint === $fingerprint) {
$conflictEntity = (int) $obj->entity;
$unique = false;
break;
}
}
$db->free($resql);

return $unique;
}

/**
* Gets the billing system configuration for AEAT
*
* @return array System configuration array
*/
function getSystemConfig()
{
global $conf, $dolibarr_main_instance_unique_id;
global $conf, $db, $dolibarr_main_instance_unique_id;

$issuerName = $conf->global->VERIFACTU_HOLDER_COMPANY_NAME ?? '';
$issuerNif = $conf->global->VERIFACTU_HOLDER_NIF ?? '';
$installationNumber = $dolibarr_main_instance_unique_id . '_' . $conf->entity;
$hasMultipleTaxpayers = false;

if (isModEnabled('multicompany')) {
$sql = "SELECT COUNT(DISTINCT e.rowid) as nb";
$sql .= " FROM " . MAIN_DB_PREFIX . "entity AS e";
$sql .= " INNER JOIN " . MAIN_DB_PREFIX . "const AS c ON c.entity = e.rowid";
$sql .= " AND c.name = 'MAIN_MODULE_VERIFACTU'";
$sql .= " AND c.value = '1'";
$sql .= " WHERE e.active = 1";
$resql = $db->query($sql);
if ($resql) {
$obj = $db->fetch_object($resql);
$hasMultipleTaxpayers = ((int) $obj->nb > 1);
$db->free($resql);
} else {
dol_syslog(__FUNCTION__ . ': unable to count active MultiCompany entities: ' . $db->lasterror(), LOG_WARNING);
}
}

return [
'NombreRazon' => $issuerName,
Expand All @@ -130,8 +325,8 @@ function getSystemConfig()
'Version' => (defined('DOL_VERSION') ? DOL_VERSION : '1.0.0'),
'NumeroInstalacion' => $installationNumber,
'TipoUsoPosibleSoloVerifactu' => 'S',
'TipoUsoPosibleMultiOT' => 'N',
'IndicadorMultiplesOT' => 'N',
'TipoUsoPosibleMultiOT' => ($hasMultipleTaxpayers ? 'S' : 'N'),
'IndicadorMultiplesOT' => ($hasMultipleTaxpayers ? 'S' : 'N'),
];
}

Expand Down
Loading