mirror of
https://github.com/chenasraf/nextcloud-autocurrency.git
synced 2026-05-17 17:28:06 +00:00
feat: add custom currencies (#182)
* feat: add custom currency table & endpoints * refactor: fix migration versions * feat: add custom currencies logic to fetch service * feat: add custom currencies UI to admin settings * feat: add custom currencies to user settings history * chore: update admin settings help info * refactor: use NcTextField instead of input
This commit is contained in:
@@ -24,6 +24,7 @@ class {{pascalCase name}}Mapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $id
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
@@ -40,7 +41,6 @@ class {{pascalCase name}}Mapper extends QBMapper {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $projectId
|
||||
* @return array<{{pascalCase name}}>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
|
||||
@@ -12,6 +12,8 @@ use OCA\AutoCurrency\AppInfo;
|
||||
use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
|
||||
use OCA\AutoCurrency\Db\CospendProjectMapper;
|
||||
use OCA\AutoCurrency\Db\CurrencyMapper;
|
||||
use OCA\AutoCurrency\Db\CustomCurrency;
|
||||
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
|
||||
use OCA\AutoCurrency\Service\FetchCurrenciesService;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\ApiRoute;
|
||||
@@ -49,6 +51,7 @@ class ApiController extends OCSController {
|
||||
private CurrencyMapper $currencyMapper,
|
||||
private CospendProjectMapper $projectMapper,
|
||||
private AutocurrencyRateHistoryMapper $historyMapper,
|
||||
private CustomCurrencyMapper $customCurrencyMapper,
|
||||
) {
|
||||
parent::__construct($appName, $request);
|
||||
$this->config = $config;
|
||||
@@ -84,11 +87,11 @@ class ApiController extends OCSController {
|
||||
* Get current user settings
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{
|
||||
* supported_currencies: array{
|
||||
* supported_currencies: list<array{
|
||||
* code: string,
|
||||
* symbol: string,
|
||||
* name: string
|
||||
* }
|
||||
* }>
|
||||
* }, array{}>
|
||||
*
|
||||
* 200: Data returned
|
||||
@@ -96,12 +99,23 @@ class ApiController extends OCSController {
|
||||
#[NoAdminRequired]
|
||||
#[ApiRoute(verb: 'GET', url: '/api/user-settings')]
|
||||
public function getUserSettings(): DataResponse {
|
||||
// Get standard currencies
|
||||
$supported = array_map(
|
||||
fn ($sym): array
|
||||
=> ['name' => $sym['name'], 'code' => $sym['code'], 'symbol' => $sym['symbol']],
|
||||
array_values($this->service->symbols)
|
||||
);
|
||||
|
||||
// Add custom currencies
|
||||
$customCurrencies = $this->customCurrencyMapper->findAll();
|
||||
foreach ($customCurrencies as $custom) {
|
||||
$supported[] = [
|
||||
'name' => $custom->getCode(),
|
||||
'code' => $custom->getCode(),
|
||||
'symbol' => $custom->getSymbol() ?: $custom->getCode(),
|
||||
];
|
||||
}
|
||||
|
||||
return new DataResponse(
|
||||
['supported_currencies' => $supported]
|
||||
);
|
||||
@@ -155,15 +169,29 @@ class ApiController extends OCSController {
|
||||
$userId = $this->userSession->getUser()->getUID();
|
||||
$projects = $this->projectMapper->findAllByUser($userId);
|
||||
|
||||
// Build a map of custom currency codes for quick lookup
|
||||
$customCurrenciesMap = [];
|
||||
foreach ($this->customCurrencyMapper->findAll() as $cc) {
|
||||
$customCurrenciesMap[strtolower($cc->getCode())] = true;
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($projects as $p) {
|
||||
$name = (string)$p->getName();
|
||||
$id = (string)$p->getId();
|
||||
$currencyName = (string)$p->getCurrencyName();
|
||||
$currencies = $this->currencyMapper->findAll($id);
|
||||
$currencyNames = array_map(function ($c) {
|
||||
$currencyNames = array_map(function ($c) use ($customCurrenciesMap) {
|
||||
$currencyCode = strtolower((string)$c->getName());
|
||||
|
||||
// Check if it's a custom currency first
|
||||
if (isset($customCurrenciesMap[$currencyCode])) {
|
||||
return $currencyCode;
|
||||
}
|
||||
|
||||
// Otherwise try to resolve as standard currency
|
||||
$resolved = $this->service->getCurrencyName((string)$c->getName());
|
||||
return $resolved ?? strtolower((string)$c->getName());
|
||||
return $resolved ?? $currencyCode;
|
||||
}, $currencies);
|
||||
|
||||
$list[] = [
|
||||
@@ -267,4 +295,156 @@ class ApiController extends OCSController {
|
||||
'points' => $points,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all custom currencies
|
||||
*
|
||||
* @return DataResponse<Http::STATUS_OK, array{
|
||||
* currencies: list<array{
|
||||
* id: int,
|
||||
* code: string,
|
||||
* symbol: string,
|
||||
* api_endpoint: string,
|
||||
* api_key: string,
|
||||
* json_path: string
|
||||
* }>
|
||||
* }, array{}>
|
||||
*
|
||||
* 200: Data returned
|
||||
*/
|
||||
#[ApiRoute(verb: 'GET', url: '/api/custom-currencies')]
|
||||
public function getCustomCurrencies(): DataResponse {
|
||||
$currencies = $this->customCurrencyMapper->findAll();
|
||||
$serialized = array_map(fn ($c) => $c->jsonSerialize(), $currencies);
|
||||
return new DataResponse(['currencies' => $serialized]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new custom currency
|
||||
*
|
||||
* @param array{
|
||||
* code: string,
|
||||
* symbol?: string,
|
||||
* api_endpoint: string,
|
||||
* json_path: string,
|
||||
* api_key?: string
|
||||
* } $data Data to create
|
||||
* @return DataResponse<Http::STATUS_CREATED, array{
|
||||
* id: int,
|
||||
* code: string,
|
||||
* symbol: string,
|
||||
* api_endpoint: string,
|
||||
* api_key: string,
|
||||
* json_path: string
|
||||
* }, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
|
||||
*
|
||||
* 201: Currency created
|
||||
* 400: Bad request
|
||||
* 500: Internal server error
|
||||
*/
|
||||
#[ApiRoute(verb: 'POST', url: '/api/custom-currencies')]
|
||||
public function createCustomCurrency(mixed $data): DataResponse {
|
||||
$requiredFields = ['code', 'api_endpoint', 'json_path'];
|
||||
foreach ($requiredFields as $field) {
|
||||
if (!isset($data[$field]) || !is_string($data[$field]) || trim($data[$field]) === '') {
|
||||
return new DataResponse(['error' => "Field '$field' is required"], Http::STATUS_BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
$currency = new CustomCurrency();
|
||||
$currency->setCode(trim((string)$data['code']));
|
||||
if (isset($data['symbol']) && is_string($data['symbol'])) {
|
||||
$currency->setSymbol(trim((string)$data['symbol']));
|
||||
} else {
|
||||
$currency->setSymbol('');
|
||||
}
|
||||
$currency->setApiEndpoint(trim((string)$data['api_endpoint']));
|
||||
$currency->setJsonPath(trim((string)$data['json_path']));
|
||||
if (isset($data['api_key']) && is_string($data['api_key'])) {
|
||||
$currency->setApiKey(trim((string)$data['api_key']));
|
||||
} else {
|
||||
$currency->setApiKey('');
|
||||
}
|
||||
try {
|
||||
$this->customCurrencyMapper->insert($currency);
|
||||
return new DataResponse($currency, Http::STATUS_CREATED);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to create custom currency: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to create custom currency'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom currency
|
||||
*
|
||||
* @param int $id Currency ID
|
||||
* @return DataResponse<Http::STATUS_OK, array{status: non-empty-string}, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Currency deleted
|
||||
* 500: Internal server error
|
||||
*/
|
||||
#[ApiRoute(verb: 'DELETE', url: '/api/custom-currencies/{id}')]
|
||||
public function deleteCustomCurrency(int $id): DataResponse {
|
||||
try {
|
||||
$currency = $this->customCurrencyMapper->find((string)$id);
|
||||
$this->customCurrencyMapper->delete($currency);
|
||||
return new DataResponse(['status' => 'OK']);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to delete custom currency: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to delete custom currency'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom currency
|
||||
*
|
||||
* @param int $id Currency ID
|
||||
* @param array{
|
||||
* code?: string,
|
||||
* symbol?: string,
|
||||
* api_endpoint?: string,
|
||||
* json_path?: string,
|
||||
* api_key?: string
|
||||
* } $data Data to update
|
||||
* @return DataResponse<Http::STATUS_OK, array{
|
||||
* id: int,
|
||||
* code: string,
|
||||
* symbol: string,
|
||||
* api_endpoint: string,
|
||||
* api_key: string,
|
||||
* json_path: string
|
||||
* }, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR, array{error: string}, array{}>
|
||||
*
|
||||
* 200: Currency updated
|
||||
* 500: Internal server error
|
||||
*/
|
||||
#[ApiRoute(verb: 'PUT', url: '/api/custom-currencies/{id}')]
|
||||
public function updateCustomCurrency(int $id, mixed $data): DataResponse {
|
||||
try {
|
||||
$currency = $this->customCurrencyMapper->find((string)$id);
|
||||
if (isset($data['code']) && is_string($data['code']) && trim((string)$data['code']) !== '') {
|
||||
$currency->setCode(trim((string)$data['code']));
|
||||
}
|
||||
if (isset($data['symbol']) && is_string($data['symbol']) && trim((string)$data['symbol']) !== '') {
|
||||
$currency->setSymbol(trim((string)$data['symbol']));
|
||||
}
|
||||
if (isset($data['api_endpoint']) && is_string($data['api_endpoint']) && trim((string)$data['api_endpoint']) !== '') {
|
||||
$currency->setApiEndpoint(trim((string)$data['api_endpoint']));
|
||||
}
|
||||
if (isset($data['json_path']) && is_string($data['json_path']) && trim((string)$data['json_path']) !== '') {
|
||||
$currency->setJsonPath(trim((string)$data['json_path']));
|
||||
}
|
||||
if (array_key_exists('api_key', $data)) {
|
||||
if (is_string($data['api_key'])) {
|
||||
$currency->setApiKey(trim((string)$data['api_key']));
|
||||
} else {
|
||||
$currency->setApiKey('');
|
||||
}
|
||||
}
|
||||
$this->customCurrencyMapper->update($currency);
|
||||
return new DataResponse($currency);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to update custom currency: ' . $e->getMessage());
|
||||
return new DataResponse(['error' => 'Failed to update custom currency'], Http::STATUS_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
lib/Db/CustomCurrency.php
Normal file
51
lib/Db/CustomCurrency.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\AutoCurrency\Db;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
use OCP\AppFramework\Db\Entity;
|
||||
|
||||
/**
|
||||
* @method string getCode()
|
||||
* @method void setCode(string $value)
|
||||
* @method string getSymbol()
|
||||
* @method void setSymbol(string $value)
|
||||
* @method string getApiEndpoint()
|
||||
* @method void setApiEndpoint(string $value)
|
||||
* @method string getApiKey()
|
||||
* @method void setApiKey(string $value)
|
||||
* @method string getJsonPath()
|
||||
* @method void setJsonPath(string $value)
|
||||
*/
|
||||
class CustomCurrency extends Entity implements JsonSerializable {
|
||||
protected $code = '';
|
||||
protected $symbol = '';
|
||||
protected $apiEndpoint = '';
|
||||
protected $apiKey = '';
|
||||
protected $jsonPath = '';
|
||||
|
||||
public function __construct() {
|
||||
$this->addType('code', 'string');
|
||||
$this->addType('symbol', 'string');
|
||||
$this->addType('apiEndpoint', 'string');
|
||||
$this->addType('apiKey', 'string');
|
||||
$this->addType('jsonPath', 'string');
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array {
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'code' => $this->getCode(),
|
||||
'symbol' => $this->getSymbol(),
|
||||
'api_endpoint' => $this->getApiEndpoint(),
|
||||
'api_key' => $this->getApiKey(),
|
||||
'json_path' => $this->getJsonPath(),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
lib/Db/CustomCurrencyMapper.php
Normal file
52
lib/Db/CustomCurrencyMapper.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\AutoCurrency\Db;
|
||||
|
||||
use OCA\AutoCurrency\AppInfo\Application;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\QBMapper;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IDBConnection;
|
||||
|
||||
/**
|
||||
* @template-extends QBMapper<CustomCurrency>
|
||||
*/
|
||||
class CustomCurrencyMapper extends QBMapper {
|
||||
public function __construct(
|
||||
IDBConnection $db,
|
||||
) {
|
||||
parent::__construct($db, Application::tableName('custom'), CustomCurrency::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $id
|
||||
* @throws \OCP\AppFramework\Db\MultipleObjectsReturnedException
|
||||
* @throws DoesNotExistException
|
||||
*/
|
||||
public function find(string $id): CustomCurrency {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')
|
||||
->from($this->getTableName())
|
||||
->where(
|
||||
$qb->expr()
|
||||
->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_STR))
|
||||
);
|
||||
return $this->findEntity($qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<CustomCurrency>
|
||||
*/
|
||||
public function findAll(): array {
|
||||
/* @var $qb IQueryBuilder */
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('*')->from($this->getTableName());
|
||||
return $this->findEntities($qb);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version0Date20250925012201 extends SimpleMigrationStep {
|
||||
class Version1Date20250925012201 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
75
lib/Migration/Version2Date20251005011020.php
Normal file
75
lib/Migration/Version2Date20251005011020.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\AutoCurrency\Migration;
|
||||
|
||||
use Closure;
|
||||
use OCP\DB\ISchemaWrapper;
|
||||
use OCP\Migration\IOutput;
|
||||
use OCP\Migration\SimpleMigrationStep;
|
||||
|
||||
class Version2Date20251005011020 extends SimpleMigrationStep {
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function preSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
* @return null|ISchemaWrapper
|
||||
*/
|
||||
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||
/** @var ISchemaWrapper $schema */
|
||||
$schema = $schemaClosure();
|
||||
|
||||
if ($schema->hasTable('autocurrency_custom')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$table = $schema->createTable('autocurrency_custom');
|
||||
$table->addColumn('id', 'integer', [
|
||||
'autoincrement' => true,
|
||||
'notnull' => true,
|
||||
]);
|
||||
$table->addColumn('code', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 10,
|
||||
]);
|
||||
$table->addColumn('symbol', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 10,
|
||||
]);
|
||||
$table->addColumn('api_endpoint', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('api_key', 'string', [
|
||||
'notnull' => false,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->addColumn('json_path', 'string', [
|
||||
'notnull' => true,
|
||||
'length' => 255,
|
||||
]);
|
||||
$table->setPrimaryKey(['id']);
|
||||
|
||||
return $schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param IOutput $output
|
||||
* @param Closure(): ISchemaWrapper $schemaClosure
|
||||
* @param array $options
|
||||
*/
|
||||
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
|
||||
use OCA\AutoCurrency\Db\CospendProjectMapper;
|
||||
use OCA\AutoCurrency\Db\Currency;
|
||||
use OCA\AutoCurrency\Db\CurrencyMapper;
|
||||
use OCA\AutoCurrency\Db\CustomCurrency;
|
||||
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
|
||||
use OCP\IAppConfig;
|
||||
@@ -27,22 +29,37 @@ class FetchCurrenciesService {
|
||||
private static $EXCHANGE_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/{base}.json';
|
||||
private static $SYMBOLS_FILE = __DIR__ . '/symbols.json';
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
/** @var array<string, mixed> */
|
||||
public array $symbols = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $symbolPreference = [];
|
||||
|
||||
/**
|
||||
* Cache for API responses to avoid duplicate fetches
|
||||
* @var array<string, array>
|
||||
*/
|
||||
private array $apiCache = [];
|
||||
|
||||
/**
|
||||
* Cache for custom currencies
|
||||
* @var array<string, CustomCurrency>|null
|
||||
*/
|
||||
private ?array $customCurrenciesCache = null;
|
||||
|
||||
public function __construct(
|
||||
private IAppConfig $config,
|
||||
private CurrencyMapper $currencyMapper,
|
||||
private CospendProjectMapper $projectMapper,
|
||||
private AutocurrencyRateHistoryMapper $historyMapper,
|
||||
private CustomCurrencyMapper $customCurrencyMapper,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->currencyMapper = $currencyMapper;
|
||||
$this->projectMapper = $projectMapper;
|
||||
$this->historyMapper = $historyMapper; // ⬅️ NEW
|
||||
$this->historyMapper = $historyMapper;
|
||||
$this->customCurrencyMapper = $customCurrencyMapper;
|
||||
$this->logger = $logger;
|
||||
$this->loadSymbols();
|
||||
}
|
||||
@@ -50,67 +67,353 @@ class FetchCurrenciesService {
|
||||
public function fetchCurrencyRates(): void {
|
||||
$this->logger->info('Starting cron job to fetch currencies');
|
||||
$projects = $this->projectMapper->findAll();
|
||||
$currencyMap = [];
|
||||
|
||||
$this->logger->info('Found ' . count($projects) . ' projects');
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$currencyName = $project->getCurrencyName();
|
||||
if (!$currencyName) {
|
||||
$this->logger->warning('Currency name not found for project ' . $project->id);
|
||||
continue;
|
||||
}
|
||||
$base = $this->getCurrencyName($currencyName);
|
||||
$lbase = strtolower($base);
|
||||
|
||||
if (isset($currencyMap[$base])) {
|
||||
$json = $currencyMap[$base];
|
||||
} else {
|
||||
// request currency exchange rates from the API
|
||||
$this->logger->info('Fetching exchange rates for base currency ' . $base);
|
||||
$fp = fopen(str_replace('{base}', $lbase, FetchCurrenciesService::$EXCHANGE_URL), 'r');
|
||||
$data = stream_get_contents($fp);
|
||||
fclose($fp);
|
||||
$json = json_decode($data, true);
|
||||
$this->logger->info('Fetched exchange rates for base currency: ' . json_encode($json));
|
||||
if ($json[$lbase] == null) {
|
||||
$this->logger->error(new \Error('Failed to fetch exchange rates for base currency ' . $base));
|
||||
continue;
|
||||
}
|
||||
$currencyMap[$lbase] = $json;
|
||||
}
|
||||
|
||||
$currencies = $this->findAllCurrencies($project->id);
|
||||
|
||||
foreach ($currencies as $currency) {
|
||||
$cur = $this->getCurrencyName($currency->getName());
|
||||
if ($cur === null) {
|
||||
$this->logger->error('Currency not found: ' . $currency->getName());
|
||||
continue;
|
||||
}
|
||||
$lcur = strtolower($cur);
|
||||
$baseRate = $json[$lbase][$lcur];
|
||||
$newRate = 1.0 / $baseRate;
|
||||
|
||||
$currency->setExchangeRate($newRate);
|
||||
$this->logger->info('Setting exchange rate for currency ' . $cur . ' to ' . $newRate);
|
||||
$this->currencyMapper->update($currency);
|
||||
|
||||
$this->writeHistory(
|
||||
projectId: (string)$project->id,
|
||||
projectName: $this->safeProjectName($project),
|
||||
baseCurrency: $lbase,
|
||||
currencyName: $lcur,
|
||||
rate: $newRate,
|
||||
currencyId: (int)$currency->getId()
|
||||
);
|
||||
}
|
||||
$this->processProject($project);
|
||||
}
|
||||
|
||||
$lastUpdate = date('c');
|
||||
$this->config->setValueString(AppInfo\Application::APP_ID, 'last_update', $lastUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single project - fetch and update all its currency rates
|
||||
*/
|
||||
private function processProject(object $project): void {
|
||||
$currencyName = $project->getCurrencyName();
|
||||
if (!$currencyName) {
|
||||
$this->logger->warning('Currency name not found for project ' . $project->id);
|
||||
return;
|
||||
}
|
||||
|
||||
$base = $this->getCurrencyName($currencyName);
|
||||
$lbase = strtolower($base);
|
||||
|
||||
$this->logger->info('Processing project ' . $project->id . ' with base currency ' . $base);
|
||||
|
||||
$currencies = $this->findAllCurrencies($project->id);
|
||||
|
||||
foreach ($currencies as $currency) {
|
||||
$this->processCurrency($currency, $project, $lbase);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single currency for a project
|
||||
*/
|
||||
private function processCurrency(Currency $currency, object $project, string $baseCurrency): void {
|
||||
$currencyCode = strtolower((string)$currency->getName());
|
||||
|
||||
// Check if this currency has a custom configuration first
|
||||
$customCurrency = $this->findCustomCurrency($currencyCode);
|
||||
$source = null;
|
||||
|
||||
if ($customCurrency !== null) {
|
||||
// Custom currency found - use custom logic
|
||||
$this->logger->info("Using custom currency configuration for $currencyCode");
|
||||
$result = $this->fetchCustomCurrencyRate($customCurrency, $baseCurrency);
|
||||
|
||||
if ($result === null) {
|
||||
$this->logger->warning("Failed to fetch custom rate for $currencyCode, falling back to standard API");
|
||||
$newRate = $this->fetchStandardRate($baseCurrency, $currencyCode);
|
||||
$source = $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency);
|
||||
} else {
|
||||
$newRate = $result['rate'];
|
||||
$source = $result['source'];
|
||||
}
|
||||
$lcur = $currencyCode;
|
||||
} else {
|
||||
// Not a custom currency - validate against standard symbols
|
||||
$cur = $this->getCurrencyName($currency->getName());
|
||||
if ($cur === null) {
|
||||
$this->logger->error('Currency not found: ' . $currency->getName());
|
||||
return;
|
||||
}
|
||||
$lcur = strtolower($cur);
|
||||
|
||||
// Use standard API
|
||||
$newRate = $this->fetchStandardRate($baseCurrency, $lcur);
|
||||
$source = $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency);
|
||||
}
|
||||
|
||||
if ($newRate === null) {
|
||||
$this->logger->error("Failed to fetch rate for $currencyCode");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->updateCurrencyRate($currency, $project, $baseCurrency, $lcur, $newRate, $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch exchange rate using the standard API
|
||||
*/
|
||||
private function fetchStandardRate(string $baseCurrency, string $targetCurrency): ?float {
|
||||
try {
|
||||
$json = $this->fetchStandardRates($baseCurrency);
|
||||
|
||||
if (!isset($json[$baseCurrency][$targetCurrency])) {
|
||||
$this->logger->error("Rate not found for $targetCurrency in base $baseCurrency");
|
||||
return null;
|
||||
}
|
||||
|
||||
$baseRate = $json[$baseCurrency][$targetCurrency];
|
||||
return 1.0 / $baseRate;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error fetching standard rate: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all exchange rates for a base currency from the standard API (with caching)
|
||||
* @return array|mixed
|
||||
*/
|
||||
private function fetchStandardRates(string $baseCurrency): array {
|
||||
if (isset($this->apiCache[$baseCurrency])) {
|
||||
return $this->apiCache[$baseCurrency];
|
||||
}
|
||||
|
||||
$this->logger->info('Fetching exchange rates for base currency ' . $baseCurrency);
|
||||
$url = $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency);
|
||||
|
||||
$fp = fopen($url, 'r');
|
||||
if (!$fp) {
|
||||
throw new \RuntimeException("Failed to open URL: $url");
|
||||
}
|
||||
|
||||
$data = stream_get_contents($fp);
|
||||
fclose($fp);
|
||||
|
||||
$json = json_decode($data, true);
|
||||
|
||||
if (!isset($json[$baseCurrency])) {
|
||||
throw new \RuntimeException("Failed to fetch exchange rates for base currency $baseCurrency");
|
||||
}
|
||||
|
||||
$this->apiCache[$baseCurrency] = $json;
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a currency's exchange rate in the database and history
|
||||
*/
|
||||
private function updateCurrencyRate(
|
||||
Currency $currency,
|
||||
object $project,
|
||||
string $baseCurrency,
|
||||
string $currencyName,
|
||||
float $rate,
|
||||
?string $source,
|
||||
): void {
|
||||
$currency->setExchangeRate($rate);
|
||||
$this->logger->info("Setting exchange rate for currency $currencyName to $rate");
|
||||
$this->currencyMapper->update($currency);
|
||||
|
||||
$this->writeHistory(
|
||||
projectId: (string)$project->id,
|
||||
projectName: $this->safeProjectName($project),
|
||||
baseCurrency: $baseCurrency,
|
||||
currencyName: $currencyName,
|
||||
rate: $rate,
|
||||
currencyId: (int)$currency->getId(),
|
||||
source: $source
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a custom currency by its code
|
||||
*/
|
||||
private function findCustomCurrency(string $currencyCode): ?CustomCurrency {
|
||||
if ($this->customCurrenciesCache === null) {
|
||||
$this->customCurrenciesCache = [];
|
||||
$customCurrencies = $this->customCurrencyMapper->findAll();
|
||||
foreach ($customCurrencies as $cc) {
|
||||
$code = strtolower($cc->getCode());
|
||||
$this->customCurrenciesCache[$code] = $cc;
|
||||
}
|
||||
$this->logger->debug('Loaded ' . count($this->customCurrenciesCache) . ' custom currencies');
|
||||
}
|
||||
|
||||
return $this->customCurrenciesCache[strtolower($currencyCode)] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch exchange rate from a custom currency endpoint
|
||||
* @return array{rate: float, source: string}|null
|
||||
*/
|
||||
private function fetchCustomCurrencyRate(CustomCurrency $customCurrency, string $baseCurrency): ?array {
|
||||
try {
|
||||
$endpoint = $this->replaceTokens($customCurrency->getApiEndpoint(), $baseCurrency);
|
||||
$this->logger->debug("Fetching custom rate from: $endpoint");
|
||||
|
||||
$hasBaseToken = strpos($customCurrency->getApiEndpoint(), '{base}') !== false
|
||||
|| strpos($customCurrency->getJsonPath(), '{base}') !== false;
|
||||
|
||||
$apiKey = $customCurrency->getApiKey();
|
||||
$response = $this->fetchApiResponse($endpoint, $apiKey);
|
||||
$jsonPath = $this->replaceTokens($customCurrency->getJsonPath(), $baseCurrency);
|
||||
$rawRate = $this->extractJsonPath($response, $jsonPath);
|
||||
|
||||
if ($rawRate === null) {
|
||||
$this->logger->error("Failed to extract rate from JSON path: $jsonPath");
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = (float)$rawRate;
|
||||
|
||||
// If {base} token wasn't used, we assume USD and may need to convert
|
||||
if (!$hasBaseToken && strtolower($baseCurrency) !== 'usd') {
|
||||
$this->logger->info("Custom currency endpoint doesn't use {base} token, assuming USD rate");
|
||||
$rate = $this->convertFromUsdToBase($rate, $baseCurrency);
|
||||
}
|
||||
|
||||
return [
|
||||
'rate' => $rate,
|
||||
'source' => $endpoint,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error fetching custom currency rate: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace tokens in a string (e.g., {base} with actual base currency)
|
||||
*/
|
||||
private function replaceTokens(string $text, string $baseCurrency): string {
|
||||
return str_replace('{base}', strtolower($baseCurrency), $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch API response with caching
|
||||
* @return array|mixed
|
||||
*/
|
||||
private function fetchApiResponse(string $url, ?string $apiKey = null): array {
|
||||
// Create a cache key that includes the API key (hashed for security)
|
||||
$cacheKey = $apiKey ? $url . ':' . md5($apiKey) : $url;
|
||||
|
||||
if (isset($this->apiCache[$cacheKey])) {
|
||||
$this->logger->debug("Using cached response for: $url");
|
||||
return $this->apiCache[$cacheKey];
|
||||
}
|
||||
|
||||
$this->logger->debug("Fetching API response from: $url");
|
||||
|
||||
// Set up stream context with headers if API key is provided
|
||||
$opts = [
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => '',
|
||||
],
|
||||
];
|
||||
|
||||
if ($apiKey && $apiKey !== '') {
|
||||
$opts['http']['header'] = "Authorization: Bearer $apiKey\r\n";
|
||||
$this->logger->debug('Using API key for authentication');
|
||||
}
|
||||
|
||||
$context = stream_context_create($opts);
|
||||
$fp = fopen($url, 'r', false, $context);
|
||||
if (!$fp) {
|
||||
throw new \RuntimeException("Failed to open URL: $url");
|
||||
}
|
||||
|
||||
$data = stream_get_contents($fp);
|
||||
fclose($fp);
|
||||
|
||||
$json = json_decode($data, true);
|
||||
|
||||
if ($json === null) {
|
||||
throw new \RuntimeException("Failed to decode JSON from: $url");
|
||||
}
|
||||
|
||||
$this->apiCache[$cacheKey] = $json;
|
||||
return $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract value from JSON using a simple JSON path notation
|
||||
* Supports both formats:
|
||||
* - With $. prefix: $.key, $.key.subkey, $.key[0]
|
||||
* - Without prefix: key, key.subkey, key[0], rates.{base}.btc
|
||||
* @param array<int,mixed> $data
|
||||
*/
|
||||
private function extractJsonPath(array $data, string $path): mixed {
|
||||
$path = preg_replace('/^\$\.?/', '', $path);
|
||||
|
||||
if ($path === '') {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// Split path by dots and brackets
|
||||
$parts = preg_split('/\.|\[|\]/', $path, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
$current = $data;
|
||||
foreach ($parts as $part) {
|
||||
if (is_array($current)) {
|
||||
if (is_numeric($part)) {
|
||||
// Array index
|
||||
$index = (int)$part;
|
||||
if (!isset($current[$index])) {
|
||||
return null;
|
||||
}
|
||||
$current = $current[$index];
|
||||
} else {
|
||||
// Object key
|
||||
if (!isset($current[$part])) {
|
||||
return null;
|
||||
}
|
||||
$current = $current[$part];
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a rate from USD to the actual base currency
|
||||
* If we have a rate in USD but need it in EUR, we need to convert it
|
||||
*/
|
||||
private function convertFromUsdToBase(float $usdRate, string $baseCurrency): float {
|
||||
try {
|
||||
$this->logger->info("Converting rate from USD to $baseCurrency");
|
||||
|
||||
// Fetch USD exchange rates
|
||||
$usdRates = $this->fetchStandardRates('usd');
|
||||
|
||||
if (!isset($usdRates['usd'][$baseCurrency])) {
|
||||
$this->logger->error("Cannot convert: USD to $baseCurrency rate not found");
|
||||
return $usdRate; // Return original rate as fallback
|
||||
}
|
||||
|
||||
// Get the USD -> baseCurrency rate
|
||||
$usdToBase = $usdRates['usd'][$baseCurrency];
|
||||
|
||||
// Check for zero to avoid division by zero
|
||||
if ($usdToBase == 0) {
|
||||
$this->logger->error("Cannot convert: USD to $baseCurrency rate is zero");
|
||||
return $usdRate; // Return original rate as fallback
|
||||
}
|
||||
|
||||
// Convert: if 1 CustomCurrency = X USD, and 1 USD = Y BaseCurrency
|
||||
// then 1 CustomCurrency = X * Y BaseCurrency
|
||||
// Example: 1 XMR = 320 USD, and 1 USD = 3.5 ILS → 1 XMR = 320 * 3.5 = 1120 ILS
|
||||
$convertedRate = $usdRate * $usdToBase;
|
||||
|
||||
$this->logger->info("Converted rate from USD: $usdRate to $baseCurrency: $convertedRate");
|
||||
|
||||
return $convertedRate;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Error converting from USD to base: ' . $e->getMessage());
|
||||
return $usdRate; // Return original rate as fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a rate history row. If a duplicate occurs due to the UNIQUE constraint
|
||||
* (project_id, currency_name, fetched_at), we quietly ignore it.
|
||||
@@ -122,10 +425,11 @@ class FetchCurrenciesService {
|
||||
string $currencyName,
|
||||
float $rate,
|
||||
int $currencyId,
|
||||
?string $source = null,
|
||||
): void {
|
||||
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||
|
||||
// 1) Check for an existing sample at the same timestamp
|
||||
// Check for an existing sample at the same timestamp
|
||||
$existing = $this->historyMapper->findByProjectAndBase(
|
||||
projectId: $projectId,
|
||||
baseCurrency: $baseCurrency,
|
||||
@@ -148,11 +452,11 @@ class FetchCurrenciesService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Insert new history row
|
||||
// Insert new history row
|
||||
try {
|
||||
$entity = new AutocurrencyRateHistory();
|
||||
|
||||
// Keep rate as string to avoid float precision issues with DECIMAL in DB
|
||||
// NOTE Keep rate as string to avoid float precision issues with DECIMAL in DB
|
||||
$rateStr = sprintf('%.10F', $rate);
|
||||
|
||||
$entity->setProjectId($projectId);
|
||||
@@ -161,7 +465,7 @@ class FetchCurrenciesService {
|
||||
$entity->setCurrencyName($currencyName);
|
||||
$entity->setRate($rateStr);
|
||||
$entity->setFetchedAt($now->format(DATE_ATOM));
|
||||
$entity->setSource(str_replace('{base}', $baseCurrency, self::$EXCHANGE_URL));
|
||||
$entity->setSource($source ?? $this->replaceTokens(self::$EXCHANGE_URL, $baseCurrency));
|
||||
$entity->setCurrencyId($currencyId);
|
||||
|
||||
$this->historyMapper->insert($entity);
|
||||
|
||||
@@ -459,6 +459,830 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/autocurrency/api/custom-currencies": {
|
||||
"get": {
|
||||
"operationId": "api-get-custom-currencies",
|
||||
"summary": "Get all custom currencies",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Data returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"currencies"
|
||||
],
|
||||
"properties": {
|
||||
"currencies": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"code",
|
||||
"symbol",
|
||||
"api_endpoint",
|
||||
"api_key",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "api-create-custom-currency",
|
||||
"summary": "Create a new custom currency",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Data to create",
|
||||
"required": [
|
||||
"code",
|
||||
"api_endpoint",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Currency created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"code",
|
||||
"symbol",
|
||||
"api_endpoint",
|
||||
"api_key",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/autocurrency/api/custom-currencies/{id}": {
|
||||
"delete": {
|
||||
"operationId": "api-delete-custom-currency",
|
||||
"summary": "Delete a custom currency",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Currency ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Currency deleted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "api-update-custom-currency",
|
||||
"summary": "Update a custom currency",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Data to update",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Currency ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Currency updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"code",
|
||||
"symbol",
|
||||
"api_endpoint",
|
||||
"api_key",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
|
||||
@@ -460,6 +460,830 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/autocurrency/api/custom-currencies": {
|
||||
"get": {
|
||||
"operationId": "api-get-custom-currencies",
|
||||
"summary": "Get all custom currencies",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Data returned",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"currencies"
|
||||
],
|
||||
"properties": {
|
||||
"currencies": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"code",
|
||||
"symbol",
|
||||
"api_endpoint",
|
||||
"api_key",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"operationId": "api-create-custom-currency",
|
||||
"summary": "Create a new custom currency",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Data to create",
|
||||
"required": [
|
||||
"code",
|
||||
"api_endpoint",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Currency created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"code",
|
||||
"symbol",
|
||||
"api_endpoint",
|
||||
"api_key",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/autocurrency/api/custom-currencies/{id}": {
|
||||
"delete": {
|
||||
"operationId": "api-delete-custom-currency",
|
||||
"summary": "Delete a custom currency",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Currency ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Currency deleted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"operationId": "api-update-custom-currency",
|
||||
"summary": "Update a custom currency",
|
||||
"description": "This endpoint requires admin access",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer_auth": []
|
||||
},
|
||||
{
|
||||
"basic_auth": []
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "object",
|
||||
"description": "Data to update",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "Currency ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OCS-APIRequest",
|
||||
"in": "header",
|
||||
"description": "Required to be true for the API request to pass",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Currency updated",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"code",
|
||||
"symbol",
|
||||
"api_endpoint",
|
||||
"api_key",
|
||||
"json_path"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string"
|
||||
},
|
||||
"json_path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Current user is not logged in",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Logged in account must be an admin",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"ocs"
|
||||
],
|
||||
"properties": {
|
||||
"ocs": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"meta",
|
||||
"data"
|
||||
],
|
||||
"properties": {
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/OCSMeta"
|
||||
},
|
||||
"data": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/ocs/v2.php/apps/autocurrency/api/user-settings": {
|
||||
"get": {
|
||||
"operationId": "api-get-user-settings",
|
||||
@@ -515,21 +1339,24 @@
|
||||
],
|
||||
"properties": {
|
||||
"supported_currencies": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"symbol",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"symbol",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
openapi.json
33
openapi.json
@@ -102,21 +102,24 @@
|
||||
],
|
||||
"properties": {
|
||||
"supported_currencies": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"symbol",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"symbol",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"chart.js": "^4.5.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"vue": "^3.5.22"
|
||||
"vue": "^3.5.22",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.37.0",
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -35,6 +35,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.22
|
||||
version: 3.5.22(typescript@5.9.3)
|
||||
vue-material-design-icons:
|
||||
specifier: ^5.3.1
|
||||
version: 5.3.1
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.37.0
|
||||
@@ -3842,6 +3845,9 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^2.6.0
|
||||
|
||||
vue-material-design-icons@5.3.1:
|
||||
resolution: {integrity: sha512-6UNEyhlTzlCeT8ZeX5WbpUGFTTPSbOoTQeoASTv7X4Ylh0pe8vltj+36VMK56KM0gG8EQVoMK/Qw/6evalg8lA==}
|
||||
|
||||
vue-resize@2.0.0-alpha.1:
|
||||
resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==}
|
||||
peerDependencies:
|
||||
@@ -8275,6 +8281,8 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
vue-material-design-icons@5.3.1: {}
|
||||
|
||||
vue-resize@2.0.0-alpha.1(vue@3.5.22(typescript@5.9.3)):
|
||||
dependencies:
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
@@ -11,7 +11,7 @@ function getLatestMigration() {
|
||||
const migrationFiles = files.sort((a, b) => a.localeCompare(b))
|
||||
const latestMigration = migrationFiles[migrationFiles.length - 1]
|
||||
const matches = /Version(\d+)/.exec(latestMigration)
|
||||
const version = matches ? Number(matches[1]) + 1 : 0
|
||||
const version = (matches ? Number(matches[1]) : 0) + 1
|
||||
return version
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,90 @@
|
||||
<p v-html="strings.instructionsHelp" />
|
||||
</NcNoteCard>
|
||||
|
||||
<NcAppSettingsSection :name="strings.customCurrenciesHeader">
|
||||
<NcNoteCard type="info">
|
||||
<p v-html="strings.customCurrenciesHelp" />
|
||||
</NcNoteCard>
|
||||
|
||||
<div class="custom-currencies-list">
|
||||
<div
|
||||
v-for="(currency, index) in customCurrencies"
|
||||
:key="currency.tempId || currency.id"
|
||||
class="currency-item"
|
||||
>
|
||||
<div class="currency-fields">
|
||||
<div class="field-row">
|
||||
<NcTextField
|
||||
v-model="currency.code"
|
||||
:label="strings.currencyCode"
|
||||
:placeholder="strings.currencyCodePlaceholder"
|
||||
required
|
||||
:disabled="loading"
|
||||
/>
|
||||
<NcTextField
|
||||
v-model="currency.symbol"
|
||||
:label="strings.currencySymbol"
|
||||
:placeholder="strings.currencySymbolPlaceholder"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<NcTextField
|
||||
v-model="currency.api_endpoint"
|
||||
:label="strings.apiEndpoint"
|
||||
type="url"
|
||||
:placeholder="strings.apiEndpointPlaceholder"
|
||||
required
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<NcTextField
|
||||
v-model="currency.api_key"
|
||||
:label="strings.apiKey"
|
||||
type="password"
|
||||
:placeholder="strings.apiKeyPlaceholder"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<NcTextField
|
||||
v-model="currency.json_path"
|
||||
:label="strings.jsonPath"
|
||||
:placeholder="strings.jsonPathPlaceholder"
|
||||
required
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NcButton
|
||||
type="error"
|
||||
@click="removeCurrency(index)"
|
||||
:disabled="loading"
|
||||
:aria-label="strings.deleteCurrency"
|
||||
>
|
||||
<template #icon>
|
||||
<Delete :size="20" />
|
||||
</template>
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<NcButton @click="addCurrency" :disabled="loading">
|
||||
<template #icon>
|
||||
<Plus :size="20" />
|
||||
</template>
|
||||
{{ strings.addCurrency }}
|
||||
</NcButton>
|
||||
</div>
|
||||
|
||||
<div class="submit-buttons">
|
||||
<NcButton type="primary" @click="saveCustomCurrencies" :disabled="loading">
|
||||
{{ strings.save }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<NcAppSettingsSection :name="strings.cronSettingsHeader">
|
||||
<section>
|
||||
<form @submit.prevent="save">
|
||||
@@ -44,6 +128,9 @@ import NcSelect from '@nextcloud/vue/components/NcSelect'
|
||||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||||
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
|
||||
import NcTextField from '@nextcloud/vue/components/NcTextField'
|
||||
import Plus from '@icons/Plus.vue'
|
||||
import Delete from '@icons/Delete.vue'
|
||||
|
||||
import { APP_ID } from '@/consts'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
@@ -59,12 +146,18 @@ export default {
|
||||
NcDateTime,
|
||||
NcSelect,
|
||||
NcNoteCard,
|
||||
NcTextField,
|
||||
Plus,
|
||||
Delete,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
interval: null,
|
||||
lastUpdate: null,
|
||||
customCurrencies: [],
|
||||
originalCustomCurrencies: [],
|
||||
tempIdCounter: 0,
|
||||
intervalOptions: [
|
||||
{ label: t(APP_ID, 'Every hour'), value: 1 },
|
||||
{ label: n(APP_ID, 'Every %n hour', 'Every %n hours', 3), value: 3 },
|
||||
@@ -78,6 +171,26 @@ export default {
|
||||
],
|
||||
strings: {
|
||||
title: t(APP_ID, 'Auto Currency for Cospend'),
|
||||
customCurrenciesHeader: t(APP_ID, 'Custom Currencies'),
|
||||
customCurrenciesHelp: t(
|
||||
APP_ID,
|
||||
`Define custom currencies with their own API endpoints.{br}Use {cStart}{base}{cEnd} in the endpoint URL or JSON path to substitute the project's base currency.{br}The API should return a rate in the base currency (or USD if {cStart}{base}{cEnd} is not used).{br}The API key will be passed in the {cStart}Authorization{cEnd} header as {cStart}Bearer{cEnd} if provided.`,
|
||||
{ br: '<br />', cStart: '<code>', cEnd: '</code>' },
|
||||
undefined,
|
||||
{ escape: false },
|
||||
),
|
||||
currencyCode: t(APP_ID, 'Currency Code'),
|
||||
currencyCodePlaceholder: t(APP_ID, 'e.g., BTC'),
|
||||
currencySymbol: t(APP_ID, 'Symbol (optional)'),
|
||||
currencySymbolPlaceholder: t(APP_ID, 'e.g., ₿'),
|
||||
apiEndpoint: t(APP_ID, 'API Endpoint'),
|
||||
apiEndpointPlaceholder: t(APP_ID, 'e.g., https://api.example.com/rates/{base}'),
|
||||
apiKey: t(APP_ID, 'API Key (optional)'),
|
||||
apiKeyPlaceholder: t(APP_ID, 'Leave empty if not required'),
|
||||
jsonPath: t(APP_ID, 'JSON Path'),
|
||||
jsonPathPlaceholder: t(APP_ID, 'e.g., $.rates.{base} or data[0].rate'),
|
||||
addCurrency: t(APP_ID, 'Add Currency'),
|
||||
deleteCurrency: t(APP_ID, 'Delete Currency'),
|
||||
cronSettingsHeader: t(APP_ID, 'Cron Settings'),
|
||||
instructionsHelp: t(
|
||||
APP_ID,
|
||||
@@ -101,6 +214,7 @@ export default {
|
||||
},
|
||||
created() {
|
||||
this.fetchSettings()
|
||||
this.fetchCustomCurrencies()
|
||||
},
|
||||
methods: {
|
||||
async fetchSettings() {
|
||||
@@ -156,6 +270,70 @@ export default {
|
||||
console.error('Failed to update Auto Currency settings', e)
|
||||
}
|
||||
},
|
||||
async fetchCustomCurrencies() {
|
||||
try {
|
||||
this.loading = true
|
||||
const resp = await ocs.get('/custom-currencies')
|
||||
this.customCurrencies = resp.data.currencies.map((c) => ({ ...c }))
|
||||
this.originalCustomCurrencies = JSON.parse(JSON.stringify(resp.data.currencies))
|
||||
this.loading = false
|
||||
console.debug('[DEBUG] Custom currencies fetched', this.customCurrencies)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch custom currencies', e)
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
addCurrency() {
|
||||
this.customCurrencies.push({
|
||||
tempId: `temp-${this.tempIdCounter++}`,
|
||||
code: '',
|
||||
symbol: '',
|
||||
api_endpoint: '',
|
||||
api_key: '',
|
||||
json_path: '',
|
||||
})
|
||||
},
|
||||
removeCurrency(index) {
|
||||
this.customCurrencies.splice(index, 1)
|
||||
},
|
||||
async saveCustomCurrencies() {
|
||||
try {
|
||||
this.loading = true
|
||||
|
||||
// Determine what needs to be created, updated, or deleted
|
||||
const toCreate = this.customCurrencies.filter((c) => c.tempId)
|
||||
const toUpdate = this.customCurrencies.filter((c) => c.id && !c.tempId)
|
||||
const toDelete = this.originalCustomCurrencies.filter(
|
||||
(orig) => !this.customCurrencies.some((curr) => curr.id === orig.id),
|
||||
)
|
||||
|
||||
// Delete removed currencies
|
||||
for (const currency of toDelete) {
|
||||
await ocs.delete(`/custom-currencies/${currency.id}`)
|
||||
console.debug('[DEBUG] Deleted currency', currency.id)
|
||||
}
|
||||
|
||||
// Create new currencies
|
||||
for (const currency of toCreate) {
|
||||
const { tempId, ...data } = currency
|
||||
await ocs.post('/custom-currencies', { data })
|
||||
console.debug('[DEBUG] Created currency', data.code)
|
||||
}
|
||||
|
||||
// Update existing currencies
|
||||
for (const currency of toUpdate) {
|
||||
const { id, ...data } = currency
|
||||
await ocs.put(`/custom-currencies/${id}`, { data })
|
||||
console.debug('[DEBUG] Updated currency', id)
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await this.fetchCustomCurrencies()
|
||||
} catch (e) {
|
||||
console.error('Failed to save custom currencies', e)
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
intervals() {
|
||||
@@ -186,5 +364,38 @@ export default {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.custom-currencies-list {
|
||||
margin-top: 16px;
|
||||
|
||||
.currency-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
margin-bottom: 12px;
|
||||
background-color: var(--color-background-hover);
|
||||
|
||||
.currency-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,6 +36,8 @@ use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
|
||||
use OCA\AutoCurrency\Db\CospendProjectMapper;
|
||||
use OCA\AutoCurrency\Db\Currency;
|
||||
use OCA\AutoCurrency\Db\CurrencyMapper;
|
||||
use OCA\AutoCurrency\Db\CustomCurrency;
|
||||
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
|
||||
use OCA\AutoCurrency\Service\FetchCurrenciesService;
|
||||
use OCA\Cospend\Db\Project;
|
||||
use OCP\IAppConfig;
|
||||
@@ -54,6 +56,7 @@ final class ApiControllerTest extends TestCase {
|
||||
/** @var CurrencyMapper&MockObject */ private $currencyMapper;
|
||||
/** @var CospendProjectMapper&MockObject */ private $projectMapper;
|
||||
/** @var AutocurrencyRateHistoryMapper&MockObject */ private $historyMapper;
|
||||
/** @var CustomCurrencyMapper&MockObject */ private $customCurrencyMapper;
|
||||
/** @var LoggerInterface&MockObject */ private $logger;
|
||||
/** @var FetchCurrenciesService */ private $service;
|
||||
|
||||
@@ -66,7 +69,7 @@ final class ApiControllerTest extends TestCase {
|
||||
|
||||
/**
|
||||
* Build controller with optional overrides:
|
||||
* - 'config', 'request', 'l10n', 'currencyMapper', 'projectMapper', 'historyMapper', 'logger'
|
||||
* - 'config', 'request', 'l10n', 'currencyMapper', 'projectMapper', 'historyMapper', 'customCurrencyMapper', 'logger'
|
||||
* - 'serviceMethods' => methods to partial-mock on FetchCurrenciesService
|
||||
* - 'symbols' => fixture array for $service->symbols
|
||||
* @param array<string,mixed> $opts
|
||||
@@ -80,11 +83,14 @@ final class ApiControllerTest extends TestCase {
|
||||
$this->currencyMapper = $opts['currencyMapper'] ?? $this->createMock(CurrencyMapper::class);
|
||||
$this->projectMapper = $opts['projectMapper'] ?? $this->createMock(CospendProjectMapper::class);
|
||||
$this->historyMapper = $opts['historyMapper'] ?? $this->createMock(AutocurrencyRateHistoryMapper::class);
|
||||
$this->customCurrencyMapper = $opts['customCurrencyMapper'] ?? $this->createMock(CustomCurrencyMapper::class);
|
||||
$this->logger = $opts['logger'] ?? $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->customCurrencyMapper->method('findAll')->willReturn([]);
|
||||
|
||||
if (!empty($opts['serviceMethods'])) {
|
||||
$this->service = $this->getMockBuilder(FetchCurrenciesService::class)
|
||||
->setConstructorArgs([$this->config, $this->currencyMapper, $this->projectMapper, $this->historyMapper, $this->logger])
|
||||
->setConstructorArgs([$this->config, $this->currencyMapper, $this->projectMapper, $this->historyMapper, $this->customCurrencyMapper, $this->logger])
|
||||
->onlyMethods($opts['serviceMethods'])
|
||||
->getMock();
|
||||
} else {
|
||||
@@ -93,6 +99,7 @@ final class ApiControllerTest extends TestCase {
|
||||
$this->currencyMapper,
|
||||
$this->projectMapper,
|
||||
$this->historyMapper,
|
||||
$this->customCurrencyMapper,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
@@ -112,7 +119,8 @@ final class ApiControllerTest extends TestCase {
|
||||
$this->service,
|
||||
$this->currencyMapper,
|
||||
$this->projectMapper,
|
||||
$this->historyMapper
|
||||
$this->historyMapper,
|
||||
$this->customCurrencyMapper
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,6 +184,46 @@ final class ApiControllerTest extends TestCase {
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetUserSettings_IncludesCustomCurrencies(): void {
|
||||
$c1 = new CustomCurrency();
|
||||
$c1->setCode('BTC');
|
||||
$c1->setSymbol('₿');
|
||||
$c1->setApiEndpoint('https://api.example.com/btc');
|
||||
$c1->setApiKey('key123');
|
||||
$c1->setJsonPath('$.rate');
|
||||
|
||||
$c2 = new CustomCurrency();
|
||||
$c2->setCode('ETH');
|
||||
$c2->setSymbol('');
|
||||
$c2->setApiEndpoint('https://api.example.com/eth');
|
||||
$c2->setApiKey('');
|
||||
$c2->setJsonPath('$.price');
|
||||
|
||||
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
|
||||
$customCurrencyMapper->expects($this->once())
|
||||
->method('findAll')
|
||||
->willReturn([$c1, $c2]);
|
||||
|
||||
$controller = $this->buildController([
|
||||
'symbols' => [
|
||||
['code' => 'usd', 'symbol' => '$', 'name' => 'US Dollar'],
|
||||
],
|
||||
'customCurrencyMapper' => $customCurrencyMapper,
|
||||
]);
|
||||
|
||||
$data = $controller->getUserSettings()->getData();
|
||||
|
||||
$this->assertCount(3, $data['supported_currencies']);
|
||||
$this->assertSame(
|
||||
[
|
||||
['name' => 'US Dollar', 'code' => 'usd', 'symbol' => '$'],
|
||||
['name' => 'BTC', 'code' => 'BTC', 'symbol' => '₿'],
|
||||
['name' => 'ETH', 'code' => 'ETH', 'symbol' => 'ETH'],
|
||||
],
|
||||
$data['supported_currencies']
|
||||
);
|
||||
}
|
||||
|
||||
public function testRunCron_CallsServiceAndReturnsOk(): void {
|
||||
$controller = $this->buildController(['serviceMethods' => ['fetchCurrencyRates']]);
|
||||
$this->service->expects($this->once())->method('fetchCurrencyRates');
|
||||
@@ -272,6 +320,69 @@ final class ApiControllerTest extends TestCase {
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetProjects_IncludesCustomCurrencies(): void {
|
||||
$btcCustom = new CustomCurrency();
|
||||
$btcCustom->setCode('BTC');
|
||||
$btcCustom->setSymbol('₿');
|
||||
$btcCustom->setApiEndpoint('https://api.example.com/btc');
|
||||
$btcCustom->setApiKey('key123');
|
||||
$btcCustom->setJsonPath('$.rate');
|
||||
|
||||
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
|
||||
$customCurrencyMapper->method('findAll')
|
||||
->willReturn([$btcCustom]);
|
||||
|
||||
$controller = $this->buildController(['customCurrencyMapper' => $customCurrencyMapper]);
|
||||
$user = $this->createConfiguredMock(\OCP\IUser::class, ['getUID' => 'u1']);
|
||||
$this->userSession->method('getUser')->willReturn($user);
|
||||
|
||||
$p1 = $this->getMockBuilder(\OCA\Cospend\Db\Project::class)
|
||||
->disableOriginalConstructor()
|
||||
->addMethods(['getId', 'getName', 'getCurrencyName'])
|
||||
->getMock();
|
||||
$p1->method('getId')->willReturn('p1');
|
||||
$p1->method('getName')->willReturn('Crypto Trip');
|
||||
$p1->method('getCurrencyName')->willReturn('usd');
|
||||
|
||||
$this->projectMapper->method('findAllByUser')->willReturn([$p1]);
|
||||
|
||||
$cUSD = new Currency();
|
||||
$cBTC = new Currency();
|
||||
|
||||
// Set up currencies
|
||||
if (method_exists($cUSD, 'setName')) {
|
||||
$cUSD->setName('USD');
|
||||
$cBTC->setName('BTC');
|
||||
} else {
|
||||
$rp = new \ReflectionProperty($cUSD, 'name');
|
||||
$rp->setAccessible(true);
|
||||
$rp->setValue($cUSD, 'USD');
|
||||
|
||||
$rp = new \ReflectionProperty($cBTC, 'name');
|
||||
$rp->setAccessible(true);
|
||||
$rp->setValue($cBTC, 'BTC');
|
||||
}
|
||||
|
||||
$this->currencyMapper->method('findAll')
|
||||
->willReturn([$cUSD, $cBTC]);
|
||||
|
||||
$data = $controller->getProjects()->getData();
|
||||
|
||||
$this->assertSame(
|
||||
[
|
||||
'projects' => [
|
||||
[
|
||||
'id' => 'p1',
|
||||
'name' => 'Crypto Trip',
|
||||
'baseCurrency' => 'usd',
|
||||
'currencies' => ['usd', 'btc'],
|
||||
],
|
||||
],
|
||||
],
|
||||
$data
|
||||
);
|
||||
}
|
||||
|
||||
public function testGetHistory_EndOfDayTo_AndMapping(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
@@ -360,4 +471,301 @@ final class ApiControllerTest extends TestCase {
|
||||
$this->assertSame(400, $resp->getStatus());
|
||||
$this->assertSame(['error' => 'projectId is required'], $resp->getData());
|
||||
}
|
||||
|
||||
public function testGetCustomCurrencies_ReturnsAllCurrencies(): void {
|
||||
$c1 = new CustomCurrency();
|
||||
$c1->setCode('BTC');
|
||||
$c1->setSymbol('₿');
|
||||
$c1->setApiEndpoint('https://api.example.com/btc');
|
||||
$c1->setApiKey('key123');
|
||||
$c1->setJsonPath('$.rate');
|
||||
|
||||
$c2 = new CustomCurrency();
|
||||
$c2->setCode('ETH');
|
||||
$c2->setSymbol('Ξ');
|
||||
$c2->setApiEndpoint('https://api.example.com/eth');
|
||||
$c2->setApiKey('');
|
||||
$c2->setJsonPath('$.price');
|
||||
|
||||
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
|
||||
$customCurrencyMapper->expects($this->once())
|
||||
->method('findAll')
|
||||
->willReturn([$c1, $c2]);
|
||||
|
||||
$controller = $this->buildController(['customCurrencyMapper' => $customCurrencyMapper]);
|
||||
|
||||
$resp = $controller->getCustomCurrencies();
|
||||
$data = $resp->getData();
|
||||
|
||||
$this->assertArrayHasKey('currencies', $data);
|
||||
$currencies = $data['currencies'];
|
||||
$this->assertCount(2, $currencies);
|
||||
|
||||
// Verify the entities are serialized
|
||||
$this->assertSame('BTC', $currencies[0]['code']);
|
||||
$this->assertSame('₿', $currencies[0]['symbol']);
|
||||
$this->assertSame('ETH', $currencies[1]['code']);
|
||||
}
|
||||
|
||||
public function testCreateCustomCurrency_Success_WithAllFields(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$inputData = [
|
||||
'code' => 'BTC',
|
||||
'symbol' => '₿',
|
||||
'api_endpoint' => 'https://api.example.com/btc',
|
||||
'api_key' => 'secret123',
|
||||
'json_path' => '$.rate',
|
||||
];
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->willReturnCallback(function ($currency) {
|
||||
$this->assertInstanceOf(CustomCurrency::class, $currency);
|
||||
$this->assertSame('BTC', $currency->getCode());
|
||||
$this->assertSame('₿', $currency->getSymbol());
|
||||
$this->assertSame('https://api.example.com/btc', $currency->getApiEndpoint());
|
||||
$this->assertSame('secret123', $currency->getApiKey());
|
||||
$this->assertSame('$.rate', $currency->getJsonPath());
|
||||
return $currency;
|
||||
});
|
||||
|
||||
$resp = $controller->createCustomCurrency($inputData);
|
||||
$this->assertSame(201, $resp->getStatus());
|
||||
}
|
||||
|
||||
public function testCreateCustomCurrency_Success_WithoutApiKey(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$inputData = [
|
||||
'code' => 'ETH',
|
||||
'symbol' => 'Ξ',
|
||||
'api_endpoint' => 'https://api.example.com/eth',
|
||||
'json_path' => '$.price',
|
||||
];
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->willReturnCallback(function ($currency) {
|
||||
$this->assertSame('', $currency->getApiKey());
|
||||
return $currency;
|
||||
});
|
||||
|
||||
$resp = $controller->createCustomCurrency($inputData);
|
||||
$this->assertSame(201, $resp->getStatus());
|
||||
}
|
||||
|
||||
public function testCreateCustomCurrency_BadRequest_MissingCode(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$inputData = [
|
||||
'symbol' => '₿',
|
||||
'api_endpoint' => 'https://api.example.com/btc',
|
||||
'json_path' => '$.rate',
|
||||
];
|
||||
|
||||
$resp = $controller->createCustomCurrency($inputData);
|
||||
$this->assertSame(400, $resp->getStatus());
|
||||
$this->assertArrayHasKey('error', $resp->getData());
|
||||
$this->assertStringContainsString('code', $resp->getData()['error']);
|
||||
}
|
||||
|
||||
public function testCreateCustomCurrency_Success_WithoutSymbol(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$inputData = [
|
||||
'code' => 'BTC',
|
||||
'api_endpoint' => 'https://api.example.com/btc',
|
||||
'json_path' => '$.rate',
|
||||
];
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->willReturnCallback(function ($currency) {
|
||||
$this->assertSame('', $currency->getSymbol());
|
||||
return $currency;
|
||||
});
|
||||
|
||||
$resp = $controller->createCustomCurrency($inputData);
|
||||
$this->assertSame(201, $resp->getStatus());
|
||||
}
|
||||
|
||||
public function testCreateCustomCurrency_InternalError_OnException(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$inputData = [
|
||||
'code' => 'BTC',
|
||||
'symbol' => '₿',
|
||||
'api_endpoint' => 'https://api.example.com/btc',
|
||||
'json_path' => '$.rate',
|
||||
];
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('insert')
|
||||
->willThrowException(new \Exception('Database error'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Failed to create custom currency'));
|
||||
|
||||
$resp = $controller->createCustomCurrency($inputData);
|
||||
$this->assertSame(500, $resp->getStatus());
|
||||
$this->assertArrayHasKey('error', $resp->getData());
|
||||
}
|
||||
|
||||
public function testDeleteCustomCurrency_Success(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$currency = new CustomCurrency();
|
||||
$currency->setCode('BTC');
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with('1')
|
||||
->willReturn($currency);
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('delete')
|
||||
->with($currency);
|
||||
|
||||
$resp = $controller->deleteCustomCurrency(1);
|
||||
$this->assertSame(200, $resp->getStatus());
|
||||
$this->assertSame(['status' => 'OK'], $resp->getData());
|
||||
}
|
||||
|
||||
public function testDeleteCustomCurrency_InternalError_OnException(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with('1')
|
||||
->willThrowException(new \Exception('Not found'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Failed to delete custom currency'));
|
||||
|
||||
$resp = $controller->deleteCustomCurrency(1);
|
||||
$this->assertSame(500, $resp->getStatus());
|
||||
$this->assertArrayHasKey('error', $resp->getData());
|
||||
}
|
||||
|
||||
public function testUpdateCustomCurrency_Success_AllFields(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$currency = new CustomCurrency();
|
||||
$currency->setCode('BTC');
|
||||
$currency->setSymbol('₿');
|
||||
$currency->setApiEndpoint('https://api.example.com/btc');
|
||||
$currency->setApiKey('oldkey');
|
||||
$currency->setJsonPath('$.old');
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with('1')
|
||||
->willReturn($currency);
|
||||
|
||||
$inputData = [
|
||||
'code' => 'ETH',
|
||||
'symbol' => 'Ξ',
|
||||
'api_endpoint' => 'https://api.example.com/eth',
|
||||
'api_key' => 'newkey',
|
||||
'json_path' => '$.new',
|
||||
];
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($c) {
|
||||
$this->assertSame('ETH', $c->getCode());
|
||||
$this->assertSame('Ξ', $c->getSymbol());
|
||||
$this->assertSame('https://api.example.com/eth', $c->getApiEndpoint());
|
||||
$this->assertSame('newkey', $c->getApiKey());
|
||||
$this->assertSame('$.new', $c->getJsonPath());
|
||||
return $c;
|
||||
});
|
||||
|
||||
$resp = $controller->updateCustomCurrency(1, $inputData);
|
||||
$this->assertSame(200, $resp->getStatus());
|
||||
}
|
||||
|
||||
public function testUpdateCustomCurrency_Success_PartialUpdate(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$currency = new CustomCurrency();
|
||||
$currency->setCode('BTC');
|
||||
$currency->setSymbol('₿');
|
||||
$currency->setApiEndpoint('https://api.example.com/btc');
|
||||
$currency->setApiKey('key123');
|
||||
$currency->setJsonPath('$.rate');
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with('1')
|
||||
->willReturn($currency);
|
||||
|
||||
$inputData = [
|
||||
'code' => 'BTCUSD',
|
||||
];
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($c) {
|
||||
$this->assertSame('BTCUSD', $c->getCode());
|
||||
$this->assertSame('₿', $c->getSymbol()); // unchanged
|
||||
$this->assertSame('https://api.example.com/btc', $c->getApiEndpoint()); // unchanged
|
||||
$this->assertSame('key123', $c->getApiKey()); // unchanged
|
||||
$this->assertSame('$.rate', $c->getJsonPath()); // unchanged
|
||||
return $c;
|
||||
});
|
||||
|
||||
$resp = $controller->updateCustomCurrency(1, $inputData);
|
||||
$this->assertSame(200, $resp->getStatus());
|
||||
}
|
||||
|
||||
public function testUpdateCustomCurrency_Success_ClearApiKey(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$currency = new CustomCurrency();
|
||||
$currency->setCode('BTC');
|
||||
$currency->setSymbol('₿');
|
||||
$currency->setApiEndpoint('https://api.example.com/btc');
|
||||
$currency->setApiKey('oldkey');
|
||||
$currency->setJsonPath('$.rate');
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with('1')
|
||||
->willReturn($currency);
|
||||
|
||||
$inputData = [
|
||||
'api_key' => null,
|
||||
];
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('update')
|
||||
->willReturnCallback(function ($c) {
|
||||
$this->assertSame('', $c->getApiKey());
|
||||
return $c;
|
||||
});
|
||||
|
||||
$resp = $controller->updateCustomCurrency(1, $inputData);
|
||||
$this->assertSame(200, $resp->getStatus());
|
||||
}
|
||||
|
||||
public function testUpdateCustomCurrency_InternalError_OnException(): void {
|
||||
$controller = $this->buildController();
|
||||
|
||||
$this->customCurrencyMapper->expects($this->once())
|
||||
->method('find')
|
||||
->with('1')
|
||||
->willThrowException(new \Exception('Not found'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Failed to update custom currency'));
|
||||
|
||||
$resp = $controller->updateCustomCurrency(1, ['code' => 'ETH']);
|
||||
$this->assertSame(500, $resp->getStatus());
|
||||
$this->assertArrayHasKey('error', $resp->getData());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ namespace OCA\AutoCurrency\Tests\Service;
|
||||
use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
|
||||
use OCA\AutoCurrency\Db\CospendProjectMapper;
|
||||
use OCA\AutoCurrency\Db\CurrencyMapper;
|
||||
use OCA\AutoCurrency\Db\CustomCurrency;
|
||||
use OCA\AutoCurrency\Db\CustomCurrencyMapper;
|
||||
use OCA\AutoCurrency\Service\FetchCurrenciesService;
|
||||
use OCP\IAppConfig;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -24,13 +26,17 @@ final class CurrencyResolverTest extends TestCase {
|
||||
$currencyMapper = $this->createMock(CurrencyMapper::class);
|
||||
$projectMapper = $this->createMock(CospendProjectMapper::class);
|
||||
$historyMapper = $this->createMock(AutocurrencyRateHistoryMapper::class);
|
||||
$customCurrencyMapper = $this->createMock(CustomCurrencyMapper::class);
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$customCurrencyMapper->method('findAll')->willReturn([]);
|
||||
|
||||
$this->resolver = new FetchCurrenciesService(
|
||||
$config,
|
||||
$currencyMapper,
|
||||
$projectMapper,
|
||||
$historyMapper,
|
||||
$customCurrencyMapper,
|
||||
$logger
|
||||
);
|
||||
|
||||
@@ -105,4 +111,655 @@ final class CurrencyResolverTest extends TestCase {
|
||||
['$U$', 'uyu'], // still contains "$U" → prefer that over bare "$"
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test JSON path extraction with various formats
|
||||
* @dataProvider provideJsonPathCases
|
||||
*/
|
||||
public function testExtractJsonPath(array $data, string $path, mixed $expected): void {
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('extractJsonPath');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->resolver, $data, $path);
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
public static function provideJsonPathCases(): array {
|
||||
return [
|
||||
// Basic paths with $. prefix
|
||||
[['key' => 'value'], '$.key', 'value'],
|
||||
[['key' => 'value'], '$key', 'value'],
|
||||
|
||||
// Basic paths without prefix
|
||||
[['key' => 'value'], 'key', 'value'],
|
||||
[['data' => ['rate' => 123.45]], 'data.rate', 123.45],
|
||||
|
||||
// Nested paths
|
||||
[['data' => ['rates' => ['btc' => 50000]]], '$.data.rates.btc', 50000],
|
||||
[['data' => ['rates' => ['btc' => 50000]]], 'data.rates.btc', 50000],
|
||||
|
||||
// Array index
|
||||
[['items' => [10, 20, 30]], '$.items[0]', 10],
|
||||
[['items' => [10, 20, 30]], 'items[1]', 20],
|
||||
|
||||
// Complex nested
|
||||
[['result' => ['currencies' => ['usd' => 1.0, 'eur' => 0.85]]], '$.result.currencies.usd', 1.0],
|
||||
[['result' => ['currencies' => ['usd' => 1.0, 'eur' => 0.85]]], 'result.currencies.eur', 0.85],
|
||||
|
||||
// Non-existent paths
|
||||
[['key' => 'value'], '$.missing', null],
|
||||
[['key' => 'value'], 'missing.nested', null],
|
||||
|
||||
// Empty path returns whole data
|
||||
[['key' => 'value'], '$', ['key' => 'value']],
|
||||
[['key' => 'value'], '$.', ['key' => 'value']],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test token replacement
|
||||
* @dataProvider provideTokenReplacementCases
|
||||
*/
|
||||
public function testReplaceTokens(string $text, string $base, string $expected): void {
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('replaceTokens');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->resolver, $text, $base);
|
||||
$this->assertSame($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
public static function provideTokenReplacementCases(): array {
|
||||
return [
|
||||
// Basic replacement
|
||||
['https://api.example.com/{base}', 'USD', 'https://api.example.com/usd'],
|
||||
['https://api.example.com/{base}', 'EUR', 'https://api.example.com/eur'],
|
||||
|
||||
// Multiple occurrences
|
||||
['/{base}/rates/{base}', 'GBP', '/gbp/rates/gbp'],
|
||||
|
||||
// JSON path with token
|
||||
['$.rates.{base}.btc', 'USD', '$.rates.usd.btc'],
|
||||
['data.{base}.price', 'EUR', 'data.eur.price'],
|
||||
|
||||
// No token
|
||||
['https://api.example.com/price', 'USD', 'https://api.example.com/price'],
|
||||
['$.rate', 'EUR', '$.rate'],
|
||||
|
||||
// Case conversion (base is lowercased)
|
||||
['https://api.example.com/{base}', 'usd', 'https://api.example.com/usd'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that {base} token in endpoint is detected
|
||||
*/
|
||||
public function testHasBaseTokenInEndpoint(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/rate?base={base}');
|
||||
$customCurrency->setJsonPath('$.rate');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API response
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/rate?base=eur' => ['rate' => 50000],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
|
||||
|
||||
// Should not attempt USD conversion because {base} was found
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('rate', $result);
|
||||
$this->assertSame(50000.0, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that {base} token in json_path is detected
|
||||
*/
|
||||
public function testHasBaseTokenInJsonPath(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/rates');
|
||||
$customCurrency->setJsonPath('$.{base}');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API response
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/rates' => [
|
||||
'usd' => 50000,
|
||||
'eur' => 45000,
|
||||
'gbp' => 40000,
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
|
||||
|
||||
// Should extract from eur key without USD conversion
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('rate', $result);
|
||||
$this->assertSame(45000.0, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test USD conversion when no {base} token is present
|
||||
*/
|
||||
public function testUsdConversionWhenNoBaseToken(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/btc/price');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API responses - fetchStandardRates caches by baseCurrency, not URL
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc/price' => ['usd' => 50000],
|
||||
// Cache key is the baseCurrency string 'usd', not the URL
|
||||
'usd' => [
|
||||
'usd' => [
|
||||
'eur' => 0.85,
|
||||
'gbp' => 0.75,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Test with EUR base (should convert from USD)
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('rate', $result);
|
||||
|
||||
// 50000 USD * 0.85 EUR/USD = 42500 EUR
|
||||
$expectedRate = 50000 * 0.85;
|
||||
$this->assertEqualsWithDelta($expectedRate, $result['rate'], 0.01);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test no USD conversion when base is already USD
|
||||
*/
|
||||
public function testNoUsdConversionWhenBaseIsUsd(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/btc/price');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API response
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc/price' => ['usd' => 50000],
|
||||
]);
|
||||
|
||||
// Test with USD base (should NOT convert)
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('rate', $result);
|
||||
$this->assertSame(50000.0, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test JSON path extraction with complex nested structure
|
||||
*/
|
||||
public function testComplexJsonPathExtraction(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/rates');
|
||||
$customCurrency->setJsonPath('data.rates.{base}.btc');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API response with nested structure
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/rates' => [
|
||||
'data' => [
|
||||
'rates' => [
|
||||
'usd' => ['btc' => 0.00002],
|
||||
'eur' => ['btc' => 0.000022],
|
||||
'gbp' => ['btc' => 0.000025],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'eur');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertArrayHasKey('rate', $result);
|
||||
$this->assertSame(0.000022, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that extraction returns null for missing path
|
||||
*/
|
||||
public function testJsonPathExtractionReturnsNullForMissingPath(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/price');
|
||||
$customCurrency->setJsonPath('$.nonexistent.path');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API response
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/price' => ['rate' => 50000],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
// Should return null when path doesn't exist
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// ========== API Response Format Variations ==========
|
||||
|
||||
/**
|
||||
* Test that rate as string is converted to float
|
||||
*/
|
||||
public function testRateAsStringIsConvertedToFloat(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/price');
|
||||
$customCurrency->setJsonPath('$.rate');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API response with string rate
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/price' => ['rate' => '50000.5'],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertIsFloat($result['rate']);
|
||||
$this->assertSame(50000.5, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handling of rate in scientific notation
|
||||
*/
|
||||
public function testScientificNotationRate(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('SHIB');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/shib');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock response with very small number in scientific notation
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/shib' => ['usd' => 1.23e-5],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame(1.23e-5, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that null rate returns null result
|
||||
*/
|
||||
public function testNullRateReturnsNull(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/price');
|
||||
$customCurrency->setJsonPath('$.rate');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock response with null rate
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/price' => ['rate' => null],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
// extractJsonPath will return null, which should cause fetchCustomCurrencyRate to return null
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test empty JSON response
|
||||
*/
|
||||
public function testEmptyJsonObjectReturnsNull(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/empty');
|
||||
$customCurrency->setJsonPath('$.rate');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock empty response
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/empty' => [],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test nested object with null value
|
||||
*/
|
||||
public function testNestedNullValueReturnsNull(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/price');
|
||||
$customCurrency->setJsonPath('$.data.rate');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock response with nested null
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/price' => ['data' => ['rate' => null]],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// ========== Conversion Edge Cases ==========
|
||||
|
||||
/**
|
||||
* Test USD conversion fails gracefully when conversion rate is missing
|
||||
*/
|
||||
public function testUsdConversionFailsGracefullyWhenRateMissing(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/btc');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API responses - standard API missing the target currency
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc' => ['usd' => 50000],
|
||||
// Cache key is baseCurrency 'usd', WITHOUT JPY rate
|
||||
'usd' => [
|
||||
'usd' => [
|
||||
'eur' => 0.85,
|
||||
'gbp' => 0.75,
|
||||
// JPY missing!
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Test with JPY base (conversion should fail, return original rate)
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'jpy');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
// Should fallback to original USD rate
|
||||
$this->assertSame(50000.0, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test USD conversion handles zero rate gracefully
|
||||
*/
|
||||
public function testUsdConversionHandlesZeroRate(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/btc');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API responses with zero conversion rate
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc' => ['usd' => 50000],
|
||||
'usd' => [
|
||||
'usd' => [
|
||||
'xyz' => 0, // Zero rate!
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Test with XYZ base - should handle division by zero
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'xyz');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
// Should return the original USD rate as fallback when conversion rate is zero
|
||||
$this->assertSame(50000.0, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handling of very small decimal rates (crypto-like)
|
||||
*/
|
||||
public function testHandlesVerySmallDecimalRates(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('SHIB');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/shib');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Very small rate
|
||||
$verySmallRate = 0.00000123456789;
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/shib' => ['usd' => $verySmallRate],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEqualsWithDelta($verySmallRate, $result['rate'], 1e-15);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handling of very large rates
|
||||
*/
|
||||
public function testHandlesVeryLargeRates(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/btc/{base}'); // Add {base} to prevent USD conversion
|
||||
$customCurrency->setJsonPath('$.price');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Very large rate
|
||||
$veryLargeRate = 1234567890.123456;
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc/vnd' => ['price' => $veryLargeRate],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'vnd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertEqualsWithDelta($veryLargeRate, $result['rate'], 0.001);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test negative rate (should still convert to float, even if unusual)
|
||||
*/
|
||||
public function testHandlesNegativeRate(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('TEST');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/test');
|
||||
$customCurrency->setJsonPath('$.rate');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/test' => ['rate' => -100.5],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertSame(-100.5, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test integer rate is converted to float
|
||||
*/
|
||||
public function testIntegerRateConvertedToFloat(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/btc');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc' => ['usd' => 50000], // Integer
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'usd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
$this->assertIsFloat($result['rate']);
|
||||
$this->assertSame(50000.0, $result['rate']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test USD conversion with very large conversion rate (like Vietnamese Dong)
|
||||
*/
|
||||
public function testUsdConversionWithVeryLargeConversionRate(): void {
|
||||
$customCurrency = new CustomCurrency();
|
||||
$customCurrency->setCode('BTC');
|
||||
$customCurrency->setApiEndpoint('https://api.example.com/btc');
|
||||
$customCurrency->setJsonPath('$.usd');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock the API responses - using Vietnamese Dong as example of high-value currency
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc' => ['usd' => 50000],
|
||||
'usd' => [
|
||||
'usd' => [
|
||||
'vnd' => 24000, // 1 USD = 24,000 VND
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($this->resolver, $customCurrency, 'vnd');
|
||||
|
||||
$this->assertIsArray($result);
|
||||
// 50000 USD * 24000 VND/USD = 1,200,000,000 VND
|
||||
$expectedRate = 50000 * 24000;
|
||||
$this->assertEqualsWithDelta($expectedRate, $result['rate'], 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that API key is properly included in cache key
|
||||
*/
|
||||
public function testApiKeyCacheIsolation(): void {
|
||||
$customCurrency1 = new CustomCurrency();
|
||||
$customCurrency1->setCode('BTC');
|
||||
$customCurrency1->setApiEndpoint('https://api.example.com/btc');
|
||||
$customCurrency1->setJsonPath('$.rate');
|
||||
$customCurrency1->setApiKey('key123');
|
||||
|
||||
$customCurrency2 = new CustomCurrency();
|
||||
$customCurrency2->setCode('BTC');
|
||||
$customCurrency2->setApiEndpoint('https://api.example.com/btc');
|
||||
$customCurrency2->setJsonPath('$.rate');
|
||||
$customCurrency2->setApiKey('key456');
|
||||
|
||||
$ref = new ReflectionClass($this->resolver);
|
||||
$method = $ref->getMethod('fetchCustomCurrencyRate');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Mock responses with different cache keys (URL + hashed API key)
|
||||
$apiCache = $ref->getProperty('apiCache');
|
||||
$apiCache->setAccessible(true);
|
||||
$apiCache->setValue($this->resolver, [
|
||||
'https://api.example.com/btc:' . md5('key123') => ['rate' => 50000],
|
||||
'https://api.example.com/btc:' . md5('key456') => ['rate' => 51000],
|
||||
]);
|
||||
|
||||
$result1 = $method->invoke($this->resolver, $customCurrency1, 'usd');
|
||||
$result2 = $method->invoke($this->resolver, $customCurrency2, 'usd');
|
||||
|
||||
$this->assertIsArray($result1);
|
||||
$this->assertIsArray($result2);
|
||||
// Different API keys should use different cache entries
|
||||
$this->assertSame(50000.0, $result1['rate']);
|
||||
$this->assertSame(51000.0, $result2['rate']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"paths": {
|
||||
"@icons/*": [
|
||||
"node_modules/vue-material-design-icons/*"
|
||||
],
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
|
||||
@@ -13,6 +13,7 @@ export default createAppConfig(
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'@icons': path.resolve(__dirname, 'node_modules/vue-material-design-icons'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
||||
Reference in New Issue
Block a user