Currency rates as entities (#3543)

This commit is contained in:
Yurii Kuznietsov
2025-12-13 10:31:07 +02:00
committed by GitHub
parent f46d9c8e4e
commit 0ee8d1dc91
72 changed files with 2518 additions and 127 deletions

View File

@@ -0,0 +1,79 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\Acl\CurrencyRecordRate;
use Espo\Core\Acl\AccessEntityCREDChecker;
use Espo\Core\Acl\DefaultAccessChecker;
use Espo\Core\Acl\ScopeData;
use Espo\Core\Acl\Table;
use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency;
use Espo\Entities\CurrencyRecordRate;
use Espo\Entities\User;
use Espo\ORM\Entity;
/**
* @implements AccessEntityCREDChecker<CurrencyRecordRate>
*/
class AccessChecker implements AccessEntityCREDChecker
{
use DefaultAccessCheckerDependency;
public function __construct(
private DefaultAccessChecker $defaultAccessChecker,
) {}
public function checkCreate(User $user, ScopeData $data): bool
{
if ($data->getEdit() === Table::LEVEL_YES) {
return true;
}
return false;
}
public function checkDelete(User $user, ScopeData $data): bool
{
if ($data->getEdit() === Table::LEVEL_YES) {
return true;
}
return false;
}
public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool
{
return $this->checkCreate($user, $data);
}
public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool
{
return $this->checkDelete($user, $data);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\AppParams;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\Utils\NumberUtil;
use Espo\Tools\App\AppParam;
use stdClass;
/**
* @noinspection PhpUnused
*/
class CurrencyRates implements AppParam
{
private const int PRECISION = 6;
public function __construct(
private ConfigDataProvider $configDataProvider,
private NumberUtil $numberUtil,
) {}
public function get(): stdClass
{
$rates = $this->configDataProvider->getCurrencyRates()->toAssoc();
foreach ($rates as $code => $value) {
$rates[$code] = $this->numberUtil->format($value, self::PRECISION);
}
return (object) $rates;
}
}

View File

@@ -0,0 +1,63 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldProcessing\CurrencyRecord;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Entities\CurrencyRecord;
use Espo\ORM\Entity;
use ValueError;
/**
* @implements Loader<CurrencyRecord>
*/
class IsBase implements Loader
{
public function __construct(
private ConfigDataProvider $configDataProvider,
) {}
public function process(Entity $entity, Params $params): void
{
try {
$code = $entity->getCode();
} catch (ValueError) {
$entity->setIsBase(false);
return;
}
$isBase = $code === $this->configDataProvider->getBaseCurrency();
$entity->setIsBase($isBase);
}
}

View File

@@ -0,0 +1,60 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldProcessing\CurrencyRecord;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\Utils\Language;
use Espo\Entities\CurrencyRecord;
use Espo\ORM\Entity;
use ValueError;
/**
* @implements Loader<CurrencyRecord>
*/
class Label implements Loader
{
public function __construct(
private Language $defaultLanguage
) {}
public function process(Entity $entity, Params $params): void
{
try {
$code = $entity->getCode();
} catch (ValueError) {
return;
}
$name = $this->defaultLanguage->translateLabel($code, 'names', 'Currency');
$entity->setLabel($name);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldProcessing\CurrencyRecord;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Entities\CurrencyRecord;
use Espo\ORM\Entity;
use Espo\Tools\Currency\RateEntryProvider;
/**
* @implements Loader<CurrencyRecord>
*/
class Rate implements Loader
{
public function __construct(
private RateEntryProvider $rateEntryProvider,
private ConfigDataProvider $configDataProvider,
) {}
public function process(Entity $entity, Params $params): void
{
$rate = $entity->getCode() !== $this->configDataProvider->getBaseCurrency() ?
$this->rateEntryProvider->getCurrentRateEntry($entity)?->getRate() :
'1';
$entity->setRate($rate);
}
}

View File

@@ -0,0 +1,57 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldProcessing\CurrencyRecord;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Entities\CurrencyRecord;
use Espo\ORM\Entity;
use Espo\Tools\Currency\RateEntryProvider;
/**
* @implements Loader<CurrencyRecord>
*/
class RateDate implements Loader
{
public function __construct(
private RateEntryProvider $rateEntryProvider,
private ConfigDataProvider $configDataProvider,
) {}
public function process(Entity $entity, Params $params): void
{
$date = $entity->getCode() !== $this->configDataProvider->getBaseCurrency() ?
$this->rateEntryProvider->getCurrentRateEntry($entity)?->getDate() :
null;
$entity->setRateDate($date);
}
}

View File

@@ -0,0 +1,59 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldProcessing\CurrencyRecord;
use Espo\Core\FieldProcessing\Loader;
use Espo\Core\FieldProcessing\Loader\Params;
use Espo\Core\Utils\Metadata;
use Espo\Entities\CurrencyRecord;
use Espo\ORM\Entity;
use ValueError;
/**
* @implements Loader<CurrencyRecord>
*/
class Symbol implements Loader
{
public function __construct(
private Metadata $metadata,
) {}
public function process(Entity $entity, Params $params): void
{
try {
$code = $entity->getCode();
} catch (ValueError) {
return;
}
$symbol = $this->metadata->get("app.currency.symbolMap.$code");
$entity->setSymbol($symbol);
}
}

View File

@@ -0,0 +1,56 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\FieldValidators\CurrencyRecordRate\Record;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\FieldValidation\Validator;
use Espo\Core\FieldValidation\Validator\Data;
use Espo\Core\FieldValidation\Validator\Failure;
use Espo\Entities\CurrencyRecordRate;
use Espo\ORM\Entity;
/**
* @implements Validator<CurrencyRecordRate>
*/
class NonBase implements Validator
{
public function __construct(
private ConfigDataProvider $configDataProvider,
) {}
public function validate(Entity $entity, string $field, Data $data): ?Failure
{
if ($entity->getRecord()->getCode() === $this->configDataProvider->getBaseCurrency()) {
return Failure::create();
}
return null;
}
}

View File

@@ -0,0 +1,60 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\RecordHooks\CurrencyRecordRate;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Error;
use Espo\Core\Record\DeleteParams;
use Espo\Core\Record\Hook\DeleteHook;
use Espo\Entities\CurrencyRecordRate;
use Espo\ORM\Entity;
use Espo\Tools\Currency\Exceptions\NotEnabled;
use Espo\Tools\Currency\RecordManager;
/**
* @implements DeleteHook<CurrencyRecordRate>
*/
class AfterDelete implements DeleteHook
{
public function __construct(
private RecordManager $recordManager,
) {}
public function process(Entity $entity, DeleteParams $params): void
{
$code = $entity->getRecord()->getCode();
try {
$this->recordManager->syncCodeToConfig($code);
} catch (NotEnabled $e) {
throw new Conflict($e->getMessage(), previous: $e);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\RecordHooks\CurrencyRecordRate;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Entities\CurrencyRecordRate;
use Espo\ORM\Entity;
use Espo\Tools\Currency\Exceptions\NotEnabled;
use Espo\Tools\Currency\RecordManager;
/**
* @implements SaveHook<CurrencyRecordRate>
*/
class AfterSave implements SaveHook
{
public function __construct(
private RecordManager $recordManager,
) {}
public function process(Entity $entity): void
{
$code = $entity->getRecord()->getCode();
try {
$this->recordManager->syncCodeToConfig($code);
} catch (NotEnabled $e) {
throw new Conflict($e->getMessage(), previous: $e);
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\RecordHooks\CurrencyRecordRate;
use Espo\Core\Exceptions\Conflict;
use Espo\Core\Exceptions\Error\Body;
use Espo\Core\Record\Hook\SaveHook;
use Espo\Entities\CurrencyRecordRate;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* @implements SaveHook<CurrencyRecordRate>
*/
class BeforeSaveValidation implements SaveHook
{
public function __construct(
private EntityManager $entityManager,
) {}
public function process(Entity $entity): void
{
$this->validateDate($entity);
}
/**
* @throws Conflict
*/
private function validateDate(CurrencyRecordRate $entity): void
{
if (!$entity->isNew()) {
return;
}
$recordId = $entity->getRecord()->getId();
$date = $entity->getDate();
$one = $this->entityManager
->getRDBRepositoryByClass(CurrencyRecordRate::class)
->where([
CurrencyRecordRate::ATTR_RECORD_ID => $recordId,
CurrencyRecordRate::FIELD_DATE => $date->toString(),
])
->findOne();
if ($one) {
throw Conflict::createWithBody(
'rateOnDateAlreadyExists',
Body::create()->withMessageTranslation('rateOnDateAlreadyExists', 'Currency')
);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Classes\Select\CurrencyRecord\PrimaryFilters;
use Espo\Core\Select\Primary\Filter;
use Espo\Entities\CurrencyRecord;
use Espo\ORM\Query\SelectBuilder;
class Active implements Filter
{
public function apply(SelectBuilder $queryBuilder): void
{
$queryBuilder->where([
CurrencyRecord::FIELD_STATUS => CurrencyRecord::STATUS_ACTIVE,
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Controllers\Record;
use Espo\Core\Exceptions\Forbidden;
/**
* @noinspection PhpUnused
*/
class CurrencyRecord extends Record
{
public function postActionCreate(Request $request, Response $response): never
{
throw new Forbidden();
}
public function putActionUpdate(Request $request, Response $response): never
{
throw new Forbidden();
}
public function deleteActionDelete(Request $request, Response $response): never
{
throw new Forbidden();
}
}

View File

@@ -0,0 +1,38 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Controllers;
use Espo\Core\Controllers\RecordBase;
/**
* @noinspection PhpUnused
*/
class CurrencyRecordRate extends RecordBase
{}

View File

@@ -32,6 +32,9 @@ namespace Espo\Core\Rebuild\Actions;
use Espo\Core\Rebuild\RebuildAction;
use Espo\Core\Utils\Currency\DatabasePopulator;
/**
* @noinspection PhpUnused
*/
class CurrencyRates implements RebuildAction
{
public function __construct(private DatabasePopulator $databasePopulator) {}

View File

@@ -0,0 +1,48 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Rebuild\Actions;
use Espo\Core\Rebuild\RebuildAction;
use Espo\Tools\Currency\RecordManager;
/**
* @noinspection PhpUnused
*/
class SyncCurrency implements RebuildAction
{
public function __construct(
private RecordManager $recordManager,
) {}
public function process(): void
{
$this->recordManager->sync();
}
}

View File

@@ -33,30 +33,28 @@ use Espo\Core\Currency\ConfigDataProvider;
use Espo\Entities\Currency;
use Espo\ORM\EntityManager;
use Espo\ORM\Name\Attribute;
use Espo\Tools\Currency\RateEntryProvider;
use Espo\Tools\Currency\Exceptions\NotEnabled;
/**
* Populates currency rates into database.
*/
class DatabasePopulator
{
private const PRECISION = 5;
private const int PRECISION = 6;
public function __construct(
private EntityManager $entityManager,
private ConfigDataProvider $configDataProvider,
private RateEntryProvider $rateEntryProvider,
) {}
public function process(): void
{
$defaultCurrency = $this->configDataProvider->getDefaultCurrency();
$baseCurrency = $this->configDataProvider->getBaseCurrency();
$currencyRates = $this->configDataProvider->getCurrencyRates()->toAssoc();
if ($defaultCurrency !== $baseCurrency) {
$currencyRates = $this->exchangeRates($baseCurrency, $defaultCurrency, $currencyRates);
}
$currencyRates[$defaultCurrency] = 1.00;
$currencyRates = $this->prepareRates($defaultCurrency, $baseCurrency);
$this->entityManager->getTransactionManager()->start();
@@ -96,4 +94,28 @@ class DatabasePopulator
return $exchangedRates;
}
/**
* @return array<string, float>
*/
private function prepareRates(string $defaultCurrency, string $baseCurrency): array
{
$currencyRates = [];
foreach ($this->configDataProvider->getCurrencyList() as $itCode) {
try {
$currencyRates[$itCode] = (float) ($this->rateEntryProvider->getRate($itCode) ?? 1);
} catch (NotEnabled) {
continue;
}
}
if ($defaultCurrency !== $baseCurrency) {
$currencyRates = $this->exchangeRates($baseCurrency, $defaultCurrency, $currencyRates);
}
$currencyRates[$defaultCurrency] = 1.00;
return $currencyRates;
}
}

View File

@@ -263,7 +263,7 @@ class Currency implements FieldConverter
{
$name = $fieldDefs->getName();
$alias = $name . 'CurrencyRate';
$alias = $name . 'CurrencyRecordRate';
$leftJoins = [
[
'Currency',

View File

@@ -0,0 +1,92 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Entities;
use Espo\Core\Field\Date;
use Espo\Core\ORM\Entity;
class CurrencyRecord extends Entity
{
public const string ENTITY_TYPE = 'CurrencyRecord';
public const string FIELD_STATUS = 'status';
public const string FIELD_CODE = 'code';
public const string STATUS_ACTIVE = 'Active';
public const string STATUS_INACTIVE = 'Inactive';
public function getCode(): string
{
return $this->get(self::FIELD_CODE);
}
public function setCode(string $code): self
{
return $this->set(self::FIELD_CODE, $code);
}
public function getStatus(): string
{
return $this->get(self::FIELD_STATUS);
}
public function setStatus(string $status): self
{
return $this->set(self::FIELD_STATUS, $status);
}
public function setLabel(?string $label): self
{
return $this->set('label', $label);
}
public function setSymbol(?string $label): self
{
return $this->set('symbol', $label);
}
public function setIsBase(bool $isBase): self
{
return $this->set('isBase', $isBase);
}
/**
* @param ?numeric-string $rate
*/
public function setRate(?string $rate): self
{
return $this->set('rate', $rate);
}
public function setRateDate(?Date $date): self
{
return $this->setValueObject('rateDate', $date);
}
}

View File

@@ -0,0 +1,100 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Entities;
use Espo\Core\Field\Date;
use Espo\Core\ORM\Entity;
use ValueError;
class CurrencyRecordRate extends Entity
{
public const string ENTITY_TYPE = 'CurrencyRecordRate';
public const string FIELD_DATE = 'date';
public const string FIELD_BASE_CODE = 'baseCode';
public const string FIELD_RATE = 'rate';
public const string FIELD_RECORD = 'record';
public const string ATTR_RECORD_ID = 'recordId';
/**
* @return numeric-string
*/
public function getRate(): string
{
/** @var numeric-string */
return $this->get(self::FIELD_RATE) ?? '1';
}
/**
* @param numeric-string $rate
*/
public function setRate(string $rate): self
{
return $this->set(self::FIELD_RATE, $rate);
}
public function setBaseCode(string $code): self
{
return $this->set(self::FIELD_BASE_CODE, $code);
}
public function setRecord(CurrencyRecord $record): self
{
return $this->setRelatedLinkOrEntity(self::FIELD_RECORD, $record);
}
public function setDate(Date $date): self
{
return $this->setValueObject(self::DATE, $date);
}
public function getRecord(): CurrencyRecord
{
$record = $this->relations->getOne(self::FIELD_RECORD);
if (!$record instanceof CurrencyRecord) {
throw new ValueError("No record.");
}
return $record;
}
public function getDate(): Date
{
$date = $this->getValueObject(self::FIELD_DATE);
if (!$date instanceof Date) {
throw new ValueError("No date.");
}
return $date;
}
}

View File

@@ -0,0 +1,56 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Hooks\CurrencyRecordRate;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\Hook\Hook\BeforeSave;
use Espo\Entities\CurrencyRecordRate;
use Espo\ORM\Entity;
use Espo\ORM\Repository\Option\SaveOptions;
/**
* @implements BeforeSave<CurrencyRecordRate>
*/
class SetFields implements BeforeSave
{
public function __construct(
private ConfigDataProvider $configDataProvider,
) {}
public function beforeSave(Entity $entity, SaveOptions $options): void
{
if ($entity->isNew()) {
$baseCode = $this->configDataProvider->getBaseCurrency();
$entity->setBaseCode($baseCode);
}
}
}

View File

@@ -164,5 +164,8 @@
"ZAR":"South African Rand",
"ZMW":"Zambian Kwacha",
"ZWL": "Zimbabwe Dollar"
},
"messages": {
"rateOnDateAlreadyExists": "A rate on this date already exists."
}
}
}

View File

@@ -0,0 +1,20 @@
{
"fields": {
"code": "Code",
"status": "Status",
"label": "Label",
"symbol": "Symbol",
"isBase": "Is Base",
"rate": "Rate",
"rateDate": "Rate Date"
},
"links": {
"rates": "Rates"
},
"options": {
"status": {
"Active": "Active",
"Inactive": "Inactive"
}
}
}

View File

@@ -0,0 +1,15 @@
{
"labels": {
"Create CurrencyRecordRate": "Create Exchange Rate"
},
"fields": {
"record": "Currency",
"baseCode": "Base",
"date": "Date",
"rate": "Rate",
"number": "Number"
},
"links": {
"record": "Currency"
}
}

View File

@@ -68,7 +68,9 @@
"AppSecret": "App Secret",
"OAuthProvider": "OAuth Provider",
"OAuthAccount": "OAuth Account",
"OpenApi": "OpenAPI"
"OpenApi": "OpenAPI",
"CurrencyRecord": "Currency Record",
"CurrencyRecordRate": "Currency Rate"
},
"scopeNamesPlural": {
"Note": "Notes",
@@ -127,7 +129,9 @@
"AppSecret": "App Secrets",
"OAuthProvider": "OAuth Providers",
"OAuthAccount": "OAuth Accounts",
"OpenApi": "OpenAPI"
"OpenApi": "OpenAPI",
"CurrencyRecord": "Currencies",
"CurrencyRecordRate": "Currency Rates"
},
"labels": {
"Previous Page": "Previous Page",

View File

@@ -0,0 +1,5 @@
{
"rates": {
"index": 0
}
}

View File

@@ -0,0 +1,30 @@
[
{
"rows": [
[
{
"name": "code"
},
{
"name": "status"
}
],
[
{
"name": "label"
},
{
"name": "isBase"
}
],
[
{
"name": "rate"
},
{
"name": "rateDate"
}
]
]
}
]

View File

@@ -0,0 +1,30 @@
[
{
"rows": [
[
{
"name": "code"
},
{
"name": "status"
}
],
[
{
"name": "label"
},
{
"name": "isBase"
}
],
[
{
"name": "rate"
},
{
"name": "rateDate"
}
]
]
}
]

View File

@@ -0,0 +1,31 @@
[
{
"name": "code",
"link": true,
"width": 16
},
{
"name": "label",
"width": 16
},
{
"name": "symbol",
"width": 10
},
{
"name": "status",
"width": 12
},
{
"name": "isBase",
"width": 10
},
{
"name": "rateDate",
"width": 12
},
{
"name": "rate",
"align": "right"
}
]

View File

@@ -0,0 +1,23 @@
[
{
"name": "code",
"link": true,
"width": 18
},
{
"name": "label",
"width": 28
},
{
"name": "status",
"width": 20
},
{
"name": "isBase",
"width": 15
},
{
"name": "rate",
"align": "right"
}
]

View File

@@ -0,0 +1,22 @@
[
{
"rows": [
[
{
"name": "record"
},
{
"name": "baseCode"
}
],
[
{
"name": "date"
},
{
"name": "rate"
}
]
]
}
]

View File

@@ -0,0 +1,22 @@
[
{
"rows": [
[
{
"name": "record"
},
{
"name": "baseCode"
}
],
[
{
"name": "date"
},
{
"name": "rate"
}
]
]
}
]

View File

@@ -0,0 +1,3 @@
[
"date"
]

View File

@@ -0,0 +1,18 @@
[
{
"name": "date",
"width": 25
},
{
"name": "record",
"width": 25
},
{
"name": "baseCode",
"width": 25
},
{
"name": "rate",
"align": "right"
}
]

View File

@@ -0,0 +1,10 @@
[
{
"name": "date",
"width": 25
},
{
"name": "rate",
"align": "right"
}
]

View File

@@ -0,0 +1,18 @@
[
{
"name": "date",
"width": 25
},
{
"name": "record",
"width": 25
},
{
"name": "baseCode",
"width": 25
},
{
"name": "rate",
"align": "right"
}
]

View File

@@ -7,9 +7,8 @@
]
},
{
"label": "Currency Rates",
"rows": [
[{"name": "baseCurrency"}, {"name": "currencyRates"}]
[{"name": "baseCurrency"}, false]
]
}
]

View File

@@ -0,0 +1,3 @@
{
"accessCheckerClassName": "Espo\\Classes\\Acl\\CurrencyRecordRate\\AccessChecker"
}

View File

@@ -63,7 +63,9 @@
"ImportError": "Import",
"ImportEml": "Import",
"WorkingTimeRange": "WorkingTimeCalendar",
"Stream": true
"Stream": true,
"CurrencyRecord": "boolean:Currency",
"CurrencyRecordRate": "Currency"
},
"fieldLevel": {
},
@@ -155,7 +157,11 @@
"delete": "all"
},
"Stream": true,
"ImportEml": "Import"
"ImportEml": "Import",
"CurrencyRecordRate": {
"read": "yes",
"edit": "yes"
}
}
},
"valuePermissionList": [

View File

@@ -34,7 +34,8 @@
"label": "Currency",
"iconClass": "fas fa-euro-sign",
"description": "currency",
"recordView": "views/admin/currency"
"recordView": "views/admin/currency",
"view": "views/admin/currency-main"
},
{
"url": "#Admin/notifications",

View File

@@ -7,5 +7,8 @@
},
"addressCountryData": {
"className": "Espo\\Classes\\AppParams\\AddressCountryData"
},
"currencyRates": {
"className": "Espo\\Classes\\AppParams\\CurrencyRates"
}
}

View File

@@ -168,6 +168,9 @@
},
"baselineRole": {
"level": "admin"
},
"currencyRates": {
"readOnly": true
}
}
}

View File

@@ -2,10 +2,11 @@
"actionClassNameList": [
"Espo\\Core\\Rebuild\\Actions\\AddSystemUser",
"Espo\\Core\\Rebuild\\Actions\\AddSystemData",
"Espo\\Core\\Rebuild\\Actions\\CurrencyRates",
"Espo\\Core\\Rebuild\\Actions\\ScheduledJobs",
"Espo\\Core\\Rebuild\\Actions\\ConfigMetadataCheck",
"Espo\\Core\\Rebuild\\Actions\\GenerateInstanceId",
"Espo\\Core\\Rebuild\\Actions\\SetIntegrationDefaults"
"Espo\\Core\\Rebuild\\Actions\\SetIntegrationDefaults",
"Espo\\Core\\Rebuild\\Actions\\SyncCurrency",
"Espo\\Core\\Rebuild\\Actions\\CurrencyRates"
]
}

View File

@@ -0,0 +1,28 @@
{
"controller": "controllers/record",
"createDisabled": true,
"editDisabled": true,
"removeDisabled": true,
"nameAttribute": "code",
"defaultFilterData": {
"primary": "active"
},
"filterList": [
"active"
],
"viewSetupHandlers": {
"record/detail": [
"handlers/currency-record/record-detail"
]
},
"relationshipPanels": {
"rates": {
"layout": "listForRecord",
"createAttributeMap": {
"code": "recordName"
},
"view": "views/currency-record/record/panels/rates",
"unlinkDisabled": true
}
}
}

View File

@@ -0,0 +1,6 @@
{
"controller": "controllers/record",
"modelDefaultsPreparator": "handlers/currency-record-rate/default-preparator",
"acl": "acl/currency-record-rate",
"textFilterDisabled": true
}

View File

@@ -0,0 +1,87 @@
{
"fields": {
"code": {
"type": "varchar",
"maxLength": 3,
"required": true,
"readOnly": true,
"index": true
},
"status": {
"type": "enum",
"options": [
"Active",
"Inactive"
],
"default": "Active",
"maxLength": 8,
"style": {
"Inactive": "info"
}
},
"label": {
"type": "varchar",
"notStorable": true,
"readOnly": true,
"orderDisabled": true,
"loaderClassName": "Espo\\Classes\\FieldProcessing\\CurrencyRecord\\Label"
},
"symbol": {
"type": "varchar",
"notStorable": true,
"readOnly": true,
"orderDisabled": true,
"loaderClassName": "Espo\\Classes\\FieldProcessing\\CurrencyRecord\\Symbol"
},
"rateDate": {
"type": "date",
"readOnly": true,
"notStorable": true,
"orderDisabled": true,
"loaderClassName": "Espo\\Classes\\FieldProcessing\\CurrencyRecord\\RateDate"
},
"rate": {
"type": "decimal",
"readOnly": true,
"notStorable": true,
"orderDisabled": true,
"decimalPlaces": 6,
"loaderClassName": "Espo\\Classes\\FieldProcessing\\CurrencyRecord\\Rate",
"view": "views/currency-record-rate/fields/rate"
},
"isBase": {
"type": "bool",
"readOnly": true,
"notStorable": true,
"orderDisabled": true,
"loaderClassName": "Espo\\Classes\\FieldProcessing\\CurrencyRecord\\IsBase"
}
},
"links": {
"rates": {
"type": "hasMany",
"entity": "CurrencyRecordRate",
"foreign": "record",
"readOnly": true,
"orderBy": "date",
"order": "desc"
}
},
"indexes": {
"codeDeleteId": {
"type": "unique",
"columns": [
"code",
"deleteId"
]
}
},
"deleteId": true,
"collection": {
"textFilterFields": [
"code"
],
"orderBy": "code",
"order": "asc"
}
}

View File

@@ -0,0 +1,84 @@
{
"fields": {
"record": {
"type": "link",
"required": true,
"readOnlyAfterCreate": true,
"validatorClassNameList": [
"Espo\\Classes\\FieldValidators\\CurrencyRecordRate\\Record\\NonBase"
]
},
"baseCode": {
"type": "varchar",
"readOnly": true,
"maxLength": 3
},
"date": {
"type": "date",
"required": true,
"readOnlyAfterCreate": true,
"default": "javascript: return this.dateTime.getToday();"
},
"rate": {
"type": "decimal",
"decimalPlaces": 6,
"min": 0.0001,
"precision": 15,
"scale": 8,
"required": true,
"audited": true,
"view": "views/currency-record-rate/fields/rate"
},
"createdAt": {
"type": "datetime",
"readOnly": true
},
"modifiedAt": {
"type": "datetime",
"readOnly": true
},
"createdBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user",
"fieldManagerParamList": []
},
"modifiedBy": {
"type": "link",
"readOnly": true,
"view": "views/fields/user",
"fieldManagerParamList": []
}
},
"links": {
"record": {
"type": "belongsTo",
"entity": "CurrencyRecord",
"foreignName": "code"
},
"createdBy": {
"type": "belongsTo",
"entity": "User"
},
"modifiedBy": {
"type": "belongsTo",
"entity": "User"
}
},
"indexes": {
"recordIdBaseCodeDate": {
"type": "unique",
"columns": [
"recordId",
"baseCode",
"date",
"deleteId"
]
}
},
"deleteId": true,
"collection": {
"orderBy": "date",
"order": "desc"
}
}

View File

@@ -191,8 +191,7 @@
"view": "views/settings/fields/default-currency"
},
"currencyRates": {
"type": "base",
"view": "views/settings/fields/currency-rates"
"type": "base"
},
"outboundEmailIsShared": {
"type": "bool",

View File

@@ -0,0 +1,14 @@
{
"fields": {
"baseCode": {
"visible": {
"conditionGroup": [
{
"type": "isNotEmpty",
"attribute": "id"
}
]
}
}
}
}

View File

@@ -0,0 +1,17 @@
{
"beforeCreateHookClassNameList": [
"Espo\\Classes\\RecordHooks\\CurrencyRecordRate\\BeforeSaveValidation"
],
"beforeUpdateHookClassNameList": [
"Espo\\Classes\\RecordHooks\\CurrencyRecordRate\\BeforeSaveValidation"
],
"afterCreateHookClassNameList": [
"Espo\\Classes\\RecordHooks\\CurrencyRecordRate\\AfterSave"
],
"afterUpdateHookClassNameList": [
"Espo\\Classes\\RecordHooks\\CurrencyRecordRate\\AfterSave"
],
"afterDeleteHookClassNameList": [
"Espo\\Classes\\RecordHooks\\CurrencyRecordRate\\AfterDelete"
]
}

View File

@@ -0,0 +1,4 @@
{
"entity": true,
"tab": true
}

View File

@@ -0,0 +1,4 @@
{
"entity": true,
"preserveAuditLog": true
}

View File

@@ -0,0 +1,11 @@
{
"primaryFilterClassNameMap": {
"active": "Espo\\Classes\\Select\\CurrencyRecord\\PrimaryFilters\\Active"
},
"selectAttributesDependencyMap": {
"id": [
"code"
]
},
"accessControlFilterResolverClassName": "Espo\\Core\\Select\\AccessControl\\FilterResolvers\\Boolean"
}

View File

@@ -0,0 +1,10 @@
{
"selectAttributesDependencyMap": {
"id": [
"recordId",
"recordName",
"baseCode"
]
},
"accessControlFilterResolverClassName": "Espo\\Core\\Select\\AccessControl\\FilterResolvers\\Boolean"
}

View File

@@ -35,7 +35,6 @@ use Espo\Entities\Email;
use Espo\Entities\Settings;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Error;
use Espo\Core\Exceptions\Forbidden;
@@ -50,10 +49,9 @@ use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Core\Utils\Config\Access;
use Espo\Entities\Portal;
use Espo\Repositories\Portal as PortalRepository;
use Espo\Tools\Currency\RecordManager as CurrencyRecordManager;
use stdClass;
class SettingsService
@@ -74,6 +72,8 @@ class SettingsService
private Config\SystemConfig $systemConfig,
private EmailConfigDataProvider $emailConfigDataProvider,
private Acl\Cache\Clearer $aclCacheClearer,
private CurrencyRecordManager $currencyRecordManager,
private CurrencyDatabasePopulator $currencyDatabasePopulator,
) {}
/**
@@ -243,8 +243,9 @@ class SettingsService
$this->aclCacheClearer->clearForAllInternalUsers();
}
if (isset($data->defaultCurrency) || isset($data->baseCurrency) || isset($data->currencyRates)) {
$this->populateDatabaseWithCurrencyRates();
if (isset($data->baseCurrency) || isset($data->currencyList) || isset($data->defaultCurrency)) {
$this->currencyRecordManager->sync();
$this->currencyDatabasePopulator->process();
}
}
@@ -340,11 +341,6 @@ class SettingsService
}
}
private function populateDatabaseWithCurrencyRates(): void
{
$this->injectableFactory->create(CurrencyDatabasePopulator::class)->process();
}
private function filterData(stdClass $data): void
{
$user = $this->applicationState->getUser();

View File

@@ -0,0 +1,35 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency\Exceptions;
use Exception;
class NotEnabled extends Exception
{}

View File

@@ -0,0 +1,163 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\Field\Date;
use Espo\Core\Utils\DateTime;
use Espo\Entities\CurrencyRecord;
use Espo\Entities\CurrencyRecordRate;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\Part\Order;
use Espo\Tools\Currency\Exceptions\NotEnabled;
use WeakMap;
/**
* @since 9.3.0
* @internal
*/
class RateEntryProvider
{
/** @var WeakMap<CurrencyRecord, ?CurrencyRecordRate> */
private WeakMap $map;
public function __construct(
private ConfigDataProvider $configDataProvider,
private EntityManager $entityManager,
private DateTime $dateTime,
) {
$this->map = new WeakMap();
}
public function getCurrentRateEntry(CurrencyRecord $record): ?CurrencyRecordRate
{
if (!$this->map->offsetExists($record)) {
$this->map[$record] = $this->entityManager
->getRDBRepositoryByClass(CurrencyRecordRate::class)
->where([
CurrencyRecordRate::ATTR_RECORD_ID => $record->getId(),
CurrencyRecordRate::FIELD_BASE_CODE => $this->configDataProvider->getBaseCurrency(),
CurrencyRecordRate::FIELD_DATE . '<=' => $this->dateTime->getToday()->toString(),
])
->order(CurrencyRecordRate::FIELD_DATE, Order::DESC)
->findOne();
}
return $this->map[$record];
}
/**
* @throws NotEnabled
*/
public function prepareNew(string $code, Date $date): CurrencyRecordRate
{
$record = $this->getRecordByCode($code);
$entry = $this->entityManager->getRDBRepositoryByClass(CurrencyRecordRate::class)->getNew();
$entry
->setRecord($record)
->setDate($date);
return $entry;
}
/**
* Get rate against the base currency by a record.
*
* @return ?numeric-string
*/
private function getRateByRecord(CurrencyRecord $record): ?string
{
$rateEntry = $this->entityManager
->getRDBRepositoryByClass(CurrencyRecordRate::class)
->where([
CurrencyRecordRate::ATTR_RECORD_ID => $record->getId(),
CurrencyRecordRate::FIELD_BASE_CODE => $this->configDataProvider->getBaseCurrency(),
CurrencyRecordRate::FIELD_DATE . '<=' => $this->dateTime->getToday()->toString(),
])
->order(CurrencyRecordRate::FIELD_DATE, Order::DESC)
->findOne();
return $rateEntry?->getRate();
}
/**
* Get rate against the base currency.
*
* @param string $code
* @return ?numeric-string
* @throws NotEnabled
*/
public function getRate(string $code): ?string
{
$record = $this->getRecordByCode($code);
return $this->getRateByRecord($record);
}
/**
* @throws NotEnabled
*/
public function getRateEntryOnDate(string $code, Date $date): ?CurrencyRecordRate
{
$record = $this->getRecordByCode($code);
return $this->entityManager
->getRDBRepositoryByClass(CurrencyRecordRate::class)
->where([
CurrencyRecordRate::ATTR_RECORD_ID => $record->getId(),
CurrencyRecordRate::FIELD_BASE_CODE => $this->configDataProvider->getBaseCurrency(),
CurrencyRecordRate::FIELD_DATE => $date->toString(),
])
->order(CurrencyRecordRate::FIELD_DATE, Order::DESC)
->findOne();
}
/**
* @throws NotEnabled
*/
private function getRecordByCode(string $code): CurrencyRecord
{
$record = $this->entityManager
->getRDBRepositoryByClass(CurrencyRecord::class)
->where([
CurrencyRecord::FIELD_CODE => $code,
CurrencyRecord::FIELD_STATUS => CurrencyRecord::STATUS_ACTIVE,
])
->findOne();
if (!$record) {
throw new NotEnabled("Currency $code is not enabled.");
}
return $record;
}
}

View File

@@ -35,18 +35,23 @@ use Espo\Core\Currency\Rates;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Acl;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Core\Utils\Currency\DatabasePopulator;
use Espo\Core\Utils\DateTime;
use Espo\ORM\EntityManager;
use RuntimeException;
class RateService
{
private const SCOPE = 'Currency';
private const string SCOPE = 'Currency';
public function __construct(
private ConfigWriter $configWriter,
private Acl $acl,
private DatabasePopulator $databasePopulator,
private ConfigDataProvider $configDataProvider
private ConfigDataProvider $configDataProvider,
private RecordManager $recordManager,
private RateEntryProvider $rateEntryProvider,
private DateTime $dateTime,
private EntityManager $entityManager,
) {}
/**
@@ -54,13 +59,7 @@ class RateService
*/
public function get(): Rates
{
if (!$this->acl->check(self::SCOPE)) {
throw new Forbidden();
}
if ($this->acl->getLevel(self::SCOPE, Table::ACTION_READ) !== Table::LEVEL_YES) {
throw new Forbidden();
}
$this->checkReadAccess();
$rates = Rates::create($this->configDataProvider->getBaseCurrency());
@@ -76,6 +75,62 @@ class RateService
* @throws Forbidden
*/
public function set(Rates $rates): void
{
$this->checkEditAccess();
$codeList = $this->configDataProvider->getCurrencyList();
$base = $this->configDataProvider->getBaseCurrency();
foreach ($rates->toAssoc() as $code => $value) {
if ($value < 0) {
throw new BadRequest("Bad value.");
}
if (!in_array($code, $codeList) || $code === $base) {
continue;
}
$this->writeOne($code, $value);
}
$this->recordManager->syncToConfig();
$this->databasePopulator->process();
}
private function writeOne(string $code, float $value): void
{
$date = $this->dateTime->getToday();
try {
$rateEntry = $this->rateEntryProvider->getRateEntryOnDate($code, $date) ??
$this->rateEntryProvider->prepareNew($code, $date);
} catch (Exceptions\NotEnabled $e) {
throw new RuntimeException($e->getMessage(), previous: $e);
}
$rateEntry->setRate((string) $value);
$this->entityManager->saveEntity($rateEntry);
}
/**
* @throws Forbidden
*/
private function checkReadAccess(): void
{
if (!$this->acl->check(self::SCOPE)) {
throw new Forbidden();
}
if ($this->acl->getLevel(self::SCOPE, Table::ACTION_READ) !== Table::LEVEL_YES) {
throw new Forbidden();
}
}
/**
* @throws Forbidden
*/
private function checkEditAccess(): void
{
if (!$this->acl->check(self::SCOPE)) {
throw new Forbidden();
@@ -84,39 +139,5 @@ class RateService
if ($this->acl->getLevel(self::SCOPE, Table::ACTION_EDIT) !== Table::LEVEL_YES) {
throw new Forbidden();
}
$currencyList = $this->configDataProvider->getCurrencyList();
$baseCurrency = $this->configDataProvider->getBaseCurrency();
$set = [];
foreach ($rates->toAssoc() as $key => $value) {
if ($value < 0) {
throw new BadRequest("Bad value.");
}
if (!in_array($key, $currencyList)) {
continue;
}
if ($key === $baseCurrency) {
continue;
}
$set[$key] = $value;
}
foreach ($currencyList as $currency) {
if ($currency === $baseCurrency) {
continue;
}
$set[$currency] ??= $this->configDataProvider->getCurrencyRate($currency);
}
$this->configWriter->set('currencyRates', $set);
$this->configWriter->save();
$this->databasePopulator->process();
}
}

View File

@@ -0,0 +1,144 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Tools\Currency;
use Espo\Core\Currency\ConfigDataProvider;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Entities\CurrencyRecord;
use Espo\ORM\EntityManager;
use Espo\ORM\Query\UpdateBuilder;
use Espo\Tools\Currency\Exceptions\NotEnabled;
class RecordManager
{
public function __construct(
private ConfigDataProvider $configDataProvider,
private EntityManager $entityManager,
private ConfigWriter $configWriter,
private RateEntryProvider $rateEntryProvider,
) {}
public function sync(): void
{
$this->entityManager->getTransactionManager()->run(function () {
$this->syncInTransaction();
});
$this->syncToConfig();
}
private function syncInTransaction(): void
{
$this->lock();
foreach ($this->configDataProvider->getCurrencyList() as $code) {
$this->syncCode($code);
}
$this->deactivateNotListed();
}
private function syncCode(string $code): void
{
$record = $this->entityManager
->getRDBRepositoryByClass(CurrencyRecord::class)
->where([CurrencyRecord::FIELD_CODE => $code])
->findOne();
if (!$record) {
$record = $this->entityManager->getRDBRepositoryByClass(CurrencyRecord::class)->getNew();
$record->setCode($code);
}
$record->setStatus(CurrencyRecord::STATUS_ACTIVE);
$this->entityManager->saveEntity($record);
}
private function deactivateNotListed(): void
{
$list = $this->configDataProvider->getCurrencyList();
$updateQuery = UpdateBuilder::create()
->in(CurrencyRecord::ENTITY_TYPE)
->set([
CurrencyRecord::FIELD_STATUS => CurrencyRecord::STATUS_INACTIVE,
])
->where([
CurrencyRecord::FIELD_CODE . '!=' => $list,
])
->build();
$this->entityManager->getQueryExecutor()->execute($updateQuery);
}
private function lock(): void
{
$this->entityManager
->getRDBRepositoryByClass(CurrencyRecord::class)
->forUpdate()
->sth()
->find();
}
public function syncToConfig(): void
{
$rates = [];
foreach ($this->configDataProvider->getCurrencyList() as $code) {
try {
$rate = $this->rateEntryProvider->getRate($code) ?? '1.0';
} catch (Exceptions\NotEnabled) {
continue;
}
$rates[$code] = (float) $rate;
}
$this->configWriter->set('currencyRates', $rates);
$this->configWriter->save();
}
/**
* @throws NotEnabled
*/
public function syncCodeToConfig(string $code): void
{
$rates = $this->configDataProvider->getCurrencyRates()->toAssoc();
$rate = $this->rateEntryProvider->getRate($code) ?? '1.0';
$rates[$code] = (float) $rate;
$this->configWriter->set('currencyRates', $rates);
$this->configWriter->save();
}
}

View File

@@ -8,6 +8,7 @@
data-currency="{{@key}}"
value="{{./this}}"
style="text-align: right;"
readonly="readonly"
>
</span>
<span class="input-group-addon radius-right" style="width: 22%">{{../baseCurrency}}</span>

View File

@@ -0,0 +1,40 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import Acl from 'acl';
export default class extends Acl {
checkScope(data, action, precise, entityAccessData) {
if (action === 'create' || action === 'delete') {
action = 'edit';
}
return super.checkScope(data, action, precise, entityAccessData);
}
}

View File

@@ -32,6 +32,7 @@ import SettingsEditView from 'views/settings/edit';
import AdminIndexView from 'views/admin/index';
import {inject} from 'di';
import Language from 'language';
import EditView from 'views/edit';
class AdminController extends Controller {
@@ -51,7 +52,7 @@ class AdminController extends Controller {
}
// noinspection JSUnusedGlobalSymbols
actionPage(options) {
async actionPage(options) {
const page = options.page;
if (options.options) {
@@ -81,7 +82,7 @@ class AdminController extends Controller {
throw new Espo.Exceptions.NotFound();
}
if (defs.view) {
if (defs.view && !defs.recordView) {
this.main(defs.view, options);
return;
@@ -93,23 +94,32 @@ class AdminController extends Controller {
const model = this.getSettingsModel();
model.fetch().then(() => {
model.id = '1';
await model.fetch();
const editView = new SettingsEditView({
model: model,
headerTemplate: 'admin/settings/headers/page',
recordView: defs.recordView,
page: page,
label: defs.label,
optionsToPass: [
'page',
'label',
],
});
model.id = '1';
this.main(editView);
const view = defs.view ?? 'views/settings/edit';
const ViewClass = await Espo.loader.requirePromise(view);
if (!EditView.isPrototypeOf(ViewClass)) {
throw new Error("View should inherit views/edit.");
}
const editView = new ViewClass({
model: model,
headerTemplate: 'admin/settings/headers/page',
recordView: defs.recordView,
page: page,
label: defs.label,
optionsToPass: [
'page',
'label',
],
});
this.main(editView);
}
// noinspection JSUnusedGlobalSymbols

View File

@@ -0,0 +1,49 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import DefaultsPreparator from 'handlers/model/defaults-preparator';
import {inject} from 'di';
import Settings from 'models/settings';
// noinspection JSUnusedGlobalSymbols
export default class extends DefaultsPreparator {
/**
* @private
* @type {Settings}
*/
@inject(Settings)
config
async prepare(model) {
return {
baseCode: this.config.get('baseCurrency'),
};
}
}

View File

@@ -0,0 +1,45 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
export default class CurrencyRecordRecordDetailHandler {
/**
* @param {import('views/record/detail').default} view
*/
constructor(view) {
this.view = view;
}
process() {
const model = this.view.model;
this.view.listenTo(model, 'after:relate:rates after:unrelate:rates after:related-change:rates', () => {
model.fetch();
});
}
}

View File

@@ -0,0 +1,49 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import EditView from 'views/edit';
export default class CurrencyMainView extends EditView {
scope = 'Settings'
getHeader() {
return this.buildHeaderHtml([
(() => {
const a = document.createElement('a');
a.href = '#Admin';
a.text = this.translate('Administration');
return a;
})(),
(() => {
return this.options.label;
})(),
]);
}
}

View File

@@ -27,6 +27,7 @@
************************************************************************/
import SettingsEditRecordView from 'views/settings/record/edit';
import EditView from 'views/edit';
export default class extends SettingsEditRecordView {
@@ -65,6 +66,22 @@ export default class extends SettingsEditRecordView {
});
this.controlCurrencyRatesVisibility();
this.whenReady().then(() => {
const view = /** @type {EditView} view */
this.getParentView();
if (!view instanceof EditView) {
return;
}
view.addMenuItem('buttons', {
name: 'currencyRecords',
link: '#CurrencyRecord',
labelTranslation: 'Settings.labels.Currency Rates',
iconClass: 'fas fa-euro-sign',
});
});
}
controlCurrencyRatesVisibility() {

View File

@@ -0,0 +1,98 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import DecimalFieldView from 'views/fields/decimal';
export default class CurrencyRecordRateRateFieldView extends DecimalFieldView {
// language=Handlebars
listTemplateContent = `
{{#if isNotEmpty~}}
<span class="text-soft">{{targetCode}} = </span>
<span class="numeric-text">{{value}}</span>
<span class="text-soft">{{baseCode}}</span>
{{~/if~}}
`
// language=Handlebars
detailTemplateContent = `
{{~#if isNotEmpty~}}
<span class="text-soft">{{targetCode}} = </span>
<span class="numeric-text">{{value}}</span>
<span class="text-soft">{{baseCode}}</span>
{{~else~}}
{{~#if valueIsSet~}}
<span class="none-value">{{translate 'None'}}</span>
{{~else~}}<span class="loading-value"></span>
{{~/if}}
{{~/if~}}
`
// language=Handlebars
editTemplateContent = `
<div class="input-group">
<span class="input-group-addon radius-left" style="width: 24%">1 {{targetCode}} = </span>
<span class="input-group-item">
<input
type="text"
class="main-element form-control numeric-text"
data-name="{{name}}"
value="{{value}}"
autocomplete="espo-{{name}}"
pattern="[\\-]?[0-9]*"
style="text-align: end;"
>
</span>
<span class="input-group-addon radius-right" style="width: 21%">{{baseCode}}</span>
</div>
`
getAttributeList() {
return [
...super.getAttributeList(),
'baseCode',
'recordName',
];
}
data() {
let baseCode = this.model.attributes.baseCode;
let targetCode = this.model.attributes.recordName;
if (this.model.entityType === 'CurrencyRecord') {
baseCode = this.getConfig().get('baseCurrency');
targetCode = this.model.attributes.code;
}
return {
...super.data(),
baseCode,
targetCode,
}
}
}

View File

@@ -0,0 +1,40 @@
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM Open Source CRM application.
* Copyright (C) 2014-2025 EspoCRM, Inc.
* Website: https://www.espocrm.com
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU Affero General Public License version 3.
*
* In accordance with Section 7(b) of the GNU Affero General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
import RelationshipPanelView from 'views/record/panels/relationship';
export default class RatesPanelView extends RelationshipPanelView {
setup() {
if (this.model.attributes.code === this.getConfig().get('baseCurrency')) {
this.defs.createDisabled = true;
}
super.setup();
}
}

View File

@@ -28,23 +28,33 @@
import BaseFieldView from 'views/fields/base';
/**
* Not used.
* @todo Remove.
*/
export default class extends BaseFieldView {
editTemplate = 'settings/fields/currency-rates/edit'
/**
* @private
* @type {string}
*/
baseCode
data() {
const baseCurrency = this.model.get('baseCurrency');
const baseCode = this.baseCode;
const currencyRates = this.model.get('currencyRates') || {};
const rateValues = {};
(this.model.get('currencyList') || []).forEach(currency => {
if (currency !== baseCurrency) {
if (currency !== baseCode) {
rateValues[currency] = currencyRates[currency];
if (!rateValues[currency]) {
if (currencyRates[baseCurrency]) {
rateValues[currency] = Math.round(1 / currencyRates[baseCurrency] * 1000) / 1000;
if (currencyRates[baseCode]) {
rateValues[currency] = Math.round(1 / currencyRates[baseCode] * 1000) / 1000;
}
if (!rateValues[currency]) {
@@ -56,36 +66,23 @@ export default class extends BaseFieldView {
return {
rateValues: rateValues,
baseCurrency: baseCurrency,
baseCurrency: baseCode,
};
}
fetch() {
const data = {};
const currencyRates = {};
setup() {
const sync = () => {
this.baseCode = this.model.get('baseCurrency');
};
const baseCurrency = this.model.get('baseCurrency');
sync();
const currencyList = this.model.get('currencyList') || [];
currencyList.forEach(currency => {
if (currency !== baseCurrency) {
const value = this.$el.find(`input[data-currency="${currency}"]`).val() || '1';
currencyRates[currency] = parseFloat(value);
}
this.listenTo(this.model, 'sync', () => {
sync();
});
}
delete currencyRates[baseCurrency];
for (const c in currencyRates) {
if (!~currencyList.indexOf(c)) {
delete currencyRates[c];
}
}
data[this.name] = currencyRates;
return data;
fetch() {
return {};
}
}

View File

@@ -80,11 +80,11 @@
"description": "A 'create' access level."
},
"read": {
"$ref": "#/definitions/levels",
"$ref": "#/definitions/levelsAll",
"description": "A 'read' access level."
},
"edit": {
"$ref": "#/definitions/levels",
"$ref": "#/definitions/levelsAll",
"description": "An 'edit' access level."
},
"delete": {

View File

@@ -47,7 +47,7 @@
},
"view": {
"type": "string",
"description": "A view. Not compatible with `recordView`."
"description": "A view. Compatible with `recordView` as of v9.3."
},
"tabQuickSearch": {
"type": "boolean",

View File

@@ -37,15 +37,17 @@ use Espo\Core\Currency\Rates;
use Espo\Core\Field\Currency;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Config\ConfigWriter;
use Espo\Tools\Currency\RecordManager;
use tests\integration\Core\BaseTestCase;
class CurrencyTest extends \tests\integration\Core\BaseTestCase
class CurrencyTest extends BaseTestCase
{
/**
* @noinspection PhpUnhandledExceptionInspection
*/
public function testSetCurrencyRates(): void
{
$app = $this->createApplication();
/** @var InjectableFactory $factory */
$factory = $app->getContainer()->get('injectableFactory');
$factory = $this->getInjectableFactory();
$configWriter = $factory->create(ConfigWriter::class);
@@ -55,8 +57,11 @@ class CurrencyTest extends \tests\integration\Core\BaseTestCase
$configWriter->set('currencyRates', [
'EUR' => 1.2,
]);
$configWriter->save();
$this->getInjectableFactory()->create(RecordManager::class)->sync();
$service = $factory->create(RateService::class);
$rates = Rates::fromAssoc(['EUR' => 1.3], '___');