FX Modul
This commit is contained in:
576
modules/fx-rates/src/Domain/FxRatesService.php
Normal file
576
modules/fx-rates/src/Domain/FxRatesService.php
Normal file
@@ -0,0 +1,576 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\FxRates\Domain;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Modules\FxRates\Infrastructure\FxRatesRepository;
|
||||
|
||||
final class FxRatesService
|
||||
{
|
||||
private array $memoryCache = [];
|
||||
|
||||
public function __construct(
|
||||
private FxRatesRepository $repository,
|
||||
private array $settings = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function latestStatus(): ?array
|
||||
{
|
||||
return $this->repository->getLatestFetch($this->defaultBaseCurrency());
|
||||
}
|
||||
|
||||
public function latestStatuses(): array
|
||||
{
|
||||
return $this->repository->listLatestFetches();
|
||||
}
|
||||
|
||||
public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
if ($base === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($at === null || trim($at) === '') {
|
||||
$latest = $this->repository->getLatestFetch($base);
|
||||
if ($latest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->repository->getSnapshotByFetchId((int) $latest['id'], $symbols);
|
||||
}
|
||||
|
||||
$atUtc = $this->normalizeTimestamp($at);
|
||||
if ($atUtc === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes);
|
||||
if ($nearest === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols);
|
||||
if ($snapshot === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $snapshot + [
|
||||
'requested_at' => $atUtc,
|
||||
'distance_seconds' => $nearest['distance_seconds'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array
|
||||
{
|
||||
$from = $this->normalizeCurrency($fromCurrency);
|
||||
$to = $this->normalizeCurrency($toCurrency);
|
||||
if ($from === '' || $to === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($from === $to) {
|
||||
return [
|
||||
'base_currency' => $from,
|
||||
'target_currency' => $to,
|
||||
'rate' => 1.0,
|
||||
'provider' => 'identity',
|
||||
'fetched_at' => $at ? $this->normalizeTimestamp($at) : null,
|
||||
'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'),
|
||||
'is_exact_pair' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]);
|
||||
if (array_key_exists($cacheKey, $this->memoryCache)) {
|
||||
return $this->memoryCache[$cacheKey];
|
||||
}
|
||||
|
||||
$candidates = array_values(array_unique(array_filter([
|
||||
$this->defaultBaseCurrency(),
|
||||
'EUR',
|
||||
'USD',
|
||||
$from,
|
||||
$to,
|
||||
], static fn (?string $value): bool => is_string($value) && trim($value) !== '')));
|
||||
|
||||
foreach ($candidates as $snapshotBase) {
|
||||
$snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes);
|
||||
if (!is_array($snapshot)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
|
||||
$resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to);
|
||||
if ($resolved !== null) {
|
||||
return $this->memoryCache[$cacheKey] = $resolved;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->memoryCache[$cacheKey] = null;
|
||||
}
|
||||
|
||||
public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float
|
||||
{
|
||||
if ($amount === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
|
||||
if (!is_numeric($rate['rate'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $amount * (float) $rate['rate'];
|
||||
}
|
||||
|
||||
public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$payload = $this->fetchLatestPayload($base, $currencies);
|
||||
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
|
||||
$saved = $this->repository->saveFetch(
|
||||
$base,
|
||||
$this->provider(),
|
||||
$rateDate,
|
||||
$rates,
|
||||
gmdate('Y-m-d H:i:s')
|
||||
);
|
||||
|
||||
return [
|
||||
'base' => $base,
|
||||
'rate_date' => $rateDate,
|
||||
'updated_count' => count($saved['rates'] ?? []),
|
||||
'rates' => $saved['rates'] ?? [],
|
||||
'fetch' => $saved['fetch'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$latest = $this->repository->getLatestFetch($base);
|
||||
$maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600);
|
||||
$fetchedAt = is_array($latest) ? strtotime((string) ($latest['fetched_at'] ?? '')) : false;
|
||||
|
||||
if ($fetchedAt !== false && (time() - $fetchedAt) <= $maxAgeSeconds) {
|
||||
return [
|
||||
'base' => $base,
|
||||
'rate_date' => $latest['rate_date'] ?? null,
|
||||
'updated_count' => 0,
|
||||
'rates' => [],
|
||||
'fetch' => $latest,
|
||||
'reused' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$result = $this->refreshLatestRates($currencies, $base);
|
||||
$result['reused'] = false;
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
|
||||
{
|
||||
$fromCurrency = $this->normalizeCurrency($fromCurrency);
|
||||
$toCurrency = $this->normalizeCurrency($toCurrency);
|
||||
if ($fromCurrency === '' || $toCurrency === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($fromCurrency === $toCurrency) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$direct = $this->repository->listDirectHistory($fromCurrency, $toCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit);
|
||||
if ($direct !== []) {
|
||||
return $direct;
|
||||
}
|
||||
|
||||
$inverse = $this->repository->listDirectHistory($toCurrency, $fromCurrency, $this->normalizeTimestamp($from), $this->normalizeTimestamp($to), $limit);
|
||||
if ($inverse === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($inverse as $row) {
|
||||
$inverseRate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null;
|
||||
if ($inverseRate === null || $inverseRate <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'fetch_id' => $row['fetch_id'] ?? null,
|
||||
'base_currency' => $fromCurrency,
|
||||
'target_currency' => $toCurrency,
|
||||
'rate' => 1 / $inverseRate,
|
||||
'rate_date' => $row['rate_date'] ?? null,
|
||||
'provider' => $row['provider'] ?? null,
|
||||
'fetched_at' => $row['fetched_at'] ?? null,
|
||||
'is_exact_pair' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function runScheduledRefresh(array $context = []): array
|
||||
{
|
||||
if (!$this->dailyRefreshEnabled()) {
|
||||
return ['ok' => true, 'message' => 'Taeglicher FX-Abruf ist deaktiviert.', 'skipped' => true];
|
||||
}
|
||||
|
||||
$timezone = $this->scheduleTimezone();
|
||||
$nowLocal = new DateTimeImmutable('now', $timezone);
|
||||
$targetHour = $this->dailyRefreshHour();
|
||||
|
||||
if ((int) $nowLocal->format('G') !== $targetHour) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Kein FX-Abruf: aktuelles Zeitfenster ist ' . $nowLocal->format('H:i') . ', Ziel ist ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
|
||||
'skipped' => true,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
$latest = $this->repository->getLatestFetch($this->defaultBaseCurrency());
|
||||
if (is_array($latest) && trim((string) ($latest['fetched_at'] ?? '')) !== '') {
|
||||
$latestLocal = new DateTimeImmutable((string) $latest['fetched_at'], new DateTimeZone('UTC'));
|
||||
$latestLocal = $latestLocal->setTimezone($timezone);
|
||||
if ($latestLocal->format('Y-m-d') === $nowLocal->format('Y-m-d') && (int) $latestLocal->format('G') >= $targetHour) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Kein FX-Abruf: fuer heute existiert bereits ein Snapshot nach ' . str_pad((string) $targetHour, 2, '0', STR_PAD_LEFT) . ':00.',
|
||||
'skipped' => true,
|
||||
'fetch' => $latest,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$result = $this->refreshLatestRates(null, $this->defaultBaseCurrency());
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Taeglicher FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.',
|
||||
'result' => $result,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
|
||||
public function refreshCurrencyCatalog(): array
|
||||
{
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
$currencies = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
|
||||
$items = [];
|
||||
foreach ($currencies as $code => $name) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
$name = trim((string) $name);
|
||||
if ($code === '' || $name === '') {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'code' => $code,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'synced_count' => count($items),
|
||||
'currencies' => $items,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeCurrencyCatalog(): array
|
||||
{
|
||||
$payload = $this->fetchCurrenciesPayload();
|
||||
return [
|
||||
'ok' => !empty($payload['currencies']),
|
||||
'provider' => $this->provider(),
|
||||
'currencies_count' => is_array($payload['currencies'] ?? null) ? count($payload['currencies']) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function probeLatestRates(?string $baseCurrency = null): array
|
||||
{
|
||||
$base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency());
|
||||
$payload = $this->fetchLatestPayload($base, null);
|
||||
return [
|
||||
'ok' => !empty($payload['rates']),
|
||||
'provider' => $this->provider(),
|
||||
'base' => $base,
|
||||
'rate_count' => is_array($payload['rates'] ?? null) ? count($payload['rates']) : 0,
|
||||
'date' => $payload['date'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveRateFromSnapshot(array $snapshot, array $rates, string $from, string $to): ?array
|
||||
{
|
||||
$base = strtoupper((string) ($snapshot['base_currency'] ?? ''));
|
||||
$rate = null;
|
||||
$isExactPair = false;
|
||||
|
||||
if ($base === $from && is_numeric($rates[$to] ?? null)) {
|
||||
$rate = (float) $rates[$to];
|
||||
$isExactPair = true;
|
||||
} elseif ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
|
||||
$rate = 1 / (float) $rates[$from];
|
||||
$isExactPair = true;
|
||||
} elseif (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
|
||||
$rate = (float) $rates[$to] / (float) $rates[$from];
|
||||
}
|
||||
|
||||
if ($rate === null || $rate <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'base_currency' => $from,
|
||||
'target_currency' => $to,
|
||||
'rate' => $rate,
|
||||
'provider' => $snapshot['provider'] ?? null,
|
||||
'fetched_at' => $snapshot['fetched_at'] ?? null,
|
||||
'rate_date' => $snapshot['rate_date'] ?? null,
|
||||
'snapshot_base_currency' => $base,
|
||||
'distance_seconds' => $snapshot['distance_seconds'] ?? null,
|
||||
'requested_at' => $snapshot['requested_at'] ?? null,
|
||||
'is_exact_pair' => $isExactPair,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new \RuntimeException('curl_init ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$url = $this->buildLatestUrl($baseCurrency);
|
||||
if ($url === null) {
|
||||
throw new \RuntimeException('FX-URL oder API-Key fehlt.');
|
||||
}
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds(),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
throw new \RuntimeException('FX-Kurse konnten nicht geladen werden.');
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload)) {
|
||||
throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.');
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
if ($this->provider() === 'currencyapi') {
|
||||
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
|
||||
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
|
||||
}
|
||||
foreach ($payload['rates'] as $code => $rate) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
} else {
|
||||
$rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
|
||||
foreach ($rawRates as $code => $rate) {
|
||||
$code = $this->normalizeCurrency((string) $code);
|
||||
if ($code === '' || $code === $baseCurrency || !is_numeric($rate)) {
|
||||
continue;
|
||||
}
|
||||
$rates[$code] = (float) $rate;
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($currencies) && $currencies !== []) {
|
||||
$wanted = [];
|
||||
foreach ($currencies as $currency) {
|
||||
$currency = $this->normalizeCurrency((string) $currency);
|
||||
if ($currency !== '' && isset($rates[$currency])) {
|
||||
$wanted[$currency] = $rates[$currency];
|
||||
}
|
||||
}
|
||||
$rates = $wanted;
|
||||
}
|
||||
|
||||
return [
|
||||
'base' => $baseCurrency,
|
||||
'date' => $payload['updated'] ?? $payload['date'] ?? null,
|
||||
'rates' => $rates,
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchCurrenciesPayload(): array
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new \RuntimeException('curl_init ist nicht verfuegbar.');
|
||||
}
|
||||
|
||||
$apiKey = $this->apiKey();
|
||||
if ($apiKey === '') {
|
||||
throw new \RuntimeException('FX-API-Key fehlt.');
|
||||
}
|
||||
|
||||
$url = sprintf(
|
||||
'%s/api/v2/currencies?output=json&key=%s',
|
||||
$this->currenciesApiUrl(),
|
||||
rawurlencode($apiKey)
|
||||
);
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => $url,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_TIMEOUT => $this->timeoutSeconds(),
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||
]);
|
||||
$response = curl_exec($ch);
|
||||
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||
$curlError = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
|
||||
throw new \RuntimeException('Waehrungskatalog konnte nicht geladen werden.');
|
||||
}
|
||||
|
||||
$payload = json_decode((string) $response, true);
|
||||
if (!is_array($payload) || ($payload['valid'] ?? false) !== true) {
|
||||
throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], 'Waehrungskatalog konnte nicht geladen werden.'));
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
private function buildLatestUrl(string $baseCurrency): ?string
|
||||
{
|
||||
$apiKey = $this->apiKey();
|
||||
if ($this->provider() === 'currencyapi') {
|
||||
if ($apiKey === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s/api/v2/rates?base=%s&output=json&key=%s',
|
||||
$this->apiUrl(),
|
||||
rawurlencode($baseCurrency),
|
||||
rawurlencode($apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
return sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency));
|
||||
}
|
||||
|
||||
private function extractProviderError(array $payload, string $fallback): string
|
||||
{
|
||||
foreach (['error', 'message', 'msg'] as $field) {
|
||||
$value = $payload[$field] ?? null;
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return trim($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function normalizeCurrency(?string $currency): string
|
||||
{
|
||||
return strtoupper(trim((string) $currency));
|
||||
}
|
||||
|
||||
private function normalizeTimestamp(?string $value): ?string
|
||||
{
|
||||
$value = trim((string) $value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$date = new DateTimeImmutable($value);
|
||||
return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeRateDate(mixed $value): string
|
||||
{
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
$timestamp = strtotime($value);
|
||||
if ($timestamp !== false) {
|
||||
return gmdate('Y-m-d', $timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return gmdate('Y-m-d', (int) $value);
|
||||
}
|
||||
|
||||
return gmdate('Y-m-d');
|
||||
}
|
||||
|
||||
private function provider(): string
|
||||
{
|
||||
$provider = strtolower(trim((string) ($this->settings['provider'] ?? 'currencyapi')));
|
||||
return $provider !== '' ? $provider : 'currencyapi';
|
||||
}
|
||||
|
||||
private function apiUrl(): string
|
||||
{
|
||||
return rtrim((string) ($this->settings['api_url'] ?? 'https://currencyapi.net'), '/');
|
||||
}
|
||||
|
||||
private function currenciesApiUrl(): string
|
||||
{
|
||||
return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/');
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
return trim((string) ($this->settings['api_key'] ?? ''));
|
||||
}
|
||||
|
||||
private function timeoutSeconds(): int
|
||||
{
|
||||
return max(2, (int) ($this->settings['timeout_sec'] ?? 10));
|
||||
}
|
||||
|
||||
private function defaultBaseCurrency(): string
|
||||
{
|
||||
return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR';
|
||||
}
|
||||
|
||||
private function dailyRefreshEnabled(): bool
|
||||
{
|
||||
return !empty($this->settings['daily_refresh_enabled']);
|
||||
}
|
||||
|
||||
private function dailyRefreshHour(): int
|
||||
{
|
||||
return max(0, min(23, (int) ($this->settings['daily_refresh_hour'] ?? 18)));
|
||||
}
|
||||
|
||||
private function scheduleTimezone(): DateTimeZone
|
||||
{
|
||||
$timezone = trim((string) ($this->settings['schedule_timezone'] ?? 'Europe/Berlin'));
|
||||
try {
|
||||
return new DateTimeZone($timezone);
|
||||
} catch (\Throwable) {
|
||||
return new DateTimeZone('Europe/Berlin');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user