From 0ee8d1dc91e4368522baf535fa8534c50b122ee8 Mon Sep 17 00:00:00 2001 From: Yurii Kuznietsov Date: Sat, 13 Dec 2025 10:31:07 +0200 Subject: [PATCH] Currency rates as entities (#3543) --- .../Acl/CurrencyRecordRate/AccessChecker.php | 79 +++++++++ .../Espo/Classes/AppParams/CurrencyRates.php | 59 +++++++ .../FieldProcessing/CurrencyRecord/IsBase.php | 63 +++++++ .../FieldProcessing/CurrencyRecord/Label.php | 60 +++++++ .../FieldProcessing/CurrencyRecord/Rate.php | 57 ++++++ .../CurrencyRecord/RateDate.php | 57 ++++++ .../FieldProcessing/CurrencyRecord/Symbol.php | 59 +++++++ .../CurrencyRecordRate/Record/NonBase.php | 56 ++++++ .../CurrencyRecordRate/AfterDelete.php | 60 +++++++ .../CurrencyRecordRate/AfterSave.php | 58 +++++++ .../BeforeSaveValidation.php | 80 +++++++++ .../CurrencyRecord/PrimaryFilters/Active.php | 44 +++++ .../Espo/Controllers/CurrencyRecord.php | 56 ++++++ .../Espo/Controllers/CurrencyRecordRate.php | 38 ++++ .../Core/Rebuild/Actions/CurrencyRates.php | 3 + .../Core/Rebuild/Actions/SyncCurrency.php | 48 ++++++ .../Core/Utils/Currency/DatabasePopulator.php | 36 +++- .../Database/Orm/FieldConverters/Currency.php | 2 +- application/Espo/Entities/CurrencyRecord.php | 92 ++++++++++ .../Espo/Entities/CurrencyRecordRate.php | 100 +++++++++++ .../Hooks/CurrencyRecordRate/SetFields.php | 56 ++++++ .../Espo/Resources/i18n/en_US/Currency.json | 5 +- .../Resources/i18n/en_US/CurrencyRecord.json | 20 +++ .../i18n/en_US/CurrencyRecordRate.json | 15 ++ .../Espo/Resources/i18n/en_US/Global.json | 8 +- .../CurrencyRecord/bottomPanelsDetail.json | 5 + .../layouts/CurrencyRecord/detail.json | 30 ++++ .../layouts/CurrencyRecord/detailSmall.json | 30 ++++ .../layouts/CurrencyRecord/list.json | 31 ++++ .../layouts/CurrencyRecord/listSmall.json | 23 +++ .../layouts/CurrencyRecordRate/detail.json | 22 +++ .../CurrencyRecordRate/detailSmall.json | 22 +++ .../layouts/CurrencyRecordRate/filters.json | 3 + .../layouts/CurrencyRecordRate/list.json | 18 ++ .../CurrencyRecordRate/listForRecord.json | 10 ++ .../layouts/CurrencyRecordRate/listSmall.json | 18 ++ .../Resources/layouts/Settings/currency.json | 3 +- .../metadata/aclDefs/CurrencyRecordRate.json | 3 + .../Espo/Resources/metadata/app/acl.json | 10 +- .../Resources/metadata/app/adminPanel.json | 3 +- .../Resources/metadata/app/appParams.json | 3 + .../Espo/Resources/metadata/app/config.json | 3 + .../Espo/Resources/metadata/app/rebuild.json | 5 +- .../metadata/clientDefs/CurrencyRecord.json | 28 +++ .../clientDefs/CurrencyRecordRate.json | 6 + .../metadata/entityDefs/CurrencyRecord.json | 87 ++++++++++ .../entityDefs/CurrencyRecordRate.json | 84 +++++++++ .../metadata/entityDefs/Settings.json | 3 +- .../logicDefs/CurrencyRecordRate.json | 14 ++ .../recordDefs/CurrencyRecordRate.json | 17 ++ .../metadata/scopes/CurrencyRecord.json | 4 + .../metadata/scopes/CurrencyRecordRate.json | 4 + .../metadata/selectDefs/CurrencyRecord.json | 11 ++ .../selectDefs/CurrencyRecordRate.json | 10 ++ .../Espo/Tools/App/SettingsService.php | 16 +- .../Tools/Currency/Exceptions/NotEnabled.php | 35 ++++ .../Espo/Tools/Currency/RateEntryProvider.php | 163 ++++++++++++++++++ .../Espo/Tools/Currency/RateService.php | 111 +++++++----- .../Espo/Tools/Currency/RecordManager.php | 144 ++++++++++++++++ .../settings/fields/currency-rates/edit.tpl | 1 + client/src/acl/currency-record-rate.js | 40 +++++ client/src/controllers/admin.js | 42 +++-- .../default-preparator.js | 49 ++++++ .../handlers/currency-record/record-detail.js | 45 +++++ client/src/views/admin/currency-main.js | 49 ++++++ client/src/views/admin/currency.js | 17 ++ .../views/currency-record-rate/fields/rate.js | 98 +++++++++++ .../currency-record/record/panels/rates.js | 40 +++++ .../views/settings/fields/currency-rates.js | 53 +++--- schema/metadata/app/acl.json | 4 +- schema/metadata/app/adminPanel.json | 2 +- .../Espo/Currency/CurrencyTest.php | 15 +- 72 files changed, 2518 insertions(+), 127 deletions(-) create mode 100644 application/Espo/Classes/Acl/CurrencyRecordRate/AccessChecker.php create mode 100644 application/Espo/Classes/AppParams/CurrencyRates.php create mode 100644 application/Espo/Classes/FieldProcessing/CurrencyRecord/IsBase.php create mode 100644 application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php create mode 100644 application/Espo/Classes/FieldProcessing/CurrencyRecord/Rate.php create mode 100644 application/Espo/Classes/FieldProcessing/CurrencyRecord/RateDate.php create mode 100644 application/Espo/Classes/FieldProcessing/CurrencyRecord/Symbol.php create mode 100644 application/Espo/Classes/FieldValidators/CurrencyRecordRate/Record/NonBase.php create mode 100644 application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php create mode 100644 application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php create mode 100644 application/Espo/Classes/RecordHooks/CurrencyRecordRate/BeforeSaveValidation.php create mode 100644 application/Espo/Classes/Select/CurrencyRecord/PrimaryFilters/Active.php create mode 100644 application/Espo/Controllers/CurrencyRecord.php create mode 100644 application/Espo/Controllers/CurrencyRecordRate.php create mode 100644 application/Espo/Core/Rebuild/Actions/SyncCurrency.php create mode 100644 application/Espo/Entities/CurrencyRecord.php create mode 100644 application/Espo/Entities/CurrencyRecordRate.php create mode 100644 application/Espo/Hooks/CurrencyRecordRate/SetFields.php create mode 100644 application/Espo/Resources/i18n/en_US/CurrencyRecord.json create mode 100644 application/Espo/Resources/i18n/en_US/CurrencyRecordRate.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecord/bottomPanelsDetail.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecord/detail.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecord/detailSmall.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecord/list.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecord/listSmall.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecordRate/detail.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecordRate/detailSmall.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecordRate/filters.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecordRate/list.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecordRate/listForRecord.json create mode 100644 application/Espo/Resources/layouts/CurrencyRecordRate/listSmall.json create mode 100644 application/Espo/Resources/metadata/aclDefs/CurrencyRecordRate.json create mode 100644 application/Espo/Resources/metadata/clientDefs/CurrencyRecord.json create mode 100644 application/Espo/Resources/metadata/clientDefs/CurrencyRecordRate.json create mode 100644 application/Espo/Resources/metadata/entityDefs/CurrencyRecord.json create mode 100644 application/Espo/Resources/metadata/entityDefs/CurrencyRecordRate.json create mode 100644 application/Espo/Resources/metadata/logicDefs/CurrencyRecordRate.json create mode 100644 application/Espo/Resources/metadata/recordDefs/CurrencyRecordRate.json create mode 100644 application/Espo/Resources/metadata/scopes/CurrencyRecord.json create mode 100644 application/Espo/Resources/metadata/scopes/CurrencyRecordRate.json create mode 100644 application/Espo/Resources/metadata/selectDefs/CurrencyRecord.json create mode 100644 application/Espo/Resources/metadata/selectDefs/CurrencyRecordRate.json create mode 100644 application/Espo/Tools/Currency/Exceptions/NotEnabled.php create mode 100644 application/Espo/Tools/Currency/RateEntryProvider.php create mode 100644 application/Espo/Tools/Currency/RecordManager.php create mode 100644 client/src/acl/currency-record-rate.js create mode 100644 client/src/handlers/currency-record-rate/default-preparator.js create mode 100644 client/src/handlers/currency-record/record-detail.js create mode 100644 client/src/views/admin/currency-main.js create mode 100644 client/src/views/currency-record-rate/fields/rate.js create mode 100644 client/src/views/currency-record/record/panels/rates.js diff --git a/application/Espo/Classes/Acl/CurrencyRecordRate/AccessChecker.php b/application/Espo/Classes/Acl/CurrencyRecordRate/AccessChecker.php new file mode 100644 index 0000000000..9a7f902cfc --- /dev/null +++ b/application/Espo/Classes/Acl/CurrencyRecordRate/AccessChecker.php @@ -0,0 +1,79 @@ +. + * + * 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 + */ +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); + } +} diff --git a/application/Espo/Classes/AppParams/CurrencyRates.php b/application/Espo/Classes/AppParams/CurrencyRates.php new file mode 100644 index 0000000000..8bb663d618 --- /dev/null +++ b/application/Espo/Classes/AppParams/CurrencyRates.php @@ -0,0 +1,59 @@ +. + * + * 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; + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/IsBase.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/IsBase.php new file mode 100644 index 0000000000..52c3ac235a --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/IsBase.php @@ -0,0 +1,63 @@ +. + * + * 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 + */ +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); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php new file mode 100644 index 0000000000..739fa284b1 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Label.php @@ -0,0 +1,60 @@ +. + * + * 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 + */ +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); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Rate.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Rate.php new file mode 100644 index 0000000000..21e7132055 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Rate.php @@ -0,0 +1,57 @@ +. + * + * 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 + */ +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); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/RateDate.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/RateDate.php new file mode 100644 index 0000000000..c5c5f26160 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/RateDate.php @@ -0,0 +1,57 @@ +. + * + * 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 + */ +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); + } +} diff --git a/application/Espo/Classes/FieldProcessing/CurrencyRecord/Symbol.php b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Symbol.php new file mode 100644 index 0000000000..47675203c5 --- /dev/null +++ b/application/Espo/Classes/FieldProcessing/CurrencyRecord/Symbol.php @@ -0,0 +1,59 @@ +. + * + * 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 + */ +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); + } +} diff --git a/application/Espo/Classes/FieldValidators/CurrencyRecordRate/Record/NonBase.php b/application/Espo/Classes/FieldValidators/CurrencyRecordRate/Record/NonBase.php new file mode 100644 index 0000000000..ddbaad0324 --- /dev/null +++ b/application/Espo/Classes/FieldValidators/CurrencyRecordRate/Record/NonBase.php @@ -0,0 +1,56 @@ +. + * + * 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 + */ +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; + } +} diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php new file mode 100644 index 0000000000..33f383669c --- /dev/null +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterDelete.php @@ -0,0 +1,60 @@ +. + * + * 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 + */ +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); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php new file mode 100644 index 0000000000..ad761b4db1 --- /dev/null +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/AfterSave.php @@ -0,0 +1,58 @@ +. + * + * 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 + */ +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); + } + } +} diff --git a/application/Espo/Classes/RecordHooks/CurrencyRecordRate/BeforeSaveValidation.php b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/BeforeSaveValidation.php new file mode 100644 index 0000000000..88e94c713f --- /dev/null +++ b/application/Espo/Classes/RecordHooks/CurrencyRecordRate/BeforeSaveValidation.php @@ -0,0 +1,80 @@ +. + * + * 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 + */ +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') + ); + } + } +} diff --git a/application/Espo/Classes/Select/CurrencyRecord/PrimaryFilters/Active.php b/application/Espo/Classes/Select/CurrencyRecord/PrimaryFilters/Active.php new file mode 100644 index 0000000000..a4b04abcd4 --- /dev/null +++ b/application/Espo/Classes/Select/CurrencyRecord/PrimaryFilters/Active.php @@ -0,0 +1,44 @@ +. + * + * 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, + ]); + } +} diff --git a/application/Espo/Controllers/CurrencyRecord.php b/application/Espo/Controllers/CurrencyRecord.php new file mode 100644 index 0000000000..beb187ddcc --- /dev/null +++ b/application/Espo/Controllers/CurrencyRecord.php @@ -0,0 +1,56 @@ +. + * + * 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(); + } +} diff --git a/application/Espo/Controllers/CurrencyRecordRate.php b/application/Espo/Controllers/CurrencyRecordRate.php new file mode 100644 index 0000000000..34ae674ec3 --- /dev/null +++ b/application/Espo/Controllers/CurrencyRecordRate.php @@ -0,0 +1,38 @@ +. + * + * 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 +{} diff --git a/application/Espo/Core/Rebuild/Actions/CurrencyRates.php b/application/Espo/Core/Rebuild/Actions/CurrencyRates.php index 69f2f3a18b..4c46d14466 100644 --- a/application/Espo/Core/Rebuild/Actions/CurrencyRates.php +++ b/application/Espo/Core/Rebuild/Actions/CurrencyRates.php @@ -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) {} diff --git a/application/Espo/Core/Rebuild/Actions/SyncCurrency.php b/application/Espo/Core/Rebuild/Actions/SyncCurrency.php new file mode 100644 index 0000000000..f148c5c1af --- /dev/null +++ b/application/Espo/Core/Rebuild/Actions/SyncCurrency.php @@ -0,0 +1,48 @@ +. + * + * 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(); + } +} diff --git a/application/Espo/Core/Utils/Currency/DatabasePopulator.php b/application/Espo/Core/Utils/Currency/DatabasePopulator.php index 83c0d3b676..b6de2689c0 100644 --- a/application/Espo/Core/Utils/Currency/DatabasePopulator.php +++ b/application/Espo/Core/Utils/Currency/DatabasePopulator.php @@ -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 + */ + 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; + } } diff --git a/application/Espo/Core/Utils/Database/Orm/FieldConverters/Currency.php b/application/Espo/Core/Utils/Database/Orm/FieldConverters/Currency.php index e3428c1ba8..482ea3bb81 100644 --- a/application/Espo/Core/Utils/Database/Orm/FieldConverters/Currency.php +++ b/application/Espo/Core/Utils/Database/Orm/FieldConverters/Currency.php @@ -263,7 +263,7 @@ class Currency implements FieldConverter { $name = $fieldDefs->getName(); - $alias = $name . 'CurrencyRate'; + $alias = $name . 'CurrencyRecordRate'; $leftJoins = [ [ 'Currency', diff --git a/application/Espo/Entities/CurrencyRecord.php b/application/Espo/Entities/CurrencyRecord.php new file mode 100644 index 0000000000..b12d7e6828 --- /dev/null +++ b/application/Espo/Entities/CurrencyRecord.php @@ -0,0 +1,92 @@ +. + * + * 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); + } +} diff --git a/application/Espo/Entities/CurrencyRecordRate.php b/application/Espo/Entities/CurrencyRecordRate.php new file mode 100644 index 0000000000..3870b26248 --- /dev/null +++ b/application/Espo/Entities/CurrencyRecordRate.php @@ -0,0 +1,100 @@ +. + * + * 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; + } +} diff --git a/application/Espo/Hooks/CurrencyRecordRate/SetFields.php b/application/Espo/Hooks/CurrencyRecordRate/SetFields.php new file mode 100644 index 0000000000..79c1f86f23 --- /dev/null +++ b/application/Espo/Hooks/CurrencyRecordRate/SetFields.php @@ -0,0 +1,56 @@ +. + * + * 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 + */ +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); + } + } +} diff --git a/application/Espo/Resources/i18n/en_US/Currency.json b/application/Espo/Resources/i18n/en_US/Currency.json index 9a2a0533db..ccfa47ebd2 100644 --- a/application/Espo/Resources/i18n/en_US/Currency.json +++ b/application/Espo/Resources/i18n/en_US/Currency.json @@ -164,5 +164,8 @@ "ZAR":"South African Rand", "ZMW":"Zambian Kwacha", "ZWL": "Zimbabwe Dollar" + }, + "messages": { + "rateOnDateAlreadyExists": "A rate on this date already exists." } -} \ No newline at end of file +} diff --git a/application/Espo/Resources/i18n/en_US/CurrencyRecord.json b/application/Espo/Resources/i18n/en_US/CurrencyRecord.json new file mode 100644 index 0000000000..779aa99abc --- /dev/null +++ b/application/Espo/Resources/i18n/en_US/CurrencyRecord.json @@ -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" + } + } +} diff --git a/application/Espo/Resources/i18n/en_US/CurrencyRecordRate.json b/application/Espo/Resources/i18n/en_US/CurrencyRecordRate.json new file mode 100644 index 0000000000..bd36b45048 --- /dev/null +++ b/application/Espo/Resources/i18n/en_US/CurrencyRecordRate.json @@ -0,0 +1,15 @@ +{ + "labels": { + "Create CurrencyRecordRate": "Create Exchange Rate" + }, + "fields": { + "record": "Currency", + "baseCode": "Base", + "date": "Date", + "rate": "Rate", + "number": "Number" + }, + "links": { + "record": "Currency" + } +} diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 58915b6742..3a2acff27c 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -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", diff --git a/application/Espo/Resources/layouts/CurrencyRecord/bottomPanelsDetail.json b/application/Espo/Resources/layouts/CurrencyRecord/bottomPanelsDetail.json new file mode 100644 index 0000000000..c188ab187d --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecord/bottomPanelsDetail.json @@ -0,0 +1,5 @@ +{ + "rates": { + "index": 0 + } +} diff --git a/application/Espo/Resources/layouts/CurrencyRecord/detail.json b/application/Espo/Resources/layouts/CurrencyRecord/detail.json new file mode 100644 index 0000000000..efb9af4654 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecord/detail.json @@ -0,0 +1,30 @@ +[ + { + "rows": [ + [ + { + "name": "code" + }, + { + "name": "status" + } + ], + [ + { + "name": "label" + }, + { + "name": "isBase" + } + ], + [ + { + "name": "rate" + }, + { + "name": "rateDate" + } + ] + ] + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecord/detailSmall.json b/application/Espo/Resources/layouts/CurrencyRecord/detailSmall.json new file mode 100644 index 0000000000..efb9af4654 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecord/detailSmall.json @@ -0,0 +1,30 @@ +[ + { + "rows": [ + [ + { + "name": "code" + }, + { + "name": "status" + } + ], + [ + { + "name": "label" + }, + { + "name": "isBase" + } + ], + [ + { + "name": "rate" + }, + { + "name": "rateDate" + } + ] + ] + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecord/list.json b/application/Espo/Resources/layouts/CurrencyRecord/list.json new file mode 100644 index 0000000000..eb2f4a0317 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecord/list.json @@ -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" + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecord/listSmall.json b/application/Espo/Resources/layouts/CurrencyRecord/listSmall.json new file mode 100644 index 0000000000..78d1919d00 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecord/listSmall.json @@ -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" + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecordRate/detail.json b/application/Espo/Resources/layouts/CurrencyRecordRate/detail.json new file mode 100644 index 0000000000..e24ff7f91a --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecordRate/detail.json @@ -0,0 +1,22 @@ +[ + { + "rows": [ + [ + { + "name": "record" + }, + { + "name": "baseCode" + } + ], + [ + { + "name": "date" + }, + { + "name": "rate" + } + ] + ] + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecordRate/detailSmall.json b/application/Espo/Resources/layouts/CurrencyRecordRate/detailSmall.json new file mode 100644 index 0000000000..e24ff7f91a --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecordRate/detailSmall.json @@ -0,0 +1,22 @@ +[ + { + "rows": [ + [ + { + "name": "record" + }, + { + "name": "baseCode" + } + ], + [ + { + "name": "date" + }, + { + "name": "rate" + } + ] + ] + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecordRate/filters.json b/application/Espo/Resources/layouts/CurrencyRecordRate/filters.json new file mode 100644 index 0000000000..419b4091a5 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecordRate/filters.json @@ -0,0 +1,3 @@ +[ + "date" +] diff --git a/application/Espo/Resources/layouts/CurrencyRecordRate/list.json b/application/Espo/Resources/layouts/CurrencyRecordRate/list.json new file mode 100644 index 0000000000..2c033f0918 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecordRate/list.json @@ -0,0 +1,18 @@ +[ + { + "name": "date", + "width": 25 + }, + { + "name": "record", + "width": 25 + }, + { + "name": "baseCode", + "width": 25 + }, + { + "name": "rate", + "align": "right" + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecordRate/listForRecord.json b/application/Espo/Resources/layouts/CurrencyRecordRate/listForRecord.json new file mode 100644 index 0000000000..4820d7fff5 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecordRate/listForRecord.json @@ -0,0 +1,10 @@ +[ + { + "name": "date", + "width": 25 + }, + { + "name": "rate", + "align": "right" + } +] diff --git a/application/Espo/Resources/layouts/CurrencyRecordRate/listSmall.json b/application/Espo/Resources/layouts/CurrencyRecordRate/listSmall.json new file mode 100644 index 0000000000..2c033f0918 --- /dev/null +++ b/application/Espo/Resources/layouts/CurrencyRecordRate/listSmall.json @@ -0,0 +1,18 @@ +[ + { + "name": "date", + "width": 25 + }, + { + "name": "record", + "width": 25 + }, + { + "name": "baseCode", + "width": 25 + }, + { + "name": "rate", + "align": "right" + } +] diff --git a/application/Espo/Resources/layouts/Settings/currency.json b/application/Espo/Resources/layouts/Settings/currency.json index 08f17630f7..69deb135ed 100644 --- a/application/Espo/Resources/layouts/Settings/currency.json +++ b/application/Espo/Resources/layouts/Settings/currency.json @@ -7,9 +7,8 @@ ] }, { - "label": "Currency Rates", "rows": [ - [{"name": "baseCurrency"}, {"name": "currencyRates"}] + [{"name": "baseCurrency"}, false] ] } ] diff --git a/application/Espo/Resources/metadata/aclDefs/CurrencyRecordRate.json b/application/Espo/Resources/metadata/aclDefs/CurrencyRecordRate.json new file mode 100644 index 0000000000..5854a88904 --- /dev/null +++ b/application/Espo/Resources/metadata/aclDefs/CurrencyRecordRate.json @@ -0,0 +1,3 @@ +{ + "accessCheckerClassName": "Espo\\Classes\\Acl\\CurrencyRecordRate\\AccessChecker" +} diff --git a/application/Espo/Resources/metadata/app/acl.json b/application/Espo/Resources/metadata/app/acl.json index ead93f75ff..3b3bef9c67 100644 --- a/application/Espo/Resources/metadata/app/acl.json +++ b/application/Espo/Resources/metadata/app/acl.json @@ -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": [ diff --git a/application/Espo/Resources/metadata/app/adminPanel.json b/application/Espo/Resources/metadata/app/adminPanel.json index 1466253102..22e63bb6a6 100644 --- a/application/Espo/Resources/metadata/app/adminPanel.json +++ b/application/Espo/Resources/metadata/app/adminPanel.json @@ -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", diff --git a/application/Espo/Resources/metadata/app/appParams.json b/application/Espo/Resources/metadata/app/appParams.json index 1f5609ee5c..0f7e8b4162 100644 --- a/application/Espo/Resources/metadata/app/appParams.json +++ b/application/Espo/Resources/metadata/app/appParams.json @@ -7,5 +7,8 @@ }, "addressCountryData": { "className": "Espo\\Classes\\AppParams\\AddressCountryData" + }, + "currencyRates": { + "className": "Espo\\Classes\\AppParams\\CurrencyRates" } } diff --git a/application/Espo/Resources/metadata/app/config.json b/application/Espo/Resources/metadata/app/config.json index a884d41076..2dd46e4a59 100644 --- a/application/Espo/Resources/metadata/app/config.json +++ b/application/Espo/Resources/metadata/app/config.json @@ -168,6 +168,9 @@ }, "baselineRole": { "level": "admin" + }, + "currencyRates": { + "readOnly": true } } } diff --git a/application/Espo/Resources/metadata/app/rebuild.json b/application/Espo/Resources/metadata/app/rebuild.json index b401a843fb..68a7cb23a9 100644 --- a/application/Espo/Resources/metadata/app/rebuild.json +++ b/application/Espo/Resources/metadata/app/rebuild.json @@ -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" ] } diff --git a/application/Espo/Resources/metadata/clientDefs/CurrencyRecord.json b/application/Espo/Resources/metadata/clientDefs/CurrencyRecord.json new file mode 100644 index 0000000000..88594ae0bd --- /dev/null +++ b/application/Espo/Resources/metadata/clientDefs/CurrencyRecord.json @@ -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 + } + } +} diff --git a/application/Espo/Resources/metadata/clientDefs/CurrencyRecordRate.json b/application/Espo/Resources/metadata/clientDefs/CurrencyRecordRate.json new file mode 100644 index 0000000000..74e72262f6 --- /dev/null +++ b/application/Espo/Resources/metadata/clientDefs/CurrencyRecordRate.json @@ -0,0 +1,6 @@ +{ + "controller": "controllers/record", + "modelDefaultsPreparator": "handlers/currency-record-rate/default-preparator", + "acl": "acl/currency-record-rate", + "textFilterDisabled": true +} diff --git a/application/Espo/Resources/metadata/entityDefs/CurrencyRecord.json b/application/Espo/Resources/metadata/entityDefs/CurrencyRecord.json new file mode 100644 index 0000000000..8a8d13f0e6 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/CurrencyRecord.json @@ -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" + } +} diff --git a/application/Espo/Resources/metadata/entityDefs/CurrencyRecordRate.json b/application/Espo/Resources/metadata/entityDefs/CurrencyRecordRate.json new file mode 100644 index 0000000000..4dc01c39a0 --- /dev/null +++ b/application/Espo/Resources/metadata/entityDefs/CurrencyRecordRate.json @@ -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" + } +} diff --git a/application/Espo/Resources/metadata/entityDefs/Settings.json b/application/Espo/Resources/metadata/entityDefs/Settings.json index 7091fcefea..ae6e4c3c6f 100644 --- a/application/Espo/Resources/metadata/entityDefs/Settings.json +++ b/application/Espo/Resources/metadata/entityDefs/Settings.json @@ -191,8 +191,7 @@ "view": "views/settings/fields/default-currency" }, "currencyRates": { - "type": "base", - "view": "views/settings/fields/currency-rates" + "type": "base" }, "outboundEmailIsShared": { "type": "bool", diff --git a/application/Espo/Resources/metadata/logicDefs/CurrencyRecordRate.json b/application/Espo/Resources/metadata/logicDefs/CurrencyRecordRate.json new file mode 100644 index 0000000000..1fc3512473 --- /dev/null +++ b/application/Espo/Resources/metadata/logicDefs/CurrencyRecordRate.json @@ -0,0 +1,14 @@ +{ + "fields": { + "baseCode": { + "visible": { + "conditionGroup": [ + { + "type": "isNotEmpty", + "attribute": "id" + } + ] + } + } + } +} diff --git a/application/Espo/Resources/metadata/recordDefs/CurrencyRecordRate.json b/application/Espo/Resources/metadata/recordDefs/CurrencyRecordRate.json new file mode 100644 index 0000000000..23ec912fce --- /dev/null +++ b/application/Espo/Resources/metadata/recordDefs/CurrencyRecordRate.json @@ -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" + ] +} diff --git a/application/Espo/Resources/metadata/scopes/CurrencyRecord.json b/application/Espo/Resources/metadata/scopes/CurrencyRecord.json new file mode 100644 index 0000000000..f1b6ea3c1d --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/CurrencyRecord.json @@ -0,0 +1,4 @@ +{ + "entity": true, + "tab": true +} diff --git a/application/Espo/Resources/metadata/scopes/CurrencyRecordRate.json b/application/Espo/Resources/metadata/scopes/CurrencyRecordRate.json new file mode 100644 index 0000000000..f7870cd3e8 --- /dev/null +++ b/application/Espo/Resources/metadata/scopes/CurrencyRecordRate.json @@ -0,0 +1,4 @@ +{ + "entity": true, + "preserveAuditLog": true +} diff --git a/application/Espo/Resources/metadata/selectDefs/CurrencyRecord.json b/application/Espo/Resources/metadata/selectDefs/CurrencyRecord.json new file mode 100644 index 0000000000..ac0f1d25f0 --- /dev/null +++ b/application/Espo/Resources/metadata/selectDefs/CurrencyRecord.json @@ -0,0 +1,11 @@ +{ + "primaryFilterClassNameMap": { + "active": "Espo\\Classes\\Select\\CurrencyRecord\\PrimaryFilters\\Active" + }, + "selectAttributesDependencyMap": { + "id": [ + "code" + ] + }, + "accessControlFilterResolverClassName": "Espo\\Core\\Select\\AccessControl\\FilterResolvers\\Boolean" +} diff --git a/application/Espo/Resources/metadata/selectDefs/CurrencyRecordRate.json b/application/Espo/Resources/metadata/selectDefs/CurrencyRecordRate.json new file mode 100644 index 0000000000..0731c7451e --- /dev/null +++ b/application/Espo/Resources/metadata/selectDefs/CurrencyRecordRate.json @@ -0,0 +1,10 @@ +{ + "selectAttributesDependencyMap": { + "id": [ + "recordId", + "recordName", + "baseCode" + ] + }, + "accessControlFilterResolverClassName": "Espo\\Core\\Select\\AccessControl\\FilterResolvers\\Boolean" +} diff --git a/application/Espo/Tools/App/SettingsService.php b/application/Espo/Tools/App/SettingsService.php index 151bbd0bb4..cdc1124c58 100644 --- a/application/Espo/Tools/App/SettingsService.php +++ b/application/Espo/Tools/App/SettingsService.php @@ -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(); diff --git a/application/Espo/Tools/Currency/Exceptions/NotEnabled.php b/application/Espo/Tools/Currency/Exceptions/NotEnabled.php new file mode 100644 index 0000000000..5359490aaf --- /dev/null +++ b/application/Espo/Tools/Currency/Exceptions/NotEnabled.php @@ -0,0 +1,35 @@ +. + * + * 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 +{} diff --git a/application/Espo/Tools/Currency/RateEntryProvider.php b/application/Espo/Tools/Currency/RateEntryProvider.php new file mode 100644 index 0000000000..895233d130 --- /dev/null +++ b/application/Espo/Tools/Currency/RateEntryProvider.php @@ -0,0 +1,163 @@ +. + * + * 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 */ + 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; + } +} diff --git a/application/Espo/Tools/Currency/RateService.php b/application/Espo/Tools/Currency/RateService.php index 95cf1b2348..e971c73762 100644 --- a/application/Espo/Tools/Currency/RateService.php +++ b/application/Espo/Tools/Currency/RateService.php @@ -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(); } } diff --git a/application/Espo/Tools/Currency/RecordManager.php b/application/Espo/Tools/Currency/RecordManager.php new file mode 100644 index 0000000000..dc3efa1552 --- /dev/null +++ b/application/Espo/Tools/Currency/RecordManager.php @@ -0,0 +1,144 @@ +. + * + * 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(); + } +} diff --git a/client/res/templates/settings/fields/currency-rates/edit.tpl b/client/res/templates/settings/fields/currency-rates/edit.tpl index 92ba28a6e3..2c94e1514c 100644 --- a/client/res/templates/settings/fields/currency-rates/edit.tpl +++ b/client/res/templates/settings/fields/currency-rates/edit.tpl @@ -8,6 +8,7 @@ data-currency="{{@key}}" value="{{./this}}" style="text-align: right;" + readonly="readonly" > {{../baseCurrency}} diff --git a/client/src/acl/currency-record-rate.js b/client/src/acl/currency-record-rate.js new file mode 100644 index 0000000000..e1181979f5 --- /dev/null +++ b/client/src/acl/currency-record-rate.js @@ -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 . + * + * 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); + } +} diff --git a/client/src/controllers/admin.js b/client/src/controllers/admin.js index 960228d971..a918048367 100644 --- a/client/src/controllers/admin.js +++ b/client/src/controllers/admin.js @@ -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 diff --git a/client/src/handlers/currency-record-rate/default-preparator.js b/client/src/handlers/currency-record-rate/default-preparator.js new file mode 100644 index 0000000000..a68cc65c74 --- /dev/null +++ b/client/src/handlers/currency-record-rate/default-preparator.js @@ -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 . + * + * 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'), + }; + } +} + diff --git a/client/src/handlers/currency-record/record-detail.js b/client/src/handlers/currency-record/record-detail.js new file mode 100644 index 0000000000..07e58efbad --- /dev/null +++ b/client/src/handlers/currency-record/record-detail.js @@ -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 . + * + * 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(); + }); + } +} diff --git a/client/src/views/admin/currency-main.js b/client/src/views/admin/currency-main.js new file mode 100644 index 0000000000..47113211e5 --- /dev/null +++ b/client/src/views/admin/currency-main.js @@ -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 . + * + * 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; + })(), + ]); + } +} diff --git a/client/src/views/admin/currency.js b/client/src/views/admin/currency.js index e6af6c4048..fd35a51435 100644 --- a/client/src/views/admin/currency.js +++ b/client/src/views/admin/currency.js @@ -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() { diff --git a/client/src/views/currency-record-rate/fields/rate.js b/client/src/views/currency-record-rate/fields/rate.js new file mode 100644 index 0000000000..b0ec86ad61 --- /dev/null +++ b/client/src/views/currency-record-rate/fields/rate.js @@ -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 . + * + * 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~}} + {{targetCode}} = + {{value}} + {{baseCode}} + {{~/if~}} + ` + + // language=Handlebars + detailTemplateContent = ` + {{~#if isNotEmpty~}} + {{targetCode}} = + {{value}} + {{baseCode}} + {{~else~}} + {{~#if valueIsSet~}} + {{translate 'None'}} + {{~else~}} + {{~/if}} + {{~/if~}} + ` + + // language=Handlebars + editTemplateContent = ` +
+ 1 {{targetCode}} = + + + + {{baseCode}} +
+ ` + + 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, + } + } +} diff --git a/client/src/views/currency-record/record/panels/rates.js b/client/src/views/currency-record/record/panels/rates.js new file mode 100644 index 0000000000..a9cd7381b2 --- /dev/null +++ b/client/src/views/currency-record/record/panels/rates.js @@ -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 . + * + * 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(); + } +} diff --git a/client/src/views/settings/fields/currency-rates.js b/client/src/views/settings/fields/currency-rates.js index a3ffd4e17d..6711604e35 100644 --- a/client/src/views/settings/fields/currency-rates.js +++ b/client/src/views/settings/fields/currency-rates.js @@ -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 {}; } } diff --git a/schema/metadata/app/acl.json b/schema/metadata/app/acl.json index 78c226cc13..9aef051908 100644 --- a/schema/metadata/app/acl.json +++ b/schema/metadata/app/acl.json @@ -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": { diff --git a/schema/metadata/app/adminPanel.json b/schema/metadata/app/adminPanel.json index 34ebb50b6b..c35d3a3a97 100644 --- a/schema/metadata/app/adminPanel.json +++ b/schema/metadata/app/adminPanel.json @@ -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", diff --git a/tests/integration/Espo/Currency/CurrencyTest.php b/tests/integration/Espo/Currency/CurrencyTest.php index b5e81e9b17..7f3839457e 100644 --- a/tests/integration/Espo/Currency/CurrencyTest.php +++ b/tests/integration/Espo/Currency/CurrencyTest.php @@ -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], '___');