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'); } }