Files
nexus/modules/mining-checker/src/Infrastructure/MiningRepository.php
Lars Gebhardt-Kusche 002d450deb
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
yxcyc
2026-05-04 22:22:55 +02:00

1623 lines
71 KiB
PHP

<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
use Modules\MiningChecker\Support\DebugTrace;
use PDO;
final class MiningRepository
{
private PDO $pdo;
private string $prefix;
private string $driver;
private ?DebugTrace $debug;
private string $ownerSub;
public function __construct(PDO $pdo, string $prefix = 'miningcheck_', ?DebugTrace $debug = null, ?string $ownerSub = null)
{
$this->pdo = $pdo;
$this->prefix = $prefix;
$this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
$this->debug = $debug;
$this->ownerSub = trim((string) $ownerSub) !== '' ? trim((string) $ownerSub) : 'local';
}
public function ensureProject(string $projectKey, ?string $projectName = null): void
{
$stmt = $this->pdo->prepare($this->driver === 'pgsql'
? 'INSERT INTO ' . $this->table('projects') . ' (project_key, project_name)
VALUES (:project_key, :project_name)
ON CONFLICT (project_key) DO UPDATE
SET project_name = COALESCE(' . $this->table('projects') . '.project_name, EXCLUDED.project_name)'
: 'INSERT INTO ' . $this->table('projects') . ' (project_key, project_name)
VALUES (:project_key, :project_name)
ON DUPLICATE KEY UPDATE project_name = COALESCE(project_name, VALUES(project_name))'
);
$stmt->execute([
'project_key' => $projectKey,
'project_name' => $projectName ?: strtoupper($projectKey),
]);
}
public function getProject(string $projectKey): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('projects') . ' WHERE project_key = :project_key LIMIT 1');
$stmt->execute(['project_key' => $projectKey]);
$project = $stmt->fetch();
return is_array($project) ? $this->normalizeRow($project) : null;
}
public function getSettings(string $projectKey): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('settings') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub LIMIT 1');
$stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]);
$row = $stmt->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function saveSettings(string $projectKey, array $settings): void
{
$stmt = $this->pdo->prepare($this->driver === 'pgsql'
? 'INSERT INTO ' . $this->table('settings') . ' (
project_key, owner_sub, baseline_measured_at, baseline_coins_total, daily_cost_amount, daily_cost_currency, report_currency, crypto_currency, display_timezone, fx_max_age_hours, module_theme_mode, module_theme_accent, preferred_currencies
) VALUES (
:project_key, :owner_sub, :baseline_measured_at, :baseline_coins_total, :daily_cost_amount, :daily_cost_currency, :report_currency, :crypto_currency, :display_timezone, :fx_max_age_hours, :module_theme_mode, :module_theme_accent, CAST(:preferred_currencies AS jsonb)
)
ON CONFLICT (project_key, owner_sub) DO UPDATE SET
baseline_measured_at = EXCLUDED.baseline_measured_at,
baseline_coins_total = EXCLUDED.baseline_coins_total,
daily_cost_amount = EXCLUDED.daily_cost_amount,
daily_cost_currency = EXCLUDED.daily_cost_currency,
report_currency = EXCLUDED.report_currency,
crypto_currency = EXCLUDED.crypto_currency,
display_timezone = EXCLUDED.display_timezone,
fx_max_age_hours = EXCLUDED.fx_max_age_hours,
module_theme_mode = EXCLUDED.module_theme_mode,
module_theme_accent = EXCLUDED.module_theme_accent,
preferred_currencies = EXCLUDED.preferred_currencies'
: 'INSERT INTO ' . $this->table('settings') . ' (
project_key, owner_sub, baseline_measured_at, baseline_coins_total, daily_cost_amount, daily_cost_currency, report_currency, crypto_currency, display_timezone, fx_max_age_hours, module_theme_mode, module_theme_accent, preferred_currencies
) VALUES (
:project_key, :owner_sub, :baseline_measured_at, :baseline_coins_total, :daily_cost_amount, :daily_cost_currency, :report_currency, :crypto_currency, :display_timezone, :fx_max_age_hours, :module_theme_mode, :module_theme_accent, :preferred_currencies
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
report_currency = VALUES(report_currency),
crypto_currency = VALUES(crypto_currency),
display_timezone = VALUES(display_timezone),
fx_max_age_hours = VALUES(fx_max_age_hours),
module_theme_mode = VALUES(module_theme_mode),
module_theme_accent = VALUES(module_theme_accent),
preferred_currencies = VALUES(preferred_currencies)'
);
$stmt->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'baseline_measured_at' => $settings['baseline_measured_at'],
'baseline_coins_total' => $settings['baseline_coins_total'],
'daily_cost_amount' => $settings['daily_cost_amount'],
'daily_cost_currency' => $settings['daily_cost_currency'],
'report_currency' => $settings['report_currency'] ?? 'EUR',
'crypto_currency' => $settings['crypto_currency'] ?? 'DOGE',
'display_timezone' => $settings['display_timezone'] ?? 'Europe/Berlin',
'fx_max_age_hours' => $settings['fx_max_age_hours'] ?? 3,
'module_theme_mode' => $settings['module_theme_mode'] ?? 'inherit',
'module_theme_accent' => $settings['module_theme_accent'] ?? 'teal',
'preferred_currencies' => json_encode($settings['preferred_currencies'] ?? [], JSON_UNESCAPED_UNICODE),
]);
}
public function listCurrencies(): array
{
$this->debug?->add('db.listCurrencies.start');
$stmt = $this->pdo->query(
'SELECT * FROM ' . $this->table('currencies') . ' WHERE ' . ($this->driver === 'pgsql' ? 'is_active = TRUE' : 'is_active = 1') . ' ORDER BY sort_order ASC, code ASC'
);
$rows = $this->normalizeRows($stmt->fetchAll() ?: []);
$this->debug?->add('db.listCurrencies.end', ['rows' => count($rows)]);
return $rows;
}
public function listCurrencyAliases(): array
{
$stmt = $this->pdo->query(
'SELECT
a.alias_code,
a.currency_code,
c.name AS currency_name,
a.created_at
FROM ' . $this->table('currency_aliases') . ' a
INNER JOIN ' . $this->table('currencies') . ' c ON c.code = a.currency_code
ORDER BY a.alias_code ASC'
);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function resolveCurrencyCode(string $code): ?array
{
$normalizedCode = strtoupper(trim($code));
if ($normalizedCode === '') {
return null;
}
$currencyStmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('currencies') . ' WHERE code = :code LIMIT 1');
$currencyStmt->execute(['code' => $normalizedCode]);
$currency = $currencyStmt->fetch();
if (is_array($currency)) {
return [
'input_code' => $normalizedCode,
'code' => $normalizedCode,
'matched_via' => 'code',
'currency' => $this->normalizeRow($currency),
];
}
if (!$this->tableExists('currency_aliases')) {
return null;
}
$aliasStmt = $this->pdo->prepare(
'SELECT
a.alias_code,
a.currency_code,
c.name AS currency_name,
c.symbol AS currency_symbol,
c.is_active,
c.sort_order
FROM ' . $this->table('currency_aliases') . ' a
INNER JOIN ' . $this->table('currencies') . ' c ON c.code = a.currency_code
WHERE a.alias_code = :alias_code
LIMIT 1'
);
$aliasStmt->execute(['alias_code' => $normalizedCode]);
$alias = $aliasStmt->fetch();
if (!is_array($alias)) {
return null;
}
return [
'input_code' => $normalizedCode,
'code' => (string) $alias['currency_code'],
'matched_via' => 'alias',
'alias_code' => (string) $alias['alias_code'],
'currency' => $this->normalizeRow([
'code' => $alias['currency_code'],
'name' => $alias['currency_name'],
'symbol' => $alias['currency_symbol'] ?? null,
'is_active' => $alias['is_active'] ?? 1,
'sort_order' => $alias['sort_order'] ?? 1000,
]),
];
}
public function saveCurrencyAlias(string $aliasCode, string $currencyCode): array
{
$normalizedAlias = strtoupper(trim($aliasCode));
$normalizedCurrency = strtoupper(trim($currencyCode));
$stmt = $this->pdo->prepare($this->driver === 'pgsql'
? 'INSERT INTO ' . $this->table('currency_aliases') . ' (alias_code, currency_code)
VALUES (:alias_code, :currency_code)
ON CONFLICT (alias_code) DO UPDATE SET currency_code = EXCLUDED.currency_code
RETURNING *'
: 'INSERT INTO ' . $this->table('currency_aliases') . ' (alias_code, currency_code)
VALUES (:alias_code, :currency_code)
ON DUPLICATE KEY UPDATE currency_code = VALUES(currency_code)'
);
$stmt->execute([
'alias_code' => $normalizedAlias,
'currency_code' => $normalizedCurrency,
]);
if ($this->driver === 'pgsql') {
$row = $stmt->fetch();
return is_array($row) ? $this->normalizeRow($row) : ['alias_code' => $normalizedAlias, 'currency_code' => $normalizedCurrency];
}
return ['alias_code' => $normalizedAlias, 'currency_code' => $normalizedCurrency];
}
public function saveCurrency(array $currency): void
{
$this->debug?->add('db.saveCurrency.start', ['code' => $currency['code'] ?? null]);
$stmt = $this->pdo->prepare($this->driver === 'pgsql'
? 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order)
VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
symbol = EXCLUDED.symbol,
is_active = EXCLUDED.is_active,
is_crypto = EXCLUDED.is_crypto,
sort_order = EXCLUDED.sort_order'
: 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order)
VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
symbol = VALUES(symbol),
is_active = VALUES(is_active),
is_crypto = VALUES(is_crypto),
sort_order = VALUES(sort_order)'
);
$stmt->execute([
'code' => $currency['code'],
'name' => $currency['name'],
'symbol' => $currency['symbol'],
'is_active' => $currency['is_active'],
'is_crypto' => $currency['is_crypto'] ?? 0,
'sort_order' => $currency['sort_order'],
]);
$this->debug?->add('db.saveCurrency.end', ['code' => $currency['code'] ?? null]);
}
public function saveCurrencies(array $currencies): int
{
if ($currencies === []) {
return 0;
}
$this->debug?->add('db.saveCurrencies.start', ['count' => count($currencies)]);
$statement = $this->pdo->prepare($this->driver === 'pgsql'
? 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order)
VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order)
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
symbol = EXCLUDED.symbol,
is_active = EXCLUDED.is_active,
is_crypto = EXCLUDED.is_crypto,
sort_order = EXCLUDED.sort_order'
: 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order)
VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
symbol = VALUES(symbol),
is_active = VALUES(is_active),
is_crypto = VALUES(is_crypto),
sort_order = VALUES(sort_order)'
);
$count = 0;
$startedTransaction = false;
if (!$this->pdo->inTransaction()) {
$this->pdo->beginTransaction();
$startedTransaction = true;
}
try {
foreach ($currencies as $currency) {
if (!is_array($currency) || empty($currency['code'])) {
continue;
}
$statement->execute([
'code' => $currency['code'],
'name' => $currency['name'] ?? $currency['code'],
'symbol' => $currency['symbol'] ?? $currency['code'],
'is_active' => $currency['is_active'] ?? 1,
'is_crypto' => $currency['is_crypto'] ?? 0,
'sort_order' => $currency['sort_order'] ?? 1000,
]);
$count++;
}
if ($startedTransaction) {
$this->pdo->commit();
}
$this->debug?->add('db.saveCurrencies.end', ['count' => $count]);
} catch (\Throwable $exception) {
if ($startedTransaction && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->debug?->add('db.saveCurrencies.error', ['message' => $exception->getMessage()]);
throw $exception;
}
return $count;
}
public function ensureCurrencyCode(string $code, ?string $name = null): void
{
$normalizedCode = strtoupper(trim($code));
if ($normalizedCode === '') {
return;
}
$this->saveCurrency([
'code' => substr($normalizedCode, 0, 10),
'name' => $name !== null && trim($name) !== '' ? trim($name) : $normalizedCode,
'symbol' => substr($normalizedCode, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
'sort_order' => 1000,
]);
}
public function tableExists(string $logicalName): bool
{
$tableName = $this->table($logicalName);
$schemaCondition = $this->driver === 'pgsql'
? 'table_schema = current_schema()'
: 'table_schema = DATABASE()';
$stmt = $this->pdo->prepare(
'SELECT table_name
FROM information_schema.tables
WHERE ' . $schemaCondition . ' AND table_name = :table_name
LIMIT 1'
);
$stmt->execute(['table_name' => $tableName]);
return (bool) $stmt->fetchColumn();
}
public function listCostPlans(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('cost_plans') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY starts_at DESC, id DESC'
);
$stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function saveCostPlan(string $projectKey, array $payload): array
{
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('cost_plans') . ' (
project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit,
bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active
) VALUES (
:project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit,
:bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active
)
RETURNING *'
);
$stmt->execute($this->normalizeInsertPayload($projectKey, $payload));
return $this->normalizeRow($stmt->fetch() ?: []);
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('cost_plans') . ' (
project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit,
bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active
) VALUES (
:project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit,
:bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active
)'
);
$stmt->execute($this->normalizeInsertPayload($projectKey, $payload));
$id = (int) $this->pdo->lastInsertId();
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('cost_plans') . ' WHERE id = :id LIMIT 1');
$fetch->execute(['id' => $id]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function listMeasurements(string $projectKey, int $limit = 200): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('measurements') . '
WHERE project_key = :project_key AND owner_sub = :owner_sub
ORDER BY measured_at ASC
LIMIT :limit'
);
$stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR);
$stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function listRecentMeasurements(string $projectKey, int $limit = 200): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('measurements') . '
WHERE project_key = :project_key AND owner_sub = :owner_sub
ORDER BY measured_at DESC
LIMIT :limit'
);
$stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR);
$stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $this->normalizeRows($stmt->fetchAll() ?: []);
return array_reverse($rows);
}
public function listAllMeasurements(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('measurements') . '
WHERE project_key = :project_key AND owner_sub = :owner_sub
ORDER BY measured_at ASC'
);
$stmt->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function createMeasurement(string $projectKey, array $payload): array
{
$this->debug?->add('db.createMeasurement.start', [
'project_key' => $projectKey,
'measured_at' => $payload['measured_at'] ?? null,
'price_currency' => $payload['price_currency'] ?? null,
'fx_fetch_id' => $payload['fx_fetch_id'] ?? null,
]);
$params = [
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'measured_at' => $payload['measured_at'],
'coins_total' => $payload['coins_total'],
'price_per_coin' => $payload['price_per_coin'],
'price_currency' => $payload['price_currency'],
'fx_fetch_id' => $payload['fx_fetch_id'] ?? null,
'note' => $payload['note'],
'source' => $payload['source'],
'image_path' => $payload['image_path'],
'ocr_raw_text' => $payload['ocr_raw_text'],
'ocr_confidence' => $payload['ocr_confidence'],
'ocr_flags' => $payload['ocr_flags'] === null ? null : json_encode($payload['ocr_flags'], JSON_UNESCAPED_UNICODE),
];
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('measurements') . ' (
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note,
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb)
)
RETURNING *'
);
$stmt->execute($params);
$row = $this->normalizeRow($stmt->fetch() ?: []);
$this->debug?->add('db.createMeasurement.end', ['id' => $row['id'] ?? null]);
return $row;
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('measurements') . ' (
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note,
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags
)'
);
$stmt->execute($params);
$id = (int) $this->pdo->lastInsertId();
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('measurements') . ' WHERE id = :id LIMIT 1');
$fetch->execute(['id' => $id]);
$row = $this->normalizeRow($fetch->fetch() ?: []);
$this->debug?->add('db.createMeasurement.end', ['id' => $row['id'] ?? null]);
return $row;
}
public function createMeasurementIfNotExists(string $projectKey, array $payload): ?array
{
try {
return $this->createMeasurement($projectKey, $payload);
} catch (\PDOException $exception) {
$sqlState = (string) ($exception->getCode() ?? '');
if (in_array($sqlState, ['23000', '23505'], true)) {
return null;
}
throw $exception;
}
}
public function setMeasurementFxFetchId(string $projectKey, int $measurementId, ?int $fxFetchId): ?array
{
if ($measurementId <= 0) {
return null;
}
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->table('measurements') . '
SET fx_fetch_id = :fx_fetch_id
WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id'
);
$stmt->execute([
'fx_fetch_id' => $fxFetchId,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $measurementId,
]);
$fetch = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('measurements') . '
WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1'
);
$fetch->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $measurementId,
]);
$row = $fetch->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function deleteMeasurement(string $projectKey, int $measurementId): void
{
if ($measurementId <= 0) {
return;
}
$deleteRates = $this->pdo->prepare(
'DELETE FROM ' . $this->table('measurement_rates') . '
WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub'
);
$deleteRates->execute([
'measurement_id' => $measurementId,
'owner_sub' => $this->ownerSub,
]);
$deleteMeasurement = $this->pdo->prepare(
'DELETE FROM ' . $this->table('measurements') . '
WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub'
);
$deleteMeasurement->execute([
'id' => $measurementId,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
]);
}
public function replaceMeasurementRates(int $measurementId, string $projectKey, array $rates): void
{
$delete = $this->pdo->prepare('DELETE FROM ' . $this->table('measurement_rates') . ' WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub');
$delete->execute(['measurement_id' => $measurementId, 'owner_sub' => $this->ownerSub]);
if ($rates === []) {
return;
}
$insert = $this->pdo->prepare(
'INSERT INTO ' . $this->table('measurement_rates') . ' (
measurement_id, project_key, owner_sub, base_currency, quote_currency, rate, provider
) VALUES (
:measurement_id, :project_key, :owner_sub, :base_currency, :quote_currency, :rate, :provider
)'
);
foreach ($rates as $rate) {
$insert->execute([
'measurement_id' => $measurementId,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'base_currency' => strtoupper((string) $rate['base_currency']),
'quote_currency' => strtoupper((string) $rate['quote_currency']),
'rate' => $rate['rate'],
'provider' => $rate['provider'] ?? 'derived',
]);
}
}
public function listMeasurementRates(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('measurement_rates') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY measurement_id ASC, base_currency ASC, quote_currency ASC'
);
$stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function listPayouts(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('payouts') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY payout_at ASC, id ASC'
);
$stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function savePayout(string $projectKey, array $payload): array
{
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('payouts') . ' (
project_key, owner_sub, payout_at, coins_amount, payout_currency, note
) VALUES (
:project_key, :owner_sub, :payout_at, :coins_amount, :payout_currency, :note
)
RETURNING *'
);
$stmt->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'payout_at' => $payload['payout_at'],
'coins_amount' => $payload['coins_amount'],
'payout_currency' => $payload['payout_currency'],
'note' => $payload['note'],
]);
return $this->normalizeRow($stmt->fetch() ?: []);
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('payouts') . ' (
project_key, owner_sub, payout_at, coins_amount, payout_currency, note
) VALUES (
:project_key, :owner_sub, :payout_at, :coins_amount, :payout_currency, :note
)'
);
$stmt->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'payout_at' => $payload['payout_at'],
'coins_amount' => $payload['coins_amount'],
'payout_currency' => $payload['payout_currency'],
'note' => $payload['note'],
]);
$id = (int) $this->pdo->lastInsertId();
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('payouts') . ' WHERE id = :id LIMIT 1');
$fetch->execute(['id' => $id]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function listTargets(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('targets') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY sort_order ASC, id ASC'
);
$stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function saveTarget(string $projectKey, array $payload): array
{
$stmt = $this->pdo->prepare($this->driver === 'pgsql'
? 'INSERT INTO ' . $this->table('targets') . ' (project_key, owner_sub, label, target_amount_fiat, currency, miner_offer_id, is_active, sort_order)
VALUES (:project_key, :owner_sub, :label, :target_amount_fiat, :currency, :miner_offer_id, :is_active, :sort_order)
ON CONFLICT (project_key, owner_sub, label) DO UPDATE SET
target_amount_fiat = EXCLUDED.target_amount_fiat,
currency = EXCLUDED.currency,
miner_offer_id = EXCLUDED.miner_offer_id,
is_active = EXCLUDED.is_active,
sort_order = EXCLUDED.sort_order'
: 'INSERT INTO ' . $this->table('targets') . ' (project_key, owner_sub, label, target_amount_fiat, currency, miner_offer_id, is_active, sort_order)
VALUES (:project_key, :owner_sub, :label, :target_amount_fiat, :currency, :miner_offer_id, :is_active, :sort_order)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
miner_offer_id = VALUES(miner_offer_id),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order)'
);
$stmt->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'label' => $payload['label'],
'target_amount_fiat' => $payload['target_amount_fiat'],
'currency' => $payload['currency'],
'miner_offer_id' => $payload['miner_offer_id'] ?? null,
'is_active' => $payload['is_active'],
'sort_order' => $payload['sort_order'],
]);
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('targets') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND label = :label LIMIT 1');
$fetch->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub, 'label' => $payload['label']]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function updateTarget(string $projectKey, int $targetId, array $payload): array
{
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->table('targets') . '
SET label = :label, target_amount_fiat = :target_amount_fiat, currency = :currency, miner_offer_id = :miner_offer_id,
is_active = :is_active, sort_order = :sort_order
WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub'
);
$stmt->execute([
'id' => $targetId,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'label' => $payload['label'],
'target_amount_fiat' => $payload['target_amount_fiat'],
'currency' => $payload['currency'],
'miner_offer_id' => $payload['miner_offer_id'] ?? null,
'is_active' => $payload['is_active'],
'sort_order' => $payload['sort_order'],
]);
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('targets') . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1');
$fetch->execute(['id' => $targetId, 'owner_sub' => $this->ownerSub]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function deleteTarget(string $projectKey, int $targetId): void
{
$stmt = $this->pdo->prepare(
'DELETE FROM ' . $this->table('targets') . ' WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub'
);
$stmt->execute([
'id' => $targetId,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
]);
}
public function listDashboards(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('dashboard_definitions') . '
WHERE project_key = :project_key AND owner_sub = :owner_sub
ORDER BY id ASC'
);
$stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function saveDashboard(string $projectKey, array $payload): array
{
$stmt = $this->pdo->prepare($this->driver === 'pgsql'
? 'INSERT INTO ' . $this->table('dashboard_definitions') . ' (
project_key, owner_sub, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
) VALUES (
:project_key, :owner_sub, :name, :chart_type, :x_field, :y_field, :aggregation, CAST(:filters_json AS jsonb), :is_active
)
ON CONFLICT (project_key, owner_sub, name) DO UPDATE SET
chart_type = EXCLUDED.chart_type,
x_field = EXCLUDED.x_field,
y_field = EXCLUDED.y_field,
aggregation = EXCLUDED.aggregation,
filters_json = EXCLUDED.filters_json,
is_active = EXCLUDED.is_active'
: 'INSERT INTO ' . $this->table('dashboard_definitions') . ' (
project_key, owner_sub, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
) VALUES (
:project_key, :owner_sub, :name, :chart_type, :x_field, :y_field, :aggregation, :filters_json, :is_active
)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active)'
);
$stmt->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'name' => $payload['name'],
'chart_type' => $payload['chart_type'],
'x_field' => $payload['x_field'],
'y_field' => $payload['y_field'],
'aggregation' => $payload['aggregation'],
'filters_json' => json_encode($payload['filters'], JSON_UNESCAPED_UNICODE),
'is_active' => $payload['is_active'],
]);
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('dashboard_definitions') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND name = :name LIMIT 1');
$fetch->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub, 'name' => $payload['name']]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function listMinerOffers(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('miner_offers') . ' WHERE project_key = :project_key ORDER BY created_at DESC, id DESC'
);
$stmt->execute(['project_key' => $projectKey]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function saveMinerOffer(string $projectKey, array $payload): array
{
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('miner_offers') . ' (
project_key, label, runtime_months, mining_speed_value, mining_speed_unit,
bonus_speed_value, bonus_speed_unit, base_price_amount, base_price_currency,
payment_type, auto_renew, note, is_active
) VALUES (
:project_key, :label, :runtime_months, :mining_speed_value, :mining_speed_unit,
:bonus_speed_value, :bonus_speed_unit, :base_price_amount, :base_price_currency,
:payment_type, :auto_renew, :note, :is_active
)
RETURNING *'
);
$stmt->execute($this->normalizeOfferPayload($projectKey, $payload));
return $this->normalizeRow($stmt->fetch() ?: []);
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('miner_offers') . ' (
project_key, label, runtime_months, mining_speed_value, mining_speed_unit,
bonus_speed_value, bonus_speed_unit, base_price_amount, base_price_currency,
payment_type, auto_renew, note, is_active
) VALUES (
:project_key, :label, :runtime_months, :mining_speed_value, :mining_speed_unit,
:bonus_speed_value, :bonus_speed_unit, :base_price_amount, :base_price_currency,
:payment_type, :auto_renew, :note, :is_active
)'
);
$stmt->execute($this->normalizeOfferPayload($projectKey, $payload));
$id = (int) $this->pdo->lastInsertId();
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('miner_offers') . ' WHERE id = :id LIMIT 1');
$fetch->execute(['id' => $id]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function listPurchasedMiners(string $projectKey): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY purchased_at DESC, id DESC'
);
$stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function getPurchasedMiner(string $projectKey, int $minerId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1'
);
$stmt->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $minerId,
]);
$row = $stmt->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function purchaseMiner(string $projectKey, int $offerId, array $payload): array
{
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('purchased_miners') . ' (
project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months,
mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit,
total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active
) VALUES (
:project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months,
:mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit,
:total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active
)
RETURNING *'
);
$stmt->execute($this->normalizePurchasedPayload($projectKey, $offerId, $payload));
return $this->normalizeRow($stmt->fetch() ?: []);
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('purchased_miners') . ' (
project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months,
mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit,
total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active
) VALUES (
:project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months,
:mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit,
:total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active
)'
);
$stmt->execute($this->normalizePurchasedPayload($projectKey, $offerId, $payload));
$id = (int) $this->pdo->lastInsertId();
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE id = :id LIMIT 1');
$fetch->execute(['id' => $id]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function restorePurchasedMiner(string $projectKey, array $payload): array
{
$params = [
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'miner_offer_id' => $payload['miner_offer_id'] ?? null,
'purchased_at' => $payload['purchased_at'],
'label' => $payload['label'],
'runtime_months' => $payload['runtime_months'] ?? null,
'mining_speed_value' => $payload['mining_speed_value'] ?? null,
'mining_speed_unit' => $payload['mining_speed_unit'] ?? null,
'bonus_speed_value' => $payload['bonus_speed_value'] ?? null,
'bonus_speed_unit' => $payload['bonus_speed_unit'] ?? null,
'total_cost_amount' => $payload['total_cost_amount'],
'currency' => $payload['currency'],
'usd_reference_amount' => $payload['usd_reference_amount'] ?? null,
'reference_price_amount' => $payload['reference_price_amount'] ?? null,
'reference_price_currency' => $payload['reference_price_currency'] ?? null,
'auto_renew' => $payload['auto_renew'] ?? 0,
'note' => $payload['note'] ?? null,
'is_active' => $payload['is_active'] ?? 1,
];
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('purchased_miners') . ' (
project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months,
mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit,
total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active
) VALUES (
:project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months,
:mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit,
:total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active
)
RETURNING *'
);
$stmt->execute($params);
return $this->normalizeRow($stmt->fetch() ?: []);
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('purchased_miners') . ' (
project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months,
mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit,
total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active
) VALUES (
:project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months,
:mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit,
:total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active
)'
);
$stmt->execute($params);
$id = (int) $this->pdo->lastInsertId();
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE id = :id LIMIT 1');
$fetch->execute(['id' => $id]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function updatePurchasedMinerAutoRenew(string $projectKey, int $minerId, bool $autoRenew): array
{
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->table('purchased_miners') . '
SET auto_renew = :auto_renew
WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id'
);
$stmt->execute([
'auto_renew' => $autoRenew ? 1 : 0,
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $minerId,
]);
$fetch = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1'
);
$fetch->execute([
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'id' => $minerId,
]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function getMinerOffer(string $projectKey, int $offerId): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('miner_offers') . ' WHERE project_key = :project_key AND id = :id LIMIT 1');
$stmt->execute(['project_key' => $projectKey, 'id' => $offerId]);
$row = $stmt->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function getLatestFxRate(string $baseCurrency, string $targetCurrency): ?array
{
$stmt = $this->pdo->prepare(
'SELECT
r.id,
f.id AS fetch_id,
f.base_currency,
r.currency_code AS target_currency,
r.current_value AS rate,
f.rate_date,
f.provider,
f.fetched_at
FROM ' . $this->table('fx_rates') . ' r
INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id
WHERE f.base_currency = :base_currency AND r.currency_code = :target_currency
ORDER BY f.rate_date DESC, f.fetched_at DESC, r.id DESC
LIMIT 1'
);
$stmt->execute([
'base_currency' => strtoupper($baseCurrency),
'target_currency' => strtoupper($targetCurrency),
]);
$row = $stmt->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function getLatestFxFetch(?string $baseCurrency = null): ?array
{
$sql = 'SELECT id, provider, base_currency, rate_date, fetched_at
FROM ' . $this->table('fx_fetches');
$params = [];
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
$sql .= ' WHERE base_currency = :base_currency';
$params['base_currency'] = strtoupper(trim($baseCurrency));
}
$sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function listFxRates(int $limit = 30): array
{
$stmt = $this->pdo->prepare(
'SELECT
r.id,
r.fetch_id,
f.base_currency,
r.currency_code AS target_currency,
r.current_value AS rate,
f.rate_date,
f.provider,
f.fetched_at
FROM ' . $this->table('fx_rates') . ' r
INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id
WHERE f.id IN (
SELECT id
FROM ' . $this->table('fx_fetches') . '
ORDER BY fetched_at DESC, id DESC
LIMIT :limit
)
ORDER BY f.fetched_at DESC, f.id DESC, r.currency_code ASC'
);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function listAllFxRates(): array
{
$stmt = $this->pdo->query(
'SELECT
r.id,
r.fetch_id,
f.base_currency,
r.currency_code AS target_currency,
r.current_value AS rate,
f.rate_date,
f.provider,
f.fetched_at
FROM ' . $this->table('fx_rates') . ' r
INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id
ORDER BY f.fetched_at ASC, f.id ASC, r.currency_code ASC'
);
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function restoreFxFetch(string $baseCurrency, string $provider, string $rateDate, ?string $fetchedAt, array $rates): array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
$fetchedAt = trim((string) $fetchedAt) !== '' ? (string) $fetchedAt : $this->currentUtcTimestamp();
if ($rates === []) {
return ['fetch' => null, 'rates' => []];
}
$currenciesToEnsure = [
[
'code' => substr($baseCurrency, 0, 10),
'name' => $baseCurrency,
'symbol' => substr($baseCurrency, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($baseCurrency) ? 1 : 0,
'sort_order' => 1000,
],
];
foreach (array_keys($rates) as $currencyCode) {
$normalizedCurrencyCode = strtoupper(trim((string) $currencyCode));
if ($normalizedCurrencyCode === '') {
continue;
}
$currenciesToEnsure[] = [
'code' => substr($normalizedCurrencyCode, 0, 10),
'name' => $normalizedCurrencyCode,
'symbol' => substr($normalizedCurrencyCode, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($normalizedCurrencyCode) ? 1 : 0,
'sort_order' => 1000,
];
}
$this->saveCurrencies($currenciesToEnsure);
if ($this->driver === 'pgsql') {
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fx_fetches') . ' (
provider, base_currency, rate_date, fetched_at
) VALUES (
:provider, :base_currency, :rate_date, :fetched_at
)
RETURNING *'
);
$fetchStmt->execute([
'provider' => $provider,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetch = $this->normalizeRow($fetchStmt->fetch() ?: []);
} else {
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fx_fetches') . ' (
provider, base_currency, rate_date, fetched_at
) VALUES (
:provider, :base_currency, :rate_date, :fetched_at
)'
);
$fetchStmt->execute([
'provider' => $provider,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetchId = (int) $this->pdo->lastInsertId();
$fetchLookup = $this->pdo->prepare('SELECT * FROM ' . $this->table('fx_fetches') . ' WHERE id = :id LIMIT 1');
$fetchLookup->execute(['id' => $fetchId]);
$fetch = $this->normalizeRow($fetchLookup->fetch() ?: []);
}
$placeholders = [];
$params = ['fetch_id' => $fetch['id']];
$savedRates = [];
$index = 0;
foreach ($rates as $currencyCode => $rate) {
if (!is_numeric($rate)) {
continue;
}
$codeKey = 'currency_code_' . $index;
$valueKey = 'current_value_' . $index;
$targetCurrency = strtoupper(trim((string) $currencyCode));
if ($targetCurrency === '') {
continue;
}
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
$params[$codeKey] = $targetCurrency;
$params[$valueKey] = (float) $rate;
$savedRates[] = [
'fetch_id' => $fetch['id'],
'base_currency' => $baseCurrency,
'target_currency' => $targetCurrency,
'rate' => (float) $rate,
'rate_date' => $rateDate,
'provider' => $provider,
'fetched_at' => $fetch['fetched_at'] ?? $fetchedAt,
];
$index++;
}
if ($placeholders !== []) {
$insert = $this->pdo->prepare('INSERT INTO ' . $this->table('fx_rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders));
$insert->execute($params);
}
return [
'fetch' => $fetch,
'rates' => $savedRates,
];
}
public function getLatestMeasurementRate(string $baseCurrency, string $targetCurrency): ?array
{
$stmt = $this->pdo->prepare(
'SELECT
id,
measurement_id,
base_currency,
quote_currency AS target_currency,
rate,
provider,
created_at
FROM ' . $this->table('measurement_rates') . '
WHERE owner_sub = :owner_sub AND base_currency = :base_currency AND quote_currency = :target_currency
ORDER BY measurement_id DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'base_currency' => strtoupper($baseCurrency),
'target_currency' => strtoupper($targetCurrency),
]);
$row = $stmt->fetch();
return is_array($row) ? $this->normalizeRow($row) : null;
}
public function saveFxRate(string $baseCurrency, string $targetCurrency, float $rate, string $rateDate, string $provider = 'currencyapi'): array
{
$result = $this->saveFxFetch($baseCurrency, $provider, $rateDate, [
strtoupper($targetCurrency) => $rate,
]);
return $result['rates'][0] ?? [
'fetch_id' => $result['fetch']['id'] ?? null,
'base_currency' => strtoupper($baseCurrency),
'target_currency' => strtoupper($targetCurrency),
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $provider,
];
}
public function saveFxFetch(string $baseCurrency, string $provider, string $rateDate, array $rates): array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
$fetchedAt = $this->currentUtcTimestamp();
$normalizedRates = [];
$currenciesToEnsure = [
[
'code' => substr($baseCurrency, 0, 10),
'name' => $baseCurrency,
'symbol' => substr($baseCurrency, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($baseCurrency) ? 1 : 0,
'sort_order' => 1000,
],
];
foreach ($rates as $currencyCode => $rate) {
if (!is_numeric($rate)) {
continue;
}
$normalizedCurrencyCode = strtoupper(trim((string) $currencyCode));
if ($normalizedCurrencyCode === '' || $normalizedCurrencyCode === $baseCurrency) {
continue;
}
$normalizedRates[$normalizedCurrencyCode] = (float) $rate;
$currenciesToEnsure[] = [
'code' => substr($normalizedCurrencyCode, 0, 10),
'name' => $normalizedCurrencyCode,
'symbol' => substr($normalizedCurrencyCode, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($normalizedCurrencyCode) ? 1 : 0,
'sort_order' => 1000,
];
}
$this->debug?->add('db.saveFxFetch.start', [
'base_currency' => $baseCurrency,
'provider' => $provider,
'rate_date' => $rateDate,
'rate_count' => count($normalizedRates),
'fetched_at' => $fetchedAt,
]);
$startedTransaction = false;
if (!$this->pdo->inTransaction()) {
$this->debug?->add('db.saveFxFetch.transaction.begin.start', [
'already_in_transaction' => false,
]);
$this->pdo->beginTransaction();
$startedTransaction = true;
$this->debug?->add('db.saveFxFetch.transaction.begin.end', [
'started_transaction' => true,
]);
} else {
$this->debug?->add('db.saveFxFetch.transaction.reuse', [
'already_in_transaction' => true,
]);
}
try {
$this->debug?->add('db.saveFxFetch.saveCurrencies.start', [
'currency_count' => count($currenciesToEnsure),
]);
$this->saveCurrencies($currenciesToEnsure);
$this->debug?->add('db.saveFxFetch.saveCurrencies.end', [
'currency_count' => count($currenciesToEnsure),
]);
if ($this->driver === 'pgsql') {
$this->debug?->add('db.saveFxFetch.insertFetch.start', [
'driver' => $this->driver,
]);
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fx_fetches') . ' (
provider, base_currency, rate_date, fetched_at
) VALUES (
:provider, :base_currency, :rate_date, :fetched_at
)
RETURNING *'
);
$fetchStmt->execute([
'provider' => $provider,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetch = $this->normalizeRow($fetchStmt->fetch() ?: []);
$this->debug?->add('db.saveFxFetch.insertFetch.end', [
'driver' => $this->driver,
'fetch_id' => $fetch['id'] ?? null,
]);
} else {
$this->debug?->add('db.saveFxFetch.insertFetch.start', [
'driver' => $this->driver,
]);
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fx_fetches') . ' (
provider, base_currency, rate_date, fetched_at
) VALUES (
:provider, :base_currency, :rate_date, :fetched_at
)'
);
$fetchStmt->execute([
'provider' => $provider,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetchId = (int) $this->pdo->lastInsertId();
$fetchLookup = $this->pdo->prepare('SELECT * FROM ' . $this->table('fx_fetches') . ' WHERE id = :id LIMIT 1');
$fetchLookup->execute(['id' => $fetchId]);
$fetch = $this->normalizeRow($fetchLookup->fetch() ?: []);
$this->debug?->add('db.saveFxFetch.insertFetch.end', [
'driver' => $this->driver,
'fetch_id' => $fetch['id'] ?? null,
]);
}
if ($normalizedRates === []) {
if ($startedTransaction) {
$this->debug?->add('db.saveFxFetch.commit.start', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => 0,
]);
$this->pdo->commit();
$this->debug?->add('db.saveFxFetch.commit.end', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => 0,
]);
}
$this->debug?->add('db.saveFxFetch.end', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => 0,
]);
return [
'fetch' => $fetch,
'rates' => [],
];
}
$placeholders = [];
$params = ['fetch_id' => $fetch['id']];
$savedRates = [];
$index = 0;
foreach ($normalizedRates as $currencyCode => $rate) {
$codeKey = 'currency_code_' . $index;
$valueKey = 'current_value_' . $index;
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
$params[$codeKey] = $currencyCode;
$params[$valueKey] = $rate;
$savedRates[] = [
'fetch_id' => $fetch['id'],
'base_currency' => $baseCurrency,
'target_currency' => $currencyCode,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $provider,
'fetched_at' => $fetch['fetched_at'] ?? null,
];
$index++;
}
$this->debug?->add('db.saveFxFetch.insertRates.start', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => count($savedRates),
]);
$sql = 'INSERT INTO ' . $this->table('fx_rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders);
$insert = $this->pdo->prepare($sql);
$insert->execute($params);
$this->debug?->add('db.saveFxFetch.insertRates.end', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => count($savedRates),
]);
if ($startedTransaction) {
$this->debug?->add('db.saveFxFetch.commit.start', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => count($savedRates),
]);
$this->pdo->commit();
$this->debug?->add('db.saveFxFetch.commit.end', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => count($savedRates),
]);
}
$this->debug?->add('db.saveFxFetch.end', [
'fetch_id' => $fetch['id'] ?? null,
'rate_count' => count($savedRates),
]);
return [
'fetch' => $fetch,
'rates' => $savedRates,
];
} catch (\Throwable $exception) {
if ($startedTransaction && $this->pdo->inTransaction()) {
$this->debug?->add('db.saveFxFetch.rollback.start', [
'message' => $exception->getMessage(),
]);
$this->pdo->rollBack();
$this->debug?->add('db.saveFxFetch.rollback.end', [
'message' => $exception->getMessage(),
]);
}
$this->debug?->add('db.saveFxFetch.error', [
'message' => $exception->getMessage(),
]);
throw $exception;
}
}
public function listActiveFiatCurrencies(): array
{
$currencies = $this->listCurrencies();
return array_values(array_filter($currencies, static function (array $currency): bool {
$code = strtoupper((string) ($currency['code'] ?? ''));
return $code !== '' && empty($currency['is_crypto']);
}));
}
private function table(string $logicalName): string
{
return match ($logicalName) {
'projects' => $this->prefix . 'projects',
'currencies' => $this->prefix . 'currencies',
'settings' => $this->prefix . 'settings',
'cost_plans' => $this->prefix . 'cost_plans',
'measurements' => $this->prefix . 'measurements',
'targets' => $this->prefix . 'targets',
'dashboard_definitions' => $this->prefix . 'dashboard_definitions',
'fx_fetches' => $this->prefix . 'fx_fetches',
'fx_rates' => $this->prefix . 'fx_rates',
'measurement_rates' => $this->prefix . 'measurement_rates',
'payouts' => $this->prefix . 'payouts',
'miner_offers' => $this->prefix . 'miner_offers',
'purchased_miners' => $this->prefix . 'purchased_miners',
default => throw new \RuntimeException('Unknown mining table: ' . $logicalName),
};
}
private function normalizeInsertPayload(string $projectKey, array $payload): array
{
return [
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'label' => $payload['label'],
'starts_at' => $payload['starts_at'],
'runtime_months' => $payload['runtime_months'],
'mining_speed_value' => $payload['mining_speed_value'],
'mining_speed_unit' => $payload['mining_speed_unit'],
'bonus_speed_value' => $payload['bonus_speed_value'],
'bonus_speed_unit' => $payload['bonus_speed_unit'],
'auto_renew' => $payload['auto_renew'],
'base_price_amount' => $payload['base_price_amount'] ?? $payload['total_cost_amount'],
'payment_type' => $payload['payment_type'] ?? 'fiat',
'total_cost_amount' => $payload['total_cost_amount'],
'currency' => $payload['currency'],
'note' => $payload['note'],
'is_active' => $payload['is_active'],
];
}
private function normalizeOfferPayload(string $projectKey, array $payload): array
{
return [
'project_key' => $projectKey,
'label' => $payload['label'],
'runtime_months' => $payload['runtime_months'],
'mining_speed_value' => $payload['mining_speed_value'],
'mining_speed_unit' => $payload['mining_speed_unit'],
'bonus_speed_value' => $payload['bonus_speed_value'],
'bonus_speed_unit' => $payload['bonus_speed_unit'],
'base_price_amount' => $payload['base_price_amount'],
'base_price_currency' => $payload['base_price_currency'],
'payment_type' => $payload['payment_type'] ?? 'fiat',
'auto_renew' => $payload['auto_renew'] ?? 0,
'note' => $payload['note'],
'is_active' => $payload['is_active'],
];
}
private function normalizePurchasedPayload(string $projectKey, int $offerId, array $payload): array
{
return [
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'miner_offer_id' => $offerId,
'purchased_at' => $payload['purchased_at'],
'label' => $payload['label'],
'runtime_months' => $payload['runtime_months'],
'mining_speed_value' => $payload['mining_speed_value'],
'mining_speed_unit' => $payload['mining_speed_unit'],
'bonus_speed_value' => $payload['bonus_speed_value'],
'bonus_speed_unit' => $payload['bonus_speed_unit'],
'total_cost_amount' => $payload['total_cost_amount'],
'currency' => $payload['currency'],
'usd_reference_amount' => $payload['usd_reference_amount'],
'reference_price_amount' => $payload['reference_price_amount'] ?? null,
'reference_price_currency' => $payload['reference_price_currency'] ?? null,
'auto_renew' => $payload['auto_renew'] ?? 0,
'note' => $payload['note'],
'is_active' => $payload['is_active'],
];
}
private function normalizeRows(array $rows): array
{
return array_map(fn (array $row): array => $this->normalizeRow($row), $rows);
}
private function normalizeRow(array $row): array
{
foreach (['ocr_flags', 'filters_json', 'preferred_currencies'] as $jsonField) {
if (array_key_exists($jsonField, $row) && is_string($row[$jsonField]) && trim($row[$jsonField]) !== '') {
$decoded = json_decode($row[$jsonField], true);
if (json_last_error() === JSON_ERROR_NONE) {
$row[$jsonField] = $decoded;
}
}
}
foreach (['is_active', 'auto_renew'] as $booleanField) {
if (array_key_exists($booleanField, $row)) {
$row[$booleanField] = $this->normalizeBoolean($row[$booleanField]);
}
}
if (array_key_exists('is_crypto', $row)) {
$row['is_crypto'] = (bool) $this->normalizeBoolean($row['is_crypto']);
}
return $row;
}
private function isCryptoCode(string $code): bool
{
return in_array(strtoupper($code), [
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XRP',
], true);
}
private function normalizeBoolean(mixed $value): bool|int|null
{
if ($value === null) {
return null;
}
if (is_bool($value) || is_int($value)) {
return $value;
}
if (is_string($value)) {
$normalized = strtolower(trim($value));
if (in_array($normalized, ['t', 'true', '1', 'y', 'yes'], true)) {
return true;
}
if (in_array($normalized, ['f', 'false', '0', 'n', 'no'], true)) {
return false;
}
}
return (int) $value;
}
private function currentUtcTimestamp(): string
{
$timezone = new \DateTimeZone('UTC');
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
}