exec(" CREATE TABLE IF NOT EXISTS ac_api_clients ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, name VARCHAR(150) NOT NULL, api_key_hash CHAR(64) NOT NULL UNIQUE, api_key_prefix VARCHAR(24) NOT NULL, webhook_url VARCHAR(1000) NULL, webhook_secret VARCHAR(128) NULL, scopes_json TEXT NULL, is_active TINYINT(1) NOT NULL DEFAULT 1, last_used_at DATETIME NULL, last_used_ip VARCHAR(64) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_api_clients_active (is_active) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } catch (Throwable $e) { } try { $db->exec(" CREATE TABLE IF NOT EXISTS ac_store_order_item_allocations ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, order_item_id INT UNSIGNED NOT NULL, artist_id INT UNSIGNED NULL, release_id INT UNSIGNED NULL, track_id INT UNSIGNED NULL, source_item_type ENUM('track','release','bundle') NOT NULL, source_item_id INT UNSIGNED NOT NULL, title_snapshot VARCHAR(255) NOT NULL, qty INT UNSIGNED NOT NULL DEFAULT 1, gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_store_allocations_order (order_id), KEY idx_store_allocations_item (order_item_id), KEY idx_store_allocations_artist (artist_id), KEY idx_store_allocations_release (release_id), KEY idx_store_allocations_track (track_id), KEY idx_store_allocations_created (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); } catch (Throwable $e) { } try { $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug"); } catch (Throwable $e) { } try { $db->exec("ALTER TABLE ac_store_order_items ADD COLUMN artist_id INT UNSIGNED NULL AFTER item_id"); } catch (Throwable $e) { } try { $db->exec("ALTER TABLE ac_releases ADD KEY idx_releases_artist_id (artist_id)"); } catch (Throwable $e) { } try { $db->exec("ALTER TABLE ac_store_order_items ADD KEY idx_store_order_items_artist_id (artist_id)"); } catch (Throwable $e) { } self::backfillReleaseArtists($db); self::backfillOrderItemArtists($db); self::backfillAllocations($db); self::$schemaEnsured = true; } finally { self::$schemaEnsuring = false; } } public static function issueClient(PDO $db, string $name, string $webhookUrl = '', string $scopesJson = '[]'): string { self::ensureSchema($db); $plainKey = 'ac_' . bin2hex(random_bytes(24)); $stmt = $db->prepare(" INSERT INTO ac_api_clients (name, api_key_hash, api_key_prefix, webhook_url, scopes_json, is_active) VALUES (:name, :hash, :prefix, :webhook_url, :scopes_json, 1) "); $stmt->execute([ ':name' => $name, ':hash' => hash('sha256', $plainKey), ':prefix' => substr($plainKey, 0, 16), ':webhook_url' => $webhookUrl !== '' ? $webhookUrl : null, ':scopes_json' => $scopesJson, ]); return $plainKey; } public static function verifyRequest(?PDO $db = null): ?array { $db = $db instanceof PDO ? $db : Database::get(); if (!($db instanceof PDO)) { return null; } self::ensureSchema($db); $key = self::extractApiKey(); if ($key === '') { return null; } try { $stmt = $db->prepare(" SELECT id, name, api_key_prefix, webhook_url, webhook_secret, scopes_json, is_active FROM ac_api_clients WHERE api_key_hash = :hash AND is_active = 1 LIMIT 1 "); $stmt->execute([':hash' => hash('sha256', $key)]); $client = $stmt->fetch(PDO::FETCH_ASSOC); if (!$client) { return null; } $touch = $db->prepare(" UPDATE ac_api_clients SET last_used_at = NOW(), last_used_ip = :ip WHERE id = :id "); $touch->execute([ ':ip' => self::clientIp(), ':id' => (int)$client['id'], ]); return $client; } catch (Throwable $e) { return null; } } public static function syncOrderItemAllocations(PDO $db, int $orderId, int $orderItemId): void { self::ensureSchema($db); if ($orderId <= 0 || $orderItemId <= 0) { return; } try { $stmt = $db->prepare(" SELECT id, order_id, item_type, item_id, artist_id, title_snapshot, qty, line_total, currency_snapshot FROM ac_store_order_items WHERE id = :id AND order_id = :order_id LIMIT 1 "); $stmt->execute([ ':id' => $orderItemId, ':order_id' => $orderId, ]); $item = $stmt->fetch(PDO::FETCH_ASSOC); if (!$item) { return; } $db->prepare("DELETE FROM ac_store_order_item_allocations WHERE order_item_id = :order_item_id") ->execute([':order_item_id' => $orderItemId]); $type = (string)($item['item_type'] ?? ''); $sourceId = (int)($item['item_id'] ?? 0); $qty = max(1, (int)($item['qty'] ?? 1)); $lineTotal = (float)($item['line_total'] ?? 0); $currency = strtoupper(trim((string)($item['currency_snapshot'] ?? 'GBP'))); if (!preg_match('/^[A-Z]{3}$/', $currency)) { $currency = 'GBP'; } $rows = []; if ($type === 'track') { $track = self::loadTrackArtistContext($db, $sourceId); if ($track) { $rows[] = [ 'artist_id' => (int)($track['artist_id'] ?? 0), 'release_id' => (int)($track['release_id'] ?? 0), 'track_id' => $sourceId, 'title_snapshot' => (string)($track['title'] ?? $item['title_snapshot'] ?? 'Track'), 'gross_amount' => $lineTotal, ]; } } elseif ($type === 'release') { $release = self::loadReleaseArtistContext($db, $sourceId); if ($release) { $rows[] = [ 'artist_id' => (int)($release['artist_id'] ?? 0), 'release_id' => $sourceId, 'track_id' => null, 'title_snapshot' => (string)($release['title'] ?? $item['title_snapshot'] ?? 'Release'), 'gross_amount' => $lineTotal, ]; } } elseif ($type === 'bundle') { $rows = self::buildBundleAllocationRows($db, $sourceId, $lineTotal); } if (!$rows) { return; } $insert = $db->prepare(" INSERT INTO ac_store_order_item_allocations (order_id, order_item_id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at) VALUES (:order_id, :order_item_id, :artist_id, :release_id, :track_id, :source_item_type, :source_item_id, :title_snapshot, :qty, :gross_amount, :currency_snapshot, NOW()) "); foreach ($rows as $row) { $artistId = (int)($row['artist_id'] ?? 0); $releaseId = (int)($row['release_id'] ?? 0); $trackId = (int)($row['track_id'] ?? 0); $insert->execute([ ':order_id' => $orderId, ':order_item_id' => $orderItemId, ':artist_id' => $artistId > 0 ? $artistId : null, ':release_id' => $releaseId > 0 ? $releaseId : null, ':track_id' => $trackId > 0 ? $trackId : null, ':source_item_type' => $type, ':source_item_id' => $sourceId, ':title_snapshot' => (string)($row['title_snapshot'] ?? $item['title_snapshot'] ?? 'Item'), ':qty' => $qty, ':gross_amount' => (float)($row['gross_amount'] ?? 0), ':currency_snapshot' => $currency, ]); } } catch (Throwable $e) { } } public static function dispatchSaleWebhooksForOrder(int $orderId): void { $db = Database::get(); if (!($db instanceof PDO) || $orderId <= 0) { return; } self::ensureSchema($db); try { $stmt = $db->query(" SELECT id, name, webhook_url, webhook_secret FROM ac_api_clients WHERE is_active = 1 AND webhook_url IS NOT NULL AND webhook_url <> '' ORDER BY id ASC "); $clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; if (!$clients) { return; } $payload = self::buildOrderWebhookPayload($db, $orderId); if (!$payload) { return; } foreach ($clients as $client) { $url = trim((string)($client['webhook_url'] ?? '')); if ($url === '') { continue; } $json = json_encode($payload, JSON_UNESCAPED_SLASHES); if (!is_string($json) || $json === '') { continue; } $headers = [ 'Content-Type: application/json', 'Content-Length: ' . strlen($json), 'X-AudioCore-Event: sale.paid', 'X-AudioCore-Client: ' . (string)($client['name'] ?? 'AudioCore API'), ]; $secret = trim((string)($client['webhook_secret'] ?? '')); if ($secret !== '') { $headers[] = 'X-AudioCore-Signature: sha256=' . hash_hmac('sha256', $json, $secret); } self::postJson($url, $json, $headers); } } catch (Throwable $e) { } } private static function buildOrderWebhookPayload(PDO $db, int $orderId): array { try { $orderStmt = $db->prepare(" SELECT id, order_no, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, created_at, updated_at FROM ac_store_orders WHERE id = :id LIMIT 1 "); $orderStmt->execute([':id' => $orderId]); $order = $orderStmt->fetch(PDO::FETCH_ASSOC); if (!$order) { return []; } $allocStmt = $db->prepare(" SELECT id, artist_id, release_id, track_id, source_item_type, source_item_id, title_snapshot, qty, gross_amount, currency_snapshot, created_at FROM ac_store_order_item_allocations WHERE order_id = :order_id ORDER BY id ASC "); $allocStmt->execute([':order_id' => $orderId]); $allocations = $allocStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; return [ 'event' => 'sale.paid', 'order' => $order, 'allocations' => $allocations, ]; } catch (Throwable $e) { return []; } } private static function backfillReleaseArtists(PDO $db): void { try { $db->exec(" UPDATE ac_releases r JOIN ac_artists a ON a.name = r.artist_name SET r.artist_id = a.id WHERE r.artist_id IS NULL AND r.artist_name IS NOT NULL AND r.artist_name <> '' "); } catch (Throwable $e) { } } private static function backfillOrderItemArtists(PDO $db): void { try { $db->exec(" UPDATE ac_store_order_items oi JOIN ac_release_tracks t ON oi.item_type = 'track' AND oi.item_id = t.id JOIN ac_releases r ON r.id = t.release_id SET oi.artist_id = r.artist_id WHERE oi.artist_id IS NULL "); } catch (Throwable $e) { } try { $db->exec(" UPDATE ac_store_order_items oi JOIN ac_releases r ON oi.item_type = 'release' AND oi.item_id = r.id SET oi.artist_id = r.artist_id WHERE oi.artist_id IS NULL "); } catch (Throwable $e) { } } private static function backfillAllocations(PDO $db): void { try { $stmt = $db->query(" SELECT oi.id, oi.order_id FROM ac_store_order_items oi LEFT JOIN ac_store_order_item_allocations a ON a.order_item_id = oi.id WHERE a.id IS NULL ORDER BY oi.id ASC LIMIT 5000 "); $rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; foreach ($rows as $row) { self::syncOrderItemAllocations($db, (int)($row['order_id'] ?? 0), (int)($row['id'] ?? 0)); } } catch (Throwable $e) { } } private static function loadReleaseArtistContext(PDO $db, int $releaseId): ?array { if ($releaseId <= 0) { return null; } try { $stmt = $db->prepare(" SELECT r.id, r.title, r.artist_id FROM ac_releases r WHERE r.id = :id LIMIT 1 "); $stmt->execute([':id' => $releaseId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } catch (Throwable $e) { return null; } } private static function loadTrackArtistContext(PDO $db, int $trackId): ?array { if ($trackId <= 0) { return null; } try { $stmt = $db->prepare(" SELECT t.id, t.title, t.release_id, r.artist_id FROM ac_release_tracks t JOIN ac_releases r ON r.id = t.release_id WHERE t.id = :id LIMIT 1 "); $stmt->execute([':id' => $trackId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row ?: null; } catch (Throwable $e) { return null; } } private static function buildBundleAllocationRows(PDO $db, int $bundleId, float $lineTotal): array { if ($bundleId <= 0) { return []; } try { $stmt = $db->prepare(" SELECT r.id AS release_id, r.title, r.artist_id, COALESCE(SUM(CASE WHEN sp.is_enabled = 1 THEN sp.track_price ELSE 0 END), 0) AS weight FROM ac_store_bundle_items bi JOIN ac_releases r ON r.id = bi.release_id LEFT JOIN ac_release_tracks t ON t.release_id = r.id LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id WHERE bi.bundle_id = :bundle_id GROUP BY r.id, r.title, r.artist_id, bi.sort_order, bi.id ORDER BY bi.sort_order ASC, bi.id ASC "); $stmt->execute([':bundle_id' => $bundleId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; if (!$rows) { return []; } $weights = []; $totalWeight = 0.0; foreach ($rows as $index => $row) { $weight = max(0.0, (float)($row['weight'] ?? 0)); if ($weight <= 0) { $weight = 1.0; } $weights[$index] = $weight; $totalWeight += $weight; } if ($totalWeight <= 0) { $totalWeight = (float)count($rows); } $result = []; $allocated = 0.0; $lastIndex = count($rows) - 1; foreach ($rows as $index => $row) { if ($index === $lastIndex) { $amount = round($lineTotal - $allocated, 2); } else { $amount = round($lineTotal * ($weights[$index] / $totalWeight), 2); $allocated += $amount; } $result[] = [ 'artist_id' => (int)($row['artist_id'] ?? 0), 'release_id' => (int)($row['release_id'] ?? 0), 'track_id' => null, 'title_snapshot' => (string)($row['title'] ?? 'Bundle release'), 'gross_amount' => max(0.0, $amount), ]; } return $result; } catch (Throwable $e) { return []; } } private static function extractApiKey(): string { $auth = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? '')); if (stripos($auth, 'Bearer ') === 0) { return trim(substr($auth, 7)); } $header = trim((string)($_SERVER['HTTP_X_API_KEY'] ?? '')); if ($header !== '') { return $header; } return ''; } private static function clientIp(): string { $forwarded = trim((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')); if ($forwarded !== '') { $parts = explode(',', $forwarded); return trim((string)($parts[0] ?? '')); } return trim((string)($_SERVER['REMOTE_ADDR'] ?? '')); } private static function postJson(string $url, string $json, array $headers): void { if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch === false) { return; } curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_POSTFIELDS => $json, CURLOPT_TIMEOUT => 8, CURLOPT_CONNECTTIMEOUT => 5, ]); curl_exec($ch); curl_close($ch); return; } $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $json, 'timeout' => 8, 'ignore_errors' => true, ], ]); @file_get_contents($url, false, $context); } }