From 9deabe1ec9bc21776ec65e7bda72d1be2e076d72 Mon Sep 17 00:00:00 2001 From: AudioCore Bot Date: Wed, 1 Apr 2026 14:12:17 +0000 Subject: [PATCH] Release v1.5.1 --- .gitignore | 3 + README.md | 4 +- core/bootstrap.php | 63 +- core/http/Router.php | 382 +- core/services/ApiLayer.php | 560 ++ core/services/Auth.php | 19 +- core/services/Csrf.php | 98 +- core/services/Plugins.php | 534 +- core/services/RateLimiter.php | 80 + core/version.php | 3 +- core/views/View.php | 86 +- index.php | 4 +- modules/admin/AdminController.php | 48 +- modules/admin/views/installer.php | 8 +- modules/admin/views/layout.php | 1271 +-- modules/admin/views/settings.php | 17 +- modules/api/ApiController.php | 760 ++ modules/api/module.php | 26 + modules/api/views/admin/index.php | 189 + modules/artists/views/admin/.gitkeep | 0 modules/blog/views/site/index.php | 204 +- modules/blog/views/site/show.php | 298 +- modules/newsletter/NewsletterController.php | 36 + .../advanced-reporting/ReportsController.php | 536 ++ plugins/advanced-reporting/plugin.json | 14 + plugins/advanced-reporting/plugin.php | 13 + .../advanced-reporting/views/admin/index.php | 387 + plugins/releases/ReleasesController.php | 46 +- plugins/releases/plugin.php | 2 + plugins/releases/views/admin/edit.php | 39 - plugins/releases/views/site/show.php | 73 +- plugins/store/StoreController.php | 7788 ++++++++++------- plugins/store/plugin.php | 6 + plugins/store/views/admin/customers.php | 20 +- plugins/store/views/admin/index.php | 29 +- plugins/store/views/admin/order.php | 151 +- plugins/store/views/admin/orders.php | 12 +- plugins/store/views/admin/settings.php | 219 +- plugins/store/views/site/cart.php | 15 + plugins/store/views/site/checkout.php | 450 +- plugins/store/views/site/checkout_card.php | 310 + plugins/support/SupportController.php | 5 + public/index.php | 49 +- storage/cache/.gitkeep | 0 update.json | 10 +- views/admin/.gitkeep | 0 views/partials/footer.php | 2 +- views/partials/header.php | 12 +- views/site/home.php | 4 +- views/site/layout.php | 1527 ++-- 50 files changed, 10775 insertions(+), 5637 deletions(-) create mode 100644 core/services/ApiLayer.php create mode 100644 core/services/RateLimiter.php create mode 100644 modules/api/ApiController.php create mode 100644 modules/api/module.php create mode 100644 modules/api/views/admin/index.php create mode 100644 modules/artists/views/admin/.gitkeep create mode 100644 plugins/advanced-reporting/ReportsController.php create mode 100644 plugins/advanced-reporting/plugin.json create mode 100644 plugins/advanced-reporting/plugin.php create mode 100644 plugins/advanced-reporting/views/admin/index.php create mode 100644 plugins/store/views/site/checkout_card.php create mode 100644 storage/cache/.gitkeep create mode 100644 views/admin/.gitkeep diff --git a/.gitignore b/.gitignore index 3ef2128..d08ec6c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ storage/*.sqlite storage/mail_debug/ storage/sessions/ storage/cache/ +!storage/cache/ +!storage/cache/.gitkeep # Uploads / media / binaries uploads/ @@ -33,3 +35,4 @@ storage/db.php storage/settings.php storage/update_cache.json storage/logs/ + diff --git a/README.md b/README.md index 76d89fa..0b17795 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# AudioCore +# AudioCore V1.5.1 -AudioCore V1.5 is a modular CMS for labels, artists, releases, storefront sales, and support workflows. +AudioCore V1.5.1 is a modular CMS for labels, artists, releases, storefront sales, and support workflows. It is built around a plugin-first architecture so core stays clean while features can be enabled/disabled per site. diff --git a/core/bootstrap.php b/core/bootstrap.php index 124253f..24f036c 100644 --- a/core/bootstrap.php +++ b/core/bootstrap.php @@ -1,22 +1,47 @@ -'; + } +} + +if (!function_exists('csrf_meta')) { + function csrf_meta(): string + { + $token = htmlspecialchars(\Core\Services\Csrf::token(), ENT_QUOTES, 'UTF-8'); + return ''; + } +} diff --git a/core/http/Router.php b/core/http/Router.php index 01b4383..89695c7 100644 --- a/core/http/Router.php +++ b/core/http/Router.php @@ -1,185 +1,197 @@ -routes['GET'][$path] = $handler; - } - - public function post(string $path, callable $handler): void - { - $this->routes['POST'][$path] = $handler; - } - - public function registerModules(string $modulesPath): void - { - foreach (glob($modulesPath . '/*/module.php') as $moduleFile) { - $module = require $moduleFile; - if (is_callable($module)) { - $module($this); - } - } - } - - public function dispatch(string $uri, string $method): Response - { - $path = parse_url($uri, PHP_URL_PATH) ?: '/'; - if ($path !== '/' && str_ends_with($path, '/')) { - $path = rtrim($path, '/'); - } - $method = strtoupper($method); - - - if ($method === 'POST' && $this->requiresCsrf($path) && !Csrf::verifyRequest()) { - return new Response('Invalid request token. Please refresh and try again.', 419); - } - - if ($method === 'GET') { - $redirect = $this->matchRedirect($path); - if ($redirect !== null) { - return $redirect; - } - } - - if (str_starts_with($path, '/admin')) { - $permission = Permissions::routePermission($path); - if ($permission !== null && Auth::check() && !Auth::can($permission)) { - return new Response('', 302, ['Location' => '/admin?denied=1']); - } - } - - if (isset($this->routes[$method][$path])) { - $handler = $this->routes[$method][$path]; - return $handler(); - } - - if ($method === 'GET') { - if (str_starts_with($path, '/news/')) { - $postSlug = trim(substr($path, strlen('/news/'))); - if ($postSlug !== '' && strpos($postSlug, '/') === false) { - $db = Database::get(); - if ($db instanceof PDO) { - try { - $stmt = $db->prepare(" - SELECT title, content_html, published_at, featured_image_url, author_name, category, tags - FROM ac_posts - WHERE slug = :slug AND is_published = 1 - LIMIT 1 - "); - $stmt->execute([':slug' => $postSlug]); - $post = $stmt->fetch(PDO::FETCH_ASSOC); - if ($post) { - $view = new View(__DIR__ . '/../../modules/blog/views'); - return new Response($view->render('site/show.php', [ - 'title' => (string)$post['title'], - 'content_html' => (string)$post['content_html'], - 'published_at' => (string)($post['published_at'] ?? ''), - 'featured_image_url' => (string)($post['featured_image_url'] ?? ''), - 'author_name' => (string)($post['author_name'] ?? ''), - 'category' => (string)($post['category'] ?? ''), - 'tags' => (string)($post['tags'] ?? ''), - ])); - } - } catch (Throwable $e) { - } - } - } - } - - $slug = ltrim($path, '/'); - if ($slug !== '' && strpos($slug, '/') === false) { - $db = Database::get(); - if ($db instanceof PDO) { - try { - $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1"); - $stmt->execute([':slug' => $slug]); - $page = $stmt->fetch(PDO::FETCH_ASSOC); - if ($page) { - $view = new View(__DIR__ . '/../../modules/pages/views'); - return new Response($view->render('site/show.php', [ - 'title' => (string)$page['title'], - 'content_html' => Shortcodes::render((string)$page['content_html'], [ - 'page_slug' => (string)$slug, - ]), - ])); - } - } catch (Throwable $e) { - } - } - } - } - - $view = new View(); - return new Response($view->render('site/404.php', [ - 'title' => 'Not Found', - 'message' => 'Page not found.', - ]), 404); - } - - - - private function requiresCsrf(string $path): bool - { - // All browser-initiated POST routes require CSRF protection. - return true; - } - - private function matchRedirect(string $path): ?Response - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return null; - } - - try { - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_redirects ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - source_path VARCHAR(255) NOT NULL UNIQUE, - target_url VARCHAR(1000) NOT NULL, - status_code SMALLINT NOT NULL DEFAULT 301, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $stmt = $db->prepare(" - SELECT target_url, status_code - FROM ac_redirects - WHERE source_path = :path AND is_active = 1 - LIMIT 1 - "); - $stmt->execute([':path' => $path]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$row) { - return null; - } - $code = (int)($row['status_code'] ?? 301); - if (!in_array($code, [301, 302, 307, 308], true)) { - $code = 301; - } - $target = trim((string)($row['target_url'] ?? '')); - if ($target === '' || $target === $path) { - return null; - } - return new Response('', $code, ['Location' => $target]); - } catch (Throwable $e) { - return null; - } - } -} +routes['GET'][$path] = $handler; + } + + public function post(string $path, callable $handler): void + { + $this->routes['POST'][$path] = $handler; + } + + public function registerModules(string $modulesPath): void + { + foreach (glob($modulesPath . '/*/module.php') as $moduleFile) { + $module = require $moduleFile; + if (is_callable($module)) { + $module($this); + } + } + } + + public function dispatch(string $uri, string $method): Response + { + $path = parse_url($uri, PHP_URL_PATH) ?: '/'; + if ($path !== '/' && str_ends_with($path, '/')) { + $path = rtrim($path, '/'); + } + $method = strtoupper($method); + + if ($method === 'GET') { + $redirect = $this->matchRedirect($path); + if ($redirect !== null) { + return $redirect; + } + } + + if (str_starts_with($path, '/admin')) { + $permission = Permissions::routePermission($path); + if ($permission !== null && Auth::check() && !Auth::can($permission)) { + return new Response('', 302, ['Location' => '/admin?denied=1']); + } + } + + if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true) && !$this->isCsrfExempt($path) && !Csrf::verifyRequest()) { + return $this->csrfFailureResponse($path); + } + + if (isset($this->routes[$method][$path])) { + $handler = $this->routes[$method][$path]; + return $handler(); + } + + if ($method === 'GET') { + if (str_starts_with($path, '/news/')) { + $postSlug = trim(substr($path, strlen('/news/'))); + if ($postSlug !== '' && strpos($postSlug, '/') === false) { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->prepare(" + SELECT title, content_html, published_at, featured_image_url, author_name, category, tags + FROM ac_posts + WHERE slug = :slug AND is_published = 1 + LIMIT 1 + "); + $stmt->execute([':slug' => $postSlug]); + $post = $stmt->fetch(PDO::FETCH_ASSOC); + if ($post) { + $view = new View(__DIR__ . '/../../modules/blog/views'); + return new Response($view->render('site/show.php', [ + 'title' => (string)$post['title'], + 'content_html' => (string)$post['content_html'], + 'published_at' => (string)($post['published_at'] ?? ''), + 'featured_image_url' => (string)($post['featured_image_url'] ?? ''), + 'author_name' => (string)($post['author_name'] ?? ''), + 'category' => (string)($post['category'] ?? ''), + 'tags' => (string)($post['tags'] ?? ''), + ])); + } + } catch (Throwable $e) { + } + } + } + } + + $slug = ltrim($path, '/'); + if ($slug !== '' && strpos($slug, '/') === false) { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->prepare("SELECT title, content_html FROM ac_pages WHERE slug = :slug AND is_published = 1 LIMIT 1"); + $stmt->execute([':slug' => $slug]); + $page = $stmt->fetch(PDO::FETCH_ASSOC); + if ($page) { + $view = new View(__DIR__ . '/../../modules/pages/views'); + return new Response($view->render('site/show.php', [ + 'title' => (string)$page['title'], + 'content_html' => Shortcodes::render((string)$page['content_html'], [ + 'page_slug' => (string)$slug, + ]), + ])); + } + } catch (Throwable $e) { + } + } + } + } + + $view = new View(); + return new Response($view->render('site/404.php', [ + 'title' => 'Not Found', + 'message' => 'Page not found.', + ]), 404); + } + + private function isCsrfExempt(string $path): bool + { + return str_starts_with($path, '/api/'); + } + + private function csrfFailureResponse(string $path): Response + { + $accept = (string)($_SERVER['HTTP_ACCEPT'] ?? ''); + $contentType = (string)($_SERVER['CONTENT_TYPE'] ?? ''); + if (stripos($accept, 'application/json') !== false || stripos($contentType, 'application/json') !== false) { + return new Response(json_encode(['ok' => false, 'error' => 'Invalid CSRF token.']), 419, ['Content-Type' => 'application/json']); + } + + $target = (string)($_SERVER['HTTP_REFERER'] ?? $path); + if ($target === '') { + $target = '/'; + } + $separator = str_contains($target, '?') ? '&' : '?'; + return new Response('', 302, ['Location' => $target . $separator . 'error=csrf']); + } + + private function matchRedirect(string $path): ?Response + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return null; + } + + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_redirects ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + source_path VARCHAR(255) NOT NULL UNIQUE, + target_url VARCHAR(1000) NOT NULL, + status_code SMALLINT NOT NULL DEFAULT 301, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $stmt = $db->prepare(" + SELECT target_url, status_code + FROM ac_redirects + WHERE source_path = :path AND is_active = 1 + LIMIT 1 + "); + $stmt->execute([':path' => $path]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + $code = (int)($row['status_code'] ?? 301); + if (!in_array($code, [301, 302, 307, 308], true)) { + $code = 301; + } + $target = trim((string)($row['target_url'] ?? '')); + if ($target === '' || $target === $path) { + return null; + } + return new Response('', $code, ['Location' => $target]); + } catch (Throwable $e) { + return null; + } + } +} diff --git a/core/services/ApiLayer.php b/core/services/ApiLayer.php new file mode 100644 index 0000000..f056990 --- /dev/null +++ b/core/services/ApiLayer.php @@ -0,0 +1,560 @@ +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); + } +} diff --git a/core/services/Auth.php b/core/services/Auth.php index 9b50a0a..ca3633b 100644 --- a/core/services/Auth.php +++ b/core/services/Auth.php @@ -12,13 +12,7 @@ class Auth public static function init(): void { if (session_status() !== PHP_SESSION_ACTIVE) { - $secure = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; - session_start([ - 'cookie_httponly' => true, - 'cookie_secure' => $secure, - 'cookie_samesite' => 'Lax', - 'use_strict_mode' => 1, - ]); + session_start(); } } @@ -48,9 +42,14 @@ class Auth public static function logout(): void { self::init(); - unset($_SESSION[self::SESSION_KEY]); - unset($_SESSION[self::SESSION_ROLE_KEY]); - unset($_SESSION[self::SESSION_NAME_KEY]); + $_SESSION = []; + if (ini_get('session.use_cookies')) { + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'], (bool)$params['secure'], (bool)$params['httponly']); + } + session_destroy(); + session_start(); + session_regenerate_id(true); } public static function role(): string diff --git a/core/services/Csrf.php b/core/services/Csrf.php index ebbfbf8..2bf032d 100644 --- a/core/services/Csrf.php +++ b/core/services/Csrf.php @@ -1,50 +1,48 @@ - (string)($nav['label'] ?? ''), - 'url' => (string)($nav['url'] ?? ''), - 'roles' => array_values(array_filter((array)($nav['roles'] ?? []))), - 'icon' => (string)($nav['icon'] ?? ''), - 'slug' => (string)($plugin['slug'] ?? ''), - ]; - } - return array_values(array_filter($items, static function (array $item): bool { - if ($item['label'] === '' || $item['url'] === '') { - return false; - } - $slug = trim((string)($item['slug'] ?? '')); - if ($slug !== '' && Auth::check() && !Permissions::can(Auth::role(), 'plugin.' . $slug)) { - return false; - } - return true; - })); - } - - public static function register(Router $router): void - { - foreach (self::enabled() as $plugin) { - $entry = (string)($plugin['entry'] ?? 'plugin.php'); - $entryPath = rtrim((string)($plugin['path'] ?? ''), '/') . '/' . $entry; - if (!is_file($entryPath)) { - continue; - } - $handler = require $entryPath; - if (is_callable($handler)) { - $handler($router); - } - } - } - - public static function toggle(string $slug, bool $enabled): void - { - $db = Database::get(); - if (!$db instanceof PDO) { - return; - } - try { - $stmt = $db->prepare("UPDATE ac_plugins SET is_enabled = :enabled, updated_at = NOW() WHERE slug = :slug"); - $stmt->execute([ - ':enabled' => $enabled ? 1 : 0, - ':slug' => $slug, - ]); - } catch (Throwable $e) { - } - self::sync(); - if (!$enabled) { - $plugin = null; - foreach (self::$plugins as $item) { - if (($item['slug'] ?? '') === $slug) { - $plugin = $item; - break; - } - } - if ($plugin && !empty($plugin['pages'])) { - self::removeNavLinks($db, (array)$plugin['pages']); - } - } - } - - public static function sync(): void - { - $filesystem = self::scanFilesystem(); - $db = Database::get(); - $dbRows = []; - - if ($db instanceof PDO) { - try { - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_plugins ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - slug VARCHAR(120) NOT NULL UNIQUE, - name VARCHAR(200) NOT NULL, - version VARCHAR(50) NOT NULL DEFAULT '0.0.0', - is_enabled TINYINT(1) NOT NULL DEFAULT 0, - installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $stmt = $db->query("SELECT slug, is_enabled FROM ac_plugins"); - $dbRows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; - } catch (Throwable $e) { - $dbRows = []; - } - } - - $dbMap = []; - foreach ($dbRows as $row) { - $dbMap[(string)$row['slug']] = (int)$row['is_enabled']; - } - - foreach ($filesystem as $slug => $plugin) { - $plugin['is_enabled'] = (bool)($dbMap[$slug] ?? ($plugin['default_enabled'] ?? false)); - $filesystem[$slug] = $plugin; - if ($db instanceof PDO && !isset($dbMap[$slug])) { - try { - $stmt = $db->prepare(" - INSERT INTO ac_plugins (slug, name, version, is_enabled) - VALUES (:slug, :name, :version, :enabled) - "); - $stmt->execute([ - ':slug' => $slug, - ':name' => (string)($plugin['name'] ?? $slug), - ':version' => (string)($plugin['version'] ?? '0.0.0'), - ':enabled' => $plugin['is_enabled'] ? 1 : 0, - ]); - } catch (Throwable $e) { - } - } - if ($db instanceof PDO && $plugin['is_enabled']) { - $thisPages = $plugin['pages'] ?? []; - if (is_array($thisPages) && $thisPages) { - self::ensurePages($db, $thisPages); - } - } - } - - self::$plugins = array_values($filesystem); - } - - private static function scanFilesystem(): array - { - if (self::$path === '' || !is_dir(self::$path)) { - return []; - } - - $plugins = []; - foreach (glob(self::$path . '/*/plugin.json') as $manifestPath) { - $dir = dirname($manifestPath); - $slug = basename($dir); - $raw = file_get_contents($manifestPath); - $decoded = json_decode($raw ?: '', true); - if (!is_array($decoded)) { - $decoded = []; - } - - $plugins[$slug] = [ - 'slug' => $slug, - 'name' => (string)($decoded['name'] ?? $slug), - 'version' => (string)($decoded['version'] ?? '0.0.0'), - 'description' => (string)($decoded['description'] ?? ''), - 'author' => (string)($decoded['author'] ?? ''), - 'admin_nav' => is_array($decoded['admin_nav'] ?? null) ? $decoded['admin_nav'] : null, - 'pages' => is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [], - 'entry' => (string)($decoded['entry'] ?? 'plugin.php'), - 'default_enabled' => (bool)($decoded['default_enabled'] ?? false), - 'path' => $dir, - ]; - } - - return $plugins; - } - - private static function ensurePages(PDO $db, array $pages): void - { - foreach ($pages as $page) { - if (!is_array($page)) { - continue; - } - $slug = trim((string)($page['slug'] ?? '')); - $title = trim((string)($page['title'] ?? '')); - $content = (string)($page['content_html'] ?? ''); - if ($slug === '' || $title === '') { - continue; - } - try { - $stmt = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1"); - $stmt->execute([':slug' => $slug]); - if ($stmt->fetch()) { - continue; - } - $insert = $db->prepare(" - INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index) - VALUES (:title, :slug, :content_html, 1, 0, 0) - "); - $insert->execute([ - ':title' => $title, - ':slug' => $slug, - ':content_html' => $content, - ]); - } catch (Throwable $e) { - } - } - } - - private static function removeNavLinks(PDO $db, array $pages): void - { - foreach ($pages as $page) { - if (!is_array($page)) { - continue; - } - $slug = trim((string)($page['slug'] ?? '')); - if ($slug === '') { - continue; - } - $url = '/' . ltrim($slug, '/'); - try { - $stmt = $db->prepare("DELETE FROM ac_nav_links WHERE url = :url"); - $stmt->execute([':url' => $url]); - } catch (Throwable $e) { - } - } - } -} + (string)($nav['label'] ?? ''), + 'url' => (string)($nav['url'] ?? ''), + 'roles' => array_values(array_filter((array)($nav['roles'] ?? []))), + 'icon' => (string)($nav['icon'] ?? ''), + 'slug' => (string)($plugin['slug'] ?? ''), + ]; + } + $items = array_values(array_filter($items, static function (array $item): bool { + if ($item['label'] === '' || $item['url'] === '') { + return false; + } + $slug = trim((string)($item['slug'] ?? '')); + if ($slug !== '' && Auth::check() && !Permissions::can(Auth::role(), 'plugin.' . $slug)) { + return false; + } + return true; + })); + $order = [ + 'artists' => 10, + 'releases' => 20, + 'store' => 30, + 'advanced-reporting' => 40, + 'support' => 50, + ]; + usort($items, static function (array $a, array $b) use ($order): int { + $aSlug = (string)($a['slug'] ?? ''); + $bSlug = (string)($b['slug'] ?? ''); + $aOrder = $order[$aSlug] ?? 1000; + $bOrder = $order[$bSlug] ?? 1000; + if ($aOrder === $bOrder) { + return strcasecmp((string)($a['label'] ?? ''), (string)($b['label'] ?? '')); + } + return $aOrder <=> $bOrder; + }); + return $items; + } + + public static function register(Router $router): void + { + foreach (self::enabled() as $plugin) { + $entry = (string)($plugin['entry'] ?? 'plugin.php'); + $entryPath = rtrim((string)($plugin['path'] ?? ''), '/') . '/' . $entry; + if (!is_file($entryPath)) { + continue; + } + $handler = require $entryPath; + if (is_callable($handler)) { + $handler($router); + } + } + } + + public static function toggle(string $slug, bool $enabled): void + { + $db = Database::get(); + if (!$db instanceof PDO) { + return; + } + try { + $stmt = $db->prepare("UPDATE ac_plugins SET is_enabled = :enabled, updated_at = NOW() WHERE slug = :slug"); + $stmt->execute([ + ':enabled' => $enabled ? 1 : 0, + ':slug' => $slug, + ]); + } catch (Throwable $e) { + } + self::sync(); + if (!$enabled) { + $plugin = null; + foreach (self::$plugins as $item) { + if (($item['slug'] ?? '') === $slug) { + $plugin = $item; + break; + } + } + if ($plugin && !empty($plugin['pages'])) { + self::removeNavLinks($db, (array)$plugin['pages']); + } + } + } + + public static function sync(): void + { + $filesystem = self::scanFilesystem(); + $db = Database::get(); + $dbRows = []; + + if ($db instanceof PDO) { + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_plugins ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(120) NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + version VARCHAR(50) NOT NULL DEFAULT '0.0.0', + is_enabled TINYINT(1) NOT NULL DEFAULT 0, + installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $stmt = $db->query("SELECT slug, is_enabled FROM ac_plugins"); + $dbRows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + $dbRows = []; + } + } + + $dbMap = []; + foreach ($dbRows as $row) { + $dbMap[(string)$row['slug']] = (int)$row['is_enabled']; + } + + foreach ($filesystem as $slug => $plugin) { + $plugin['is_enabled'] = (bool)($dbMap[$slug] ?? ($plugin['default_enabled'] ?? false)); + $filesystem[$slug] = $plugin; + if ($db instanceof PDO && !isset($dbMap[$slug])) { + try { + $stmt = $db->prepare(" + INSERT INTO ac_plugins (slug, name, version, is_enabled) + VALUES (:slug, :name, :version, :enabled) + "); + $stmt->execute([ + ':slug' => $slug, + ':name' => (string)($plugin['name'] ?? $slug), + ':version' => (string)($plugin['version'] ?? '0.0.0'), + ':enabled' => $plugin['is_enabled'] ? 1 : 0, + ]); + } catch (Throwable $e) { + } + } + if ($db instanceof PDO && $plugin['is_enabled']) { + $thisPages = $plugin['pages'] ?? []; + if (is_array($thisPages) && $thisPages) { + self::ensurePages($db, $thisPages); + } + } + } + + self::$plugins = array_values($filesystem); + } + + private static function scanFilesystem(): array + { + if (self::$path === '' || !is_dir(self::$path)) { + return []; + } + + $plugins = []; + foreach (glob(self::$path . '/*/plugin.json') as $manifestPath) { + $dir = dirname($manifestPath); + $slug = basename($dir); + $raw = file_get_contents($manifestPath); + $decoded = json_decode($raw ?: '', true); + if (!is_array($decoded)) { + $decoded = []; + } + + $plugins[$slug] = [ + 'slug' => $slug, + 'name' => (string)($decoded['name'] ?? $slug), + 'version' => (string)($decoded['version'] ?? '0.0.0'), + 'description' => (string)($decoded['description'] ?? ''), + 'author' => (string)($decoded['author'] ?? ''), + 'admin_nav' => is_array($decoded['admin_nav'] ?? null) ? $decoded['admin_nav'] : null, + 'pages' => is_array($decoded['pages'] ?? null) ? $decoded['pages'] : [], + 'entry' => (string)($decoded['entry'] ?? 'plugin.php'), + 'default_enabled' => (bool)($decoded['default_enabled'] ?? false), + 'path' => $dir, + ]; + } + + return $plugins; + } + + private static function ensurePages(PDO $db, array $pages): void + { + foreach ($pages as $page) { + if (!is_array($page)) { + continue; + } + $slug = trim((string)($page['slug'] ?? '')); + $title = trim((string)($page['title'] ?? '')); + $content = (string)($page['content_html'] ?? ''); + if ($slug === '' || $title === '') { + continue; + } + try { + $stmt = $db->prepare("SELECT id FROM ac_pages WHERE slug = :slug LIMIT 1"); + $stmt->execute([':slug' => $slug]); + if ($stmt->fetch()) { + continue; + } + $insert = $db->prepare(" + INSERT INTO ac_pages (title, slug, content_html, is_published, is_home, is_blog_index) + VALUES (:title, :slug, :content_html, 1, 0, 0) + "); + $insert->execute([ + ':title' => $title, + ':slug' => $slug, + ':content_html' => $content, + ]); + } catch (Throwable $e) { + } + } + } + + private static function removeNavLinks(PDO $db, array $pages): void + { + foreach ($pages as $page) { + if (!is_array($page)) { + continue; + } + $slug = trim((string)($page['slug'] ?? '')); + if ($slug === '') { + continue; + } + $url = '/' . ltrim($slug, '/'); + try { + $stmt = $db->prepare("DELETE FROM ac_nav_links WHERE url = :url"); + $stmt->execute([':url' => $url]); + } catch (Throwable $e) { + } + } + } +} diff --git a/core/services/RateLimiter.php b/core/services/RateLimiter.php new file mode 100644 index 0000000..cab779d --- /dev/null +++ b/core/services/RateLimiter.php @@ -0,0 +1,80 @@ +prepare(" + SELECT COUNT(*) AS c + FROM ac_rate_limits + WHERE action_name = :action_name + AND subject_key = :subject_key + AND created_at >= :cutoff + "); + $countStmt->execute([ + ':action_name' => $action, + ':subject_key' => $subjectKey, + ':cutoff' => $cutoff, + ]); + $count = (int)(($countStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0)); + if ($count >= $limit) { + return true; + } + + $insertStmt = $db->prepare(" + INSERT INTO ac_rate_limits (action_name, subject_key, created_at) + VALUES (:action_name, :subject_key, NOW()) + "); + $insertStmt->execute([ + ':action_name' => $action, + ':subject_key' => $subjectKey, + ]); + + $db->exec("DELETE FROM ac_rate_limits WHERE created_at < (NOW() - INTERVAL 2 DAY)"); + } catch (Throwable $e) { + return false; + } + + return false; + } + + private static function ensureTable(PDO $db): void + { + if (self::$tableEnsured) { + return; + } + + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_rate_limits ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + action_name VARCHAR(80) NOT NULL, + subject_key VARCHAR(191) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_rate_limits_lookup (action_name, subject_key, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + "); + + self::$tableEnsured = true; + } +} diff --git a/core/version.php b/core/version.php index fd0daf7..e02c8a2 100644 --- a/core/version.php +++ b/core/version.php @@ -2,6 +2,5 @@ declare(strict_types=1); return [ - 'version' => '1.5.0', + 'version' => '1.5.1', ]; - diff --git a/core/views/View.php b/core/views/View.php index 67cffe3..2e6cef7 100644 --- a/core/views/View.php +++ b/core/views/View.php @@ -1,31 +1,55 @@ -basePath = $basePath !== '' ? rtrim($basePath, '/') : __DIR__ . '/../../views'; - } - - public function render(string $template, array $vars = []): string - { - $path = $this->basePath !== '' ? $this->basePath . '/' . ltrim($template, '/') : $template; - if (!is_file($path)) { - error_log('AC View missing: ' . $path); - return ''; - } - - if ($vars) { - extract($vars, EXTR_SKIP); - } - - ob_start(); - require $path; - return ob_get_clean() ?: ''; - } -} +basePath = $basePath !== '' ? rtrim($basePath, '/') : __DIR__ . '/../../views'; + } + + public function render(string $template, array $vars = []): string + { + $path = $this->basePath !== '' ? $this->basePath . '/' . ltrim($template, '/') : $template; + if (!is_file($path)) { + error_log('AC View missing: ' . $path); + return ''; + } + + if ($vars) { + extract($vars, EXTR_SKIP); + } + + ob_start(); + require $path; + $html = ob_get_clean() ?: ''; + return $this->injectCsrfTokens($html); + } + + private function injectCsrfTokens(string $html): string + { + if ($html === '' || stripos($html, ''; + + return (string)preg_replace_callback( + '~]*>~i', + static function (array $matches) use ($tokenField): string { + $tag = $matches[0]; + if (!preg_match('~\bmethod\s*=\s*([\"\']?)post\1~i', $tag)) { + return $tag; + } + return $tag . $tokenField; + }, + $html + ); + } +} diff --git a/index.php b/index.php index d4a3596..51fa49a 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,6 @@ appInstalled()) { + return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']); + } $installer = $_SESSION['installer'] ?? []; $step = !empty($installer['core_ready']) ? 2 : 1; $values = is_array($installer['values'] ?? null) ? $installer['values'] : []; @@ -61,6 +64,9 @@ class AdminController public function install(): Response { + if ($this->appInstalled()) { + return new Response('', 302, ['Location' => Auth::check() ? '/admin' : '/admin/login']); + } $action = trim((string)($_POST['installer_action'] ?? 'setup_core')); if ($action === 'setup_core') { return $this->installSetupCore(); @@ -307,9 +313,9 @@ class AdminController 'title' => 'Settings', 'status' => $status, 'status_message' => $statusMessage, - 'footer_text' => Settings::get('footer_text', 'AudioCore V1.5'), + 'footer_text' => Settings::get('footer_text', 'AudioCore V1.5.1'), 'footer_links' => $this->parseFooterLinks(Settings::get('footer_links_json', '[]')), - 'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5'), + 'site_header_title' => Settings::get('site_header_title', 'AudioCore V1.5.1'), 'site_header_tagline' => Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'), 'site_header_badge_text' => Settings::get('site_header_badge_text', 'Independent catalog'), 'site_header_brand_mode' => Settings::get('site_header_brand_mode', 'default'), @@ -327,6 +333,7 @@ class AdminController 'site_maintenance_button_label' => Settings::get('site_maintenance_button_label', ''), 'site_maintenance_button_url' => Settings::get('site_maintenance_button_url', ''), 'site_maintenance_html' => Settings::get('site_maintenance_html', ''), + 'site_maintenance_access_password_enabled' => Settings::get('site_maintenance_access_password_hash', '') !== '' ? '1' : '0', 'smtp_host' => Settings::get('smtp_host', ''), 'smtp_port' => Settings::get('smtp_port', '587'), 'smtp_user' => Settings::get('smtp_user', ''), @@ -336,7 +343,7 @@ class AdminController 'smtp_from_name' => Settings::get('smtp_from_name', ''), 'mailchimp_api_key' => Settings::get('mailchimp_api_key', ''), 'mailchimp_list_id' => Settings::get('mailchimp_list_id', ''), - 'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5')), + 'seo_title_suffix' => Settings::get('seo_title_suffix', Settings::get('site_title', 'AudioCore V1.5.1')), 'seo_meta_description' => Settings::get('seo_meta_description', ''), 'seo_robots_index' => Settings::get('seo_robots_index', '1'), 'seo_robots_follow' => Settings::get('seo_robots_follow', '1'), @@ -632,6 +639,8 @@ class AdminController $maintenanceButtonLabel = trim((string)($_POST['site_maintenance_button_label'] ?? '')); $maintenanceButtonUrl = trim((string)($_POST['site_maintenance_button_url'] ?? '')); $maintenanceHtml = trim((string)($_POST['site_maintenance_html'] ?? '')); + $maintenanceAccessPassword = trim((string)($_POST['site_maintenance_access_password'] ?? '')); + $maintenanceAccessPasswordClear = isset($_POST['site_maintenance_access_password_clear']); $smtpHost = trim((string)($_POST['smtp_host'] ?? '')); $smtpPort = trim((string)($_POST['smtp_port'] ?? '')); $smtpUser = trim((string)($_POST['smtp_user'] ?? '')); @@ -675,6 +684,11 @@ class AdminController Settings::set('site_maintenance_button_label', $maintenanceButtonLabel); Settings::set('site_maintenance_button_url', $maintenanceButtonUrl); Settings::set('site_maintenance_html', $maintenanceHtml); + if ($maintenanceAccessPasswordClear) { + Settings::set('site_maintenance_access_password_hash', ''); + } elseif ($maintenanceAccessPassword !== '') { + Settings::set('site_maintenance_access_password_hash', password_hash($maintenanceAccessPassword, PASSWORD_DEFAULT)); + } Settings::set('smtp_host', $smtpHost); Settings::set('smtp_port', $smtpPort); Settings::set('smtp_user', $smtpUser); @@ -769,7 +783,7 @@ class AdminController } $settingsPath = $storageDir . '/settings.php'; if (!is_file($settingsPath)) { - $settingsSeed = " 'AudioCore V1.5',\n];\n"; + $settingsSeed = " 'AudioCore V1.5.1',\n];\n"; @file_put_contents($settingsPath, $settingsSeed); } $configPath = $storageDir . '/db.php'; @@ -925,7 +939,7 @@ class AdminController ]; $subject = 'AudioCore installer SMTP test'; - $html = '

SMTP test successful

Your AudioCore V1.5 installer SMTP settings are valid.

' + $html = '

SMTP test successful

Your AudioCore V1.5.1 installer SMTP settings are valid.

' . '

Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC

'; $mail = Mailer::send($testEmail, $subject, $html, $smtpSettings); @@ -1028,9 +1042,9 @@ class AdminController private function installerDefaultValues(): array { return [ - 'site_title' => 'AudioCore V1.5', + 'site_title' => 'AudioCore V1.5.1', 'site_tagline' => 'Core CMS for DJs & Producers', - 'seo_title_suffix' => 'AudioCore V1.5', + 'seo_title_suffix' => 'AudioCore V1.5.1', 'seo_meta_description' => 'Independent catalog platform for artists, releases, store, and support.', 'smtp_host' => '', 'smtp_port' => '587', @@ -1038,7 +1052,7 @@ class AdminController 'smtp_pass' => '', 'smtp_encryption' => 'tls', 'smtp_from_email' => '', - 'smtp_from_name' => 'AudioCore V1.5', + 'smtp_from_name' => 'AudioCore V1.5.1', ]; } @@ -1664,6 +1678,24 @@ class AdminController return Database::get() instanceof PDO; } + private function appInstalled(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + + $this->ensureCoreTables(); + + try { + $adminUsers = (int)$db->query("SELECT COUNT(*) FROM ac_admin_users")->fetchColumn(); + $legacyAdmins = (int)$db->query("SELECT COUNT(*) FROM ac_admins")->fetchColumn(); + return ($adminUsers + $legacyAdmins) > 0; + } catch (Throwable $e) { + return false; + } + } + private function normalizeUrl(string $url): string { if (preg_match('~^(https?://|/|#|mailto:)~i', $url)) { diff --git a/modules/admin/views/installer.php b/modules/admin/views/installer.php index 46bb554..19253b3 100644 --- a/modules/admin/views/installer.php +++ b/modules/admin/views/installer.php @@ -136,7 +136,7 @@ ob_start();
Setup
-

AudioCore V1.5 Installer

+

AudioCore V1.5.1 Installer

Deploy a fresh instance with validated SMTP and baseline health checks.

@@ -201,7 +201,7 @@ ob_start();
- +
@@ -209,7 +209,7 @@ ob_start();
- +
@@ -244,7 +244,7 @@ ob_start();
- +
diff --git a/modules/admin/views/layout.php b/modules/admin/views/layout.php index deb7b1c..b3bf031 100644 --- a/modules/admin/views/layout.php +++ b/modules/admin/views/layout.php @@ -1,638 +1,643 @@ - false, 'update_available' => false]; -if ($isAuthed) { - try { - $updateStatus = Updater::getStatus(false); - } catch (Throwable $e) { - $updateStatus = ['ok' => false, 'update_available' => false]; - } -} -?> - - - - - - <?= htmlspecialchars($pageTitle ?? 'Admin', ENT_QUOTES, 'UTF-8') ?> - - - - - - - - - -
-
- -
+ + + + + + + + + + + + + + + + +
+ +
+
+
+
+
AC
+
+
AudioCore Admin
+
V1.5.1
+
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+ + + + diff --git a/modules/admin/views/settings.php b/modules/admin/views/settings.php index c588064..68d4bc3 100644 --- a/modules/admin/views/settings.php +++ b/modules/admin/views/settings.php @@ -32,7 +32,7 @@ ob_start();
Branding
- + @@ -120,7 +120,7 @@ ob_start();
Footer
- +
Shown in the site footer.
Footer Links
@@ -158,6 +158,17 @@ ob_start();
+
+
Visitor Access Password
+
+ Set a password to let non-admin users unlock the site during maintenance mode. Leave blank to keep the current password. +
+ + +
@@ -218,7 +229,7 @@ ob_start();
Global SEO
- + diff --git a/modules/api/ApiController.php b/modules/api/ApiController.php new file mode 100644 index 0000000..b544e77 --- /dev/null +++ b/modules/api/ApiController.php @@ -0,0 +1,760 @@ +view = new View(__DIR__ . '/views'); + } + + public function adminIndex(): Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin'])) { + return new Response('', 302, ['Location' => '/admin']); + } + + $db = Database::get(); + $clients = []; + $createdKey = (string)($_SESSION['ac_api_created_key'] ?? ''); + unset($_SESSION['ac_api_created_key']); + + if ($db instanceof PDO) { + ApiLayer::ensureSchema($db); + try { + $stmt = $db->query(" + SELECT id, name, api_key_prefix, webhook_url, is_active, last_used_at, last_used_ip, created_at + FROM ac_api_clients + ORDER BY created_at DESC, id DESC + "); + $clients = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; + } catch (Throwable $e) { + $clients = []; + } + } + + return new Response($this->view->render('admin/index.php', [ + 'title' => 'API', + 'clients' => $clients, + 'created_key' => $createdKey, + 'status' => trim((string)($_GET['status'] ?? '')), + 'message' => trim((string)($_GET['message'] ?? '')), + 'base_url' => $this->baseUrl(), + ])); + } + + public function adminCreateClient(): Response + { + if ($guard = $this->guardAdmin()) { + return $guard; + } + + $name = trim((string)($_POST['name'] ?? '')); + $webhookUrl = trim((string)($_POST['webhook_url'] ?? '')); + if ($name === '') { + return $this->adminRedirect('error', 'Client name is required.'); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->adminRedirect('error', 'Database unavailable.'); + } + + try { + $plainKey = ApiLayer::issueClient($db, $name, $webhookUrl); + $_SESSION['ac_api_created_key'] = $plainKey; + return $this->adminRedirect('ok', 'API client created. Copy the key now.'); + } catch (Throwable $e) { + return $this->adminRedirect('error', 'Unable to create API client.'); + } + } + + public function adminToggleClient(): Response + { + if ($guard = $this->guardAdmin()) { + return $guard; + } + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + return $this->adminRedirect('error', 'Invalid client.'); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->adminRedirect('error', 'Database unavailable.'); + } + + try { + $stmt = $db->prepare(" + UPDATE ac_api_clients + SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END + WHERE id = :id + "); + $stmt->execute([':id' => $id]); + return $this->adminRedirect('ok', 'API client updated.'); + } catch (Throwable $e) { + return $this->adminRedirect('error', 'Unable to update API client.'); + } + } + + public function adminDeleteClient(): Response + { + if ($guard = $this->guardAdmin()) { + return $guard; + } + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + return $this->adminRedirect('error', 'Invalid client.'); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->adminRedirect('error', 'Database unavailable.'); + } + + try { + $stmt = $db->prepare("DELETE FROM ac_api_clients WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + return $this->adminRedirect('ok', 'API client deleted.'); + } catch (Throwable $e) { + return $this->adminRedirect('error', 'Unable to delete API client.'); + } + } + + public function authVerify(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + return $this->json([ + 'ok' => true, + 'client' => [ + 'id' => (int)($client['id'] ?? 0), + 'name' => (string)($client['name'] ?? ''), + 'prefix' => (string)($client['api_key_prefix'] ?? ''), + ], + ]); + } + + public function artistSales(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $artistId = (int)($_GET['artist_id'] ?? 0); + if ($artistId <= 0) { + return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + ApiLayer::ensureSchema($db); + + try { + $stmt = $db->prepare(" + SELECT + a.id, + a.order_id, + o.order_no, + a.order_item_id, + a.source_item_type, + a.source_item_id, + a.release_id, + a.track_id, + a.title_snapshot, + a.qty, + a.gross_amount, + a.currency_snapshot, + a.created_at, + o.email AS customer_email, + o.payment_provider, + o.payment_ref + FROM ac_store_order_item_allocations a + JOIN ac_store_orders o ON o.id = a.order_id + WHERE a.artist_id = :artist_id + AND o.status = 'paid' + ORDER BY a.id DESC + LIMIT 500 + "); + $stmt->execute([':artist_id' => $artistId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + return $this->json([ + 'ok' => true, + 'artist_id' => $artistId, + 'count' => count($rows), + 'rows' => $rows, + ]); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to load artist sales.'], 500); + } + } + + public function salesSince(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $artistId = (int)($_GET['artist_id'] ?? 0); + $afterId = (int)($_GET['after_id'] ?? 0); + $since = trim((string)($_GET['since'] ?? '')); + if ($artistId <= 0) { + return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422); + } + if ($afterId <= 0 && $since === '') { + return $this->json(['ok' => false, 'error' => 'after_id or since is required.'], 422); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + ApiLayer::ensureSchema($db); + + try { + $where = [ + 'a.artist_id = :artist_id', + "o.status = 'paid'", + ]; + $params = [':artist_id' => $artistId]; + if ($afterId > 0) { + $where[] = 'a.id > :after_id'; + $params[':after_id'] = $afterId; + } + if ($since !== '') { + $where[] = 'a.created_at >= :since'; + $params[':since'] = $since; + } + + $stmt = $db->prepare(" + SELECT + a.id, + a.order_id, + o.order_no, + a.order_item_id, + a.source_item_type, + a.source_item_id, + a.release_id, + a.track_id, + a.title_snapshot, + a.qty, + a.gross_amount, + a.currency_snapshot, + a.created_at + FROM ac_store_order_item_allocations a + JOIN ac_store_orders o ON o.id = a.order_id + WHERE " . implode(' AND ', $where) . " + ORDER BY a.id ASC + LIMIT 500 + "); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + return $this->json([ + 'ok' => true, + 'artist_id' => $artistId, + 'count' => count($rows), + 'rows' => $rows, + ]); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to load incremental sales.'], 500); + } + } + + public function artistTracks(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $artistId = (int)($_GET['artist_id'] ?? 0); + if ($artistId <= 0) { + return $this->json(['ok' => false, 'error' => 'artist_id is required.'], 422); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + ApiLayer::ensureSchema($db); + + try { + $stmt = $db->prepare(" + SELECT + t.id, + t.release_id, + r.title AS release_title, + r.slug AS release_slug, + r.catalog_no, + r.release_date, + t.track_no, + t.title, + t.mix_name, + t.duration, + t.bpm, + t.key_signature, + t.sample_url, + sp.is_enabled AS store_enabled, + sp.track_price, + sp.currency + FROM ac_release_tracks t + JOIN ac_releases r ON r.id = t.release_id + LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id + WHERE r.artist_id = :artist_id + ORDER BY r.release_date DESC, r.id DESC, t.track_no ASC, t.id ASC + "); + $stmt->execute([':artist_id' => $artistId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + return $this->json([ + 'ok' => true, + 'artist_id' => $artistId, + 'count' => count($rows), + 'tracks' => $rows, + ]); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to load artist tracks.'], 500); + } + } + + public function submitRelease(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + ApiLayer::ensureSchema($db); + + $data = $this->requestData(); + $title = trim((string)($data['title'] ?? '')); + if ($title === '') { + return $this->json(['ok' => false, 'error' => 'title is required.'], 422); + } + + $artistId = (int)($data['artist_id'] ?? 0); + $artistName = trim((string)($data['artist_name'] ?? '')); + if ($artistId > 0) { + $artistName = $this->artistNameById($db, $artistId) ?: $artistName; + } + + $slug = $this->slugify((string)($data['slug'] ?? $title)); + try { + $stmt = $db->prepare(" + INSERT INTO ac_releases + (title, slug, artist_id, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published) + VALUES (:title, :slug, :artist_id, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published) + "); + $stmt->execute([ + ':title' => $title, + ':slug' => $slug, + ':artist_id' => $artistId > 0 ? $artistId : null, + ':artist_name' => $artistName !== '' ? $artistName : null, + ':description' => $this->nullableText($data['description'] ?? null), + ':credits' => $this->nullableText($data['credits'] ?? null), + ':catalog_no' => $this->nullableText($data['catalog_no'] ?? null), + ':release_date' => $this->nullableText($data['release_date'] ?? null), + ':cover_url' => $this->nullableText($data['cover_url'] ?? null), + ':sample_url' => $this->nullableText($data['sample_url'] ?? null), + ':is_published' => !empty($data['is_published']) ? 1 : 0, + ]); + $releaseId = (int)$db->lastInsertId(); + return $this->json([ + 'ok' => true, + 'release_id' => $releaseId, + 'slug' => $slug, + ], 201); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to create release.'], 500); + } + } + + public function submitTracks(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + ApiLayer::ensureSchema($db); + + $data = $this->requestData(); + $releaseId = (int)($data['release_id'] ?? 0); + $tracks = is_array($data['tracks'] ?? null) ? $data['tracks'] : []; + if ($releaseId <= 0 || !$tracks) { + return $this->json(['ok' => false, 'error' => 'release_id and tracks are required.'], 422); + } + + $created = []; + try { + foreach ($tracks as $track) { + if (!is_array($track)) { + continue; + } + $trackId = (int)($track['id'] ?? 0); + $title = trim((string)($track['title'] ?? '')); + if ($title === '') { + continue; + } + if ($trackId > 0) { + $stmt = $db->prepare(" + UPDATE ac_release_tracks + SET track_no = :track_no, title = :title, mix_name = :mix_name, duration = :duration, + bpm = :bpm, key_signature = :key_signature, sample_url = :sample_url + WHERE id = :id AND release_id = :release_id + "); + $stmt->execute([ + ':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null, + ':title' => $title, + ':mix_name' => $this->nullableText($track['mix_name'] ?? null), + ':duration' => $this->nullableText($track['duration'] ?? null), + ':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null, + ':key_signature' => $this->nullableText($track['key_signature'] ?? null), + ':sample_url' => $this->nullableText($track['sample_url'] ?? null), + ':id' => $trackId, + ':release_id' => $releaseId, + ]); + $created[] = ['id' => $trackId, 'updated' => true]; + } else { + $stmt = $db->prepare(" + INSERT INTO ac_release_tracks + (release_id, track_no, title, mix_name, duration, bpm, key_signature, sample_url) + VALUES (:release_id, :track_no, :title, :mix_name, :duration, :bpm, :key_signature, :sample_url) + "); + $stmt->execute([ + ':release_id' => $releaseId, + ':track_no' => isset($track['track_no']) ? (int)$track['track_no'] : null, + ':title' => $title, + ':mix_name' => $this->nullableText($track['mix_name'] ?? null), + ':duration' => $this->nullableText($track['duration'] ?? null), + ':bpm' => isset($track['bpm']) && $track['bpm'] !== '' ? (int)$track['bpm'] : null, + ':key_signature' => $this->nullableText($track['key_signature'] ?? null), + ':sample_url' => $this->nullableText($track['sample_url'] ?? null), + ]); + $created[] = ['id' => (int)$db->lastInsertId(), 'created' => true]; + } + } + return $this->json([ + 'ok' => true, + 'release_id' => $releaseId, + 'tracks' => $created, + ], 201); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to submit tracks.'], 500); + } + } + + public function updateRelease(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + ApiLayer::ensureSchema($db); + $data = $this->requestData(); + $releaseId = (int)($data['release_id'] ?? 0); + if ($releaseId <= 0) { + return $this->json(['ok' => false, 'error' => 'release_id is required.'], 422); + } + + $fields = []; + $params = [':id' => $releaseId]; + $map = [ + 'title' => 'title', + 'description' => 'description', + 'credits' => 'credits', + 'catalog_no' => 'catalog_no', + 'release_date' => 'release_date', + 'cover_url' => 'cover_url', + 'sample_url' => 'sample_url', + 'is_published' => 'is_published', + ]; + foreach ($map as $input => $column) { + if (!array_key_exists($input, $data)) { + continue; + } + $fields[] = "{$column} = :{$input}"; + $params[":{$input}"] = $input === 'is_published' + ? (!empty($data[$input]) ? 1 : 0) + : $this->nullableText($data[$input]); + } + if (array_key_exists('slug', $data)) { + $fields[] = "slug = :slug"; + $params[':slug'] = $this->slugify((string)$data['slug']); + } + if (array_key_exists('artist_id', $data) || array_key_exists('artist_name', $data)) { + $artistId = (int)($data['artist_id'] ?? 0); + $artistName = trim((string)($data['artist_name'] ?? '')); + if ($artistId > 0) { + $artistName = $this->artistNameById($db, $artistId) ?: $artistName; + } + $fields[] = "artist_id = :artist_id"; + $fields[] = "artist_name = :artist_name"; + $params[':artist_id'] = $artistId > 0 ? $artistId : null; + $params[':artist_name'] = $artistName !== '' ? $artistName : null; + } + if (!$fields) { + return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422); + } + + try { + $stmt = $db->prepare("UPDATE ac_releases SET " . implode(', ', $fields) . " WHERE id = :id"); + $stmt->execute($params); + return $this->json(['ok' => true, 'release_id' => $releaseId]); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to update release.'], 500); + } + } + + public function updateTrack(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + + $data = $this->requestData(); + $trackId = (int)($data['track_id'] ?? 0); + if ($trackId <= 0) { + return $this->json(['ok' => false, 'error' => 'track_id is required.'], 422); + } + + $fields = []; + $params = [':id' => $trackId]; + $map = [ + 'track_no' => 'track_no', + 'title' => 'title', + 'mix_name' => 'mix_name', + 'duration' => 'duration', + 'bpm' => 'bpm', + 'key_signature' => 'key_signature', + 'sample_url' => 'sample_url', + ]; + foreach ($map as $input => $column) { + if (!array_key_exists($input, $data)) { + continue; + } + $fields[] = "{$column} = :{$input}"; + if ($input === 'bpm' || $input === 'track_no') { + $params[":{$input}"] = $data[$input] !== '' ? (int)$data[$input] : null; + } else { + $params[":{$input}"] = $this->nullableText($data[$input]); + } + } + if (!$fields) { + return $this->json(['ok' => false, 'error' => 'No fields to update.'], 422); + } + + try { + $stmt = $db->prepare("UPDATE ac_release_tracks SET " . implode(', ', $fields) . " WHERE id = :id"); + $stmt->execute($params); + return $this->json(['ok' => true, 'track_id' => $trackId]); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to update track.'], 500); + } + } + + public function orderItemData(): Response + { + $client = $this->requireClient(); + if ($client instanceof Response) { + return $client; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + ApiLayer::ensureSchema($db); + + $artistId = (int)($_GET['artist_id'] ?? 0); + $orderId = (int)($_GET['order_id'] ?? 0); + $afterId = (int)($_GET['after_id'] ?? 0); + $since = trim((string)($_GET['since'] ?? '')); + + try { + $where = ['1=1']; + $params = []; + if ($artistId > 0) { + $where[] = 'a.artist_id = :artist_id'; + $params[':artist_id'] = $artistId; + } + if ($orderId > 0) { + $where[] = 'a.order_id = :order_id'; + $params[':order_id'] = $orderId; + } + if ($afterId > 0) { + $where[] = 'a.id > :after_id'; + $params[':after_id'] = $afterId; + } + if ($since !== '') { + $where[] = 'a.created_at >= :since'; + $params[':since'] = $since; + } + + $stmt = $db->prepare(" + SELECT + a.id, + a.order_id, + o.order_no, + o.status AS order_status, + o.email AS customer_email, + a.order_item_id, + a.artist_id, + a.release_id, + a.track_id, + a.source_item_type, + a.source_item_id, + a.title_snapshot, + a.qty, + a.gross_amount, + a.currency_snapshot, + a.created_at + FROM ac_store_order_item_allocations a + JOIN ac_store_orders o ON o.id = a.order_id + WHERE " . implode(' AND ', $where) . " + ORDER BY a.id DESC + LIMIT 1000 + "); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + return $this->json([ + 'ok' => true, + 'count' => count($rows), + 'rows' => $rows, + ]); + } catch (Throwable $e) { + return $this->json(['ok' => false, 'error' => 'Unable to load order item data.'], 500); + } + } + + private function requestData(): array + { + $contentType = strtolower(trim((string)($_SERVER['CONTENT_TYPE'] ?? ''))); + if (str_contains($contentType, 'application/json')) { + $raw = file_get_contents('php://input'); + $decoded = json_decode(is_string($raw) ? $raw : '', true); + return is_array($decoded) ? $decoded : []; + } + return is_array($_POST) ? $_POST : []; + } + + private function requireClient(): array|Response + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->json(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + $client = ApiLayer::verifyRequest($db); + if (!$client) { + return $this->json(['ok' => false, 'error' => 'Invalid API key.'], 401); + } + return $client; + } + + private function json(array $data, int $status = 200): Response + { + $json = json_encode($data, JSON_UNESCAPED_SLASHES); + return new Response( + is_string($json) ? $json : '{"ok":false,"error":"json_encode_failed"}', + $status, + ['Content-Type' => 'application/json; charset=utf-8'] + ); + } + + private function slugify(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? $value; + $value = trim($value, '-'); + return $value !== '' ? $value : 'release'; + } + + private function nullableText(mixed $value): ?string + { + $text = trim((string)$value); + return $text !== '' ? $text : null; + } + + private function artistNameById(PDO $db, int $artistId): string + { + if ($artistId <= 0) { + return ''; + } + try { + $stmt = $db->prepare("SELECT name FROM ac_artists WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $artistId]); + return trim((string)($stmt->fetchColumn() ?: '')); + } catch (Throwable $e) { + return ''; + } + } + + private function adminRedirect(string $status, string $message): Response + { + return new Response('', 302, [ + 'Location' => '/admin/api?status=' . rawurlencode($status) . '&message=' . rawurlencode($message), + ]); + } + + private function baseUrl(): string + { + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') + || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443'); + $scheme = $https ? 'https' : 'http'; + $host = trim((string)($_SERVER['HTTP_HOST'] ?? '')); + return $host !== '' ? ($scheme . '://' . $host) : ''; + } + + private function guardAdmin(): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin'])) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } +} diff --git a/modules/api/module.php b/modules/api/module.php new file mode 100644 index 0000000..47496a8 --- /dev/null +++ b/modules/api/module.php @@ -0,0 +1,26 @@ +get('/admin/api', [$controller, 'adminIndex']); + $router->post('/admin/api/clients/create', [$controller, 'adminCreateClient']); + $router->post('/admin/api/clients/toggle', [$controller, 'adminToggleClient']); + $router->post('/admin/api/clients/delete', [$controller, 'adminDeleteClient']); + + $router->get('/api/v1/auth/verify', [$controller, 'authVerify']); + $router->get('/api/v1/sales', [$controller, 'artistSales']); + $router->get('/api/v1/sales/since', [$controller, 'salesSince']); + $router->get('/api/v1/tracks', [$controller, 'artistTracks']); + $router->get('/api/v1/order-items', [$controller, 'orderItemData']); + $router->post('/api/v1/releases', [$controller, 'submitRelease']); + $router->post('/api/v1/tracks', [$controller, 'submitTracks']); + $router->post('/api/v1/releases/update', [$controller, 'updateRelease']); + $router->post('/api/v1/tracks/update', [$controller, 'updateTrack']); +}; diff --git a/modules/api/views/admin/index.php b/modules/api/views/admin/index.php new file mode 100644 index 0000000..21b8340 --- /dev/null +++ b/modules/api/views/admin/index.php @@ -0,0 +1,189 @@ + 'Verify key', + 'method' => 'GET', + 'path' => $baseUrl . '/api/v1/auth/verify', + 'note' => 'Quick auth check for AMS bootstrapping.', + ], + [ + 'title' => 'Artist sales', + 'method' => 'GET', + 'path' => $baseUrl . '/api/v1/sales?artist_id=123', + 'note' => 'Detailed paid sales rows for one artist.', + ], + [ + 'title' => 'Sales since sync', + 'method' => 'GET', + 'path' => $baseUrl . '/api/v1/sales/since?artist_id=123&after_id=500', + 'note' => 'Incremental sync using after_id or timestamp.', + ], + [ + 'title' => 'Artist tracks', + 'method' => 'GET', + 'path' => $baseUrl . '/api/v1/tracks?artist_id=123', + 'note' => 'Track list tied to one artist account.', + ], + [ + 'title' => 'Order item detail', + 'method' => 'GET', + 'path' => $baseUrl . '/api/v1/order-items?artist_id=123', + 'note' => 'Granular order transparency for AMS reporting.', + ], + [ + 'title' => 'Submit release / tracks', + 'method' => 'POST', + 'path' => $baseUrl . '/api/v1/releases + /api/v1/tracks', + 'note' => 'AMS pushes approved releases and tracks into AudioCore.', + ], +]; + +ob_start(); +?> +
+
Integration
+
+

API

+

AMS integration endpoints, API keys, and sales sync access live here. Keep this page operational rather than decorative: create clients, issue keys, and hand exact endpoints to the AMS.

+
+ + +
+ +
+ + + +
+
+
+
New API Key
+
Copy this now. It is only shown once.
+
+ +
+ +
+ + +
+
+
Clients
+
+
Configured external systems
+
+
+
Auth
+
Bearer
+
Also accepts X-API-Key
+
+
+
Webhook
+
sale.paid
+
Outbound sale notifications for AMS sync
+
+
+ +
+
+
+
Create API Client
+
Use one client per AMS install or per integration target. That keeps revocation clean and usage attribution obvious.
+
+
+
+
+ + + +
+
+
+ +
+
+
+
Endpoint Reference
+
Keep this as an operator reference. The layout is stacked because this panel needs readability more than compression.
+
+
Use Authorization: Bearer <api-key> or X-API-Key.
+
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+
+ +
+
+
+
Active Clients
+
Disable a client to cut access immediately. Delete only when you do not need audit visibility anymore.
+
+
+ +
No API clients created yet.
+ +
+ +
+
+
+ + + + +
+
+
Key prefix...
+
Last used
+
Last IP
+
+ +
+ Webhook: +
+ +
+
+
+ + +
+
+ + +
+
+
+ +
+ +
+
+format('d M Y'); + } catch (Throwable $e) { + return $value; + } +}; + +$renderTags = static function (string $tags): array { + return array_values(array_filter(array_map('trim', explode(',', $tags)))); +}; + ob_start(); ?> -
-
News
- -
- +
+ +
+
- -

Latest Updates

-

News, updates, and announcements.

-
- -
No posts yet.
- + +
- +
+
+ + + +
+

+ +

+ + + +
+ + + +
+ +
+ - -
+
+ +
No published posts yet.
+
+ + -
-
News
-

- -
- - - - - · - - - - · - - +format('d M Y'); + } catch (Throwable $e) { + $publishedDisplay = $publishedAt; + } +} +ob_start(); +?> +
+
+ + +
+
+ +
- - - - -
-
- -
- - - - - -
-
- + .article-shell-fluid { + padding: 0; + } + .article-fluid-grid { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 0; + align-items: start; + min-height: 100%; + } + .article-fluid-meta { + display: grid; + gap: 18px; + align-content: start; + min-height: 100%; + padding: 32px 24px; + } + .article-fluid-title { + margin: 0; + color: #f5f7ff; + font-size: clamp(34px, 3vw, 56px); + line-height: .94; + word-break: break-word; + } + .article-fluid-meta-list { + display: grid; + gap: 14px; + } + .article-fluid-meta-item span { + display: block; + margin-bottom: 6px; + color: rgba(255,255,255,.56); + font-family: 'IBM Plex Mono', monospace; + font-size: 10px; + letter-spacing: .18em; + text-transform: uppercase; + } + .article-fluid-meta-item strong { + color: #e8eefc; + font-size: 15px; + font-weight: 600; + } + .article-fluid-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .article-fluid-tags span { + display: inline-flex; + align-items: center; + padding: 7px 10px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,.12); + background: rgba(255,255,255,.04); + color: #dce5f6; + font-size: 11px; + } + .article-fluid-back { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid rgba(255,255,255,.14); + background: rgba(255,255,255,.04); + color: #eef4ff; + text-decoration: none; + font-family: 'IBM Plex Mono', monospace; + font-size: 11px; + letter-spacing: .16em; + text-transform: uppercase; + } + .article-fluid-back:hover { + border-color: rgba(34,242,165,.36); + color: #9ff8d8; + } + .article-fluid-content-shell { + min-width: 0; + min-height: 100%; + margin: 0; + padding: 40px 44px; + border-radius: 0 28px 28px 0; + background: #f4f1ec; + box-shadow: none; + } + .article-fluid-post-box { + min-width: 0; + padding: 0; + color: #26272d; + } + .article-fluid-post-box, + .article-fluid-post-box p, + .article-fluid-post-box li, + .article-fluid-post-box blockquote, + .article-fluid-post-box figcaption, + .article-fluid-post-box td, + .article-fluid-post-box th { + color: #3a3d45; + } + .article-fluid-post-box h1, + .article-fluid-post-box h2, + .article-fluid-post-box h3, + .article-fluid-post-box h4, + .article-fluid-post-box h5, + .article-fluid-post-box h6, + .article-fluid-post-box strong { + color: #202228; + } + .article-fluid-post-box, + .article-fluid-post-box > *, + .article-fluid-post-box > * > *, + .article-fluid-post-box > * > * > * { + max-width: none !important; + } + .article-fluid-post-box > * { + width: 100% !important; + margin: 0 !important; + } + .article-fluid-post-box > div, + .article-fluid-post-box > section, + .article-fluid-post-box > article, + .article-fluid-post-box > main, + .article-fluid-post-box > figure { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + } + .article-fluid-post-box * { + margin-left: 0 !important; + margin-right: 0 !important; + } + .article-fluid-post-box [style*="max-width"], + .article-fluid-post-box [style*="width"], + .article-fluid-post-box [style*="margin: auto"], + .article-fluid-post-box [style*="margin-left:auto"], + .article-fluid-post-box [style*="margin-right:auto"] { + max-width: none !important; + width: 100% !important; + margin-left: 0 !important; + margin-right: 0 !important; + } + .article-fluid-post-box > div, + .article-fluid-post-box > section, + .article-fluid-post-box > article, + .article-fluid-post-box > main, + .article-fluid-post-box > figure, + .article-fluid-post-box > div > div:first-child { + background: transparent !important; + box-shadow: none !important; + border: 0 !important; + border-radius: 0 !important; + } + .article-fluid-post-box img { + max-width: 100%; + height: auto; + display: block; + } + .article-fluid-post-box iframe, + .article-fluid-post-box video, + .article-fluid-post-box table, + .article-fluid-post-box pre { + max-width: 100%; + } + @media (max-width: 980px) { + .article-fluid-grid { + grid-template-columns: 1fr; + gap: 20px; + } + .article-fluid-meta { + padding: 24px 24px 8px; + } + .article-fluid-content-shell { + margin: 0; + border-radius: 0 0 28px 28px; + } + } + @media (max-width: 720px) { + .article-shell-fluid { + padding: 0; + } + .article-fluid-content-shell { + padding: 28px 24px; + } + .article-fluid-post-box { + padding: 0; + } + .article-fluid-title { + font-size: 42px; + } + } + +clientIp()); + if (RateLimiter::tooMany('newsletter_subscribe', $limitKey, 8, 600)) { + return new Response('Too many requests. Please wait 10 minutes and try again.', 429); + } $db = Database::get(); if ($db instanceof PDO) { @@ -61,6 +69,16 @@ class NewsletterController { $email = trim((string)($_POST['email'] ?? '')); $status = 'Email is required.'; + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $limitKey = sha1(strtolower($email) . '|' . $this->clientIp()); + if (RateLimiter::tooMany('newsletter_unsubscribe', $limitKey, 8, 600)) { + return new Response($this->view->render('site/unsubscribe.php', [ + 'title' => 'Unsubscribe', + 'email' => $email, + 'status' => 'Too many unsubscribe attempts. Please wait 10 minutes.', + ]), 429); + } + } $db = Database::get(); if ($db instanceof PDO && $email !== '') { @@ -76,6 +94,24 @@ class NewsletterController ])); } + private function clientIp(): string + { + foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $key) { + $value = trim((string)($_SERVER[$key] ?? '')); + if ($value === '') { + continue; + } + if ($key === 'HTTP_X_FORWARDED_FOR') { + $parts = array_map('trim', explode(',', $value)); + $value = (string)($parts[0] ?? ''); + } + if ($value !== '') { + return substr($value, 0, 64); + } + } + return '0.0.0.0'; + } + public function adminIndex(): Response { if ($guard = $this->guard(['admin', 'manager'])) { diff --git a/plugins/advanced-reporting/ReportsController.php b/plugins/advanced-reporting/ReportsController.php new file mode 100644 index 0000000..8b27d95 --- /dev/null +++ b/plugins/advanced-reporting/ReportsController.php @@ -0,0 +1,536 @@ +view = new View(__DIR__ . '/views'); + } + + public function adminIndex(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Sales Reports', + 'tab' => 'overview', + 'filters' => $this->filters(), + 'overview' => [], + 'artist_rows' => [], + 'track_rows' => [], + 'artist_options' => [], + 'tables_ready' => false, + 'currency' => Settings::get('store_currency', 'GBP'), + ])); + } + + ApiLayer::ensureSchema($db); + $filters = $this->filters(); + $tab = $filters['tab']; + + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Sales Reports', + 'tab' => $tab, + 'filters' => $filters, + 'overview' => $this->overviewPayload($db, $filters), + 'artist_rows' => $tab === 'artists' ? $this->artistRows($db, $filters) : [], + 'track_rows' => $tab === 'tracks' ? $this->trackRows($db, $filters) : [], + 'artist_options' => $this->artistOptions($db), + 'tables_ready' => $this->tablesReady($db), + 'currency' => strtoupper(trim((string)Settings::get('store_currency', 'GBP'))), + ])); + } + + public function adminExport(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('Database unavailable', 500); + } + + ApiLayer::ensureSchema($db); + $filters = $this->filters(); + $type = strtolower(trim((string)($_GET['type'] ?? 'artists'))); + if (!in_array($type, ['artists', 'tracks'], true)) { + $type = 'artists'; + } + + $rows = $type === 'tracks' ? $this->trackRows($db, $filters) : $this->artistRows($db, $filters); + $stream = fopen('php://temp', 'w+'); + if ($stream === false) { + return new Response('Unable to create export', 500); + } + + if ($type === 'tracks') { + fputcsv($stream, ['Artist', 'Track', 'Release', 'Catalog', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Downloads']); + foreach ($rows as $row) { + fputcsv($stream, [ + (string)($row['artist_name'] ?? ''), + (string)($row['track_display'] ?? ''), + (string)($row['release_title'] ?? ''), + (string)($row['catalog_no'] ?? ''), + (int)($row['units_sold'] ?? 0), + (int)($row['units_refunded'] ?? 0), + number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''), + number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''), + number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''), + number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''), + number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''), + (int)($row['download_count'] ?? 0), + ]); + } + } else { + fputcsv($stream, ['Artist', 'Paid Orders', 'Units Sold', 'Units Refunded', 'Gross Revenue', 'Refunded Revenue', 'Net Revenue', 'PayPal Fees', 'Net After Fees', 'Releases', 'Tracks']); + foreach ($rows as $row) { + fputcsv($stream, [ + (string)($row['artist_name'] ?? ''), + (int)($row['paid_orders'] ?? 0), + (int)($row['units_sold'] ?? 0), + (int)($row['units_refunded'] ?? 0), + number_format((float)($row['gross_revenue'] ?? 0), 2, '.', ''), + number_format((float)($row['refunded_revenue'] ?? 0), 2, '.', ''), + number_format((float)($row['net_revenue'] ?? 0), 2, '.', ''), + number_format((float)($row['payment_fees'] ?? 0), 2, '.', ''), + number_format((float)($row['net_after_fees'] ?? 0), 2, '.', ''), + (int)($row['release_count'] ?? 0), + (int)($row['track_count'] ?? 0), + ]); + } + } + + rewind($stream); + $csv = stream_get_contents($stream); + fclose($stream); + + return new Response((string)$csv, 200, [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Content-Disposition' => 'attachment; filename="sales-report-' . $type . '-' . gmdate('Ymd-His') . '.csv"', + ]); + } + + private function guard(): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } + + private function filters(): array + { + $tab = strtolower(trim((string)($_GET['tab'] ?? 'overview'))); + if (!in_array($tab, ['overview', 'artists', 'tracks'], true)) { + $tab = 'overview'; + } + + return [ + 'tab' => $tab, + 'from' => trim((string)($_GET['from'] ?? '')), + 'to' => trim((string)($_GET['to'] ?? '')), + 'q' => trim((string)($_GET['q'] ?? '')), + 'artist_id' => max(0, (int)($_GET['artist_id'] ?? 0)), + ]; + } + + private function tablesReady(PDO $db): bool + { + try { + $probe = $db->query("SHOW TABLES LIKE 'ac_store_order_item_allocations'"); + return (bool)($probe && $probe->fetch(PDO::FETCH_NUM)); + } catch (Throwable $e) { + return false; + } + } + + private function overviewPayload(PDO $db, array $filters): array + { + $stats = [ + 'gross_revenue' => 0.0, + 'refunded_revenue' => 0.0, + 'net_revenue' => 0.0, + 'payment_fees' => 0.0, + 'net_after_fees' => 0.0, + 'paid_orders' => 0, + 'refunded_orders' => 0, + 'units_sold' => 0, + 'units_refunded' => 0, + 'top_artists' => [], + 'top_tracks' => [], + ]; + + try { + [$whereSql, $params] = $this->dateWhere($filters, 'o'); + $stmt = $db->prepare(" + SELECT + COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END), 0) AS gross_revenue, + COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN o.total ELSE 0 END), 0) AS refunded_revenue, + COALESCE(SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END), 0) AS payment_fees, + COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN o.id END) AS paid_orders, + COUNT(DISTINCT CASE WHEN o.status = 'refunded' THEN o.id END) AS refunded_orders + FROM ac_store_orders o + {$whereSql} + "); + $stmt->execute($params); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + $stats['gross_revenue'] = (float)($row['gross_revenue'] ?? 0); + $stats['refunded_revenue'] = (float)($row['refunded_revenue'] ?? 0); + $stats['payment_fees'] = (float)($row['payment_fees'] ?? 0); + $stats['net_revenue'] = $stats['gross_revenue'] - $stats['refunded_revenue']; + $stats['net_after_fees'] = $stats['net_revenue'] - $stats['payment_fees']; + $stats['paid_orders'] = (int)($row['paid_orders'] ?? 0); + $stats['refunded_orders'] = (int)($row['refunded_orders'] ?? 0); + } catch (Throwable $e) { + } + + try { + [$whereSql, $params] = $this->dateWhere($filters, 'o'); + $stmt = $db->prepare(" + SELECT + COALESCE(SUM(CASE WHEN o.status = 'paid' THEN a.qty ELSE 0 END), 0) AS units_sold, + COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN a.qty ELSE 0 END), 0) AS units_refunded + FROM ac_store_order_item_allocations a + JOIN ac_store_orders o ON o.id = a.order_id + {$whereSql} + "); + $stmt->execute($params); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + $stats['units_sold'] = (int)($row['units_sold'] ?? 0); + $stats['units_refunded'] = (int)($row['units_refunded'] ?? 0); + } catch (Throwable $e) { + } + + $topFilters = $filters; + $topFilters['limit'] = 5; + $stats['top_artists'] = $this->artistRows($db, $topFilters); + $stats['top_tracks'] = $this->trackRows($db, $topFilters); + + return $stats; + } + + private function artistRows(PDO $db, array $filters): array + { + [$whereSql, $params] = $this->dateWhere($filters, 'o'); + if (!empty($filters['artist_id'])) { + $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'alloc.artist_id = :artist_id'; + $params[':artist_id'] = (int)$filters['artist_id']; + } + $q = trim((string)($filters['q'] ?? '')); + if ($q !== '') { + $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . " (ar.name LIKE :q OR r.artist_name LIKE :q OR alloc.title_snapshot LIKE :q)"; + $params[':q'] = '%' . $q . '%'; + } + $limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : ''; + + try { + $stmt = $db->prepare(" + SELECT + COALESCE(alloc.artist_id, 0) AS artist_id, + COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name, + COUNT(DISTINCT CASE WHEN o.status = 'paid' THEN alloc.order_id END) AS paid_orders, + COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.qty ELSE 0 END), 0) AS units_sold, + COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.qty ELSE 0 END), 0) AS units_refunded, + COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) AS gross_revenue, + COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0) AS refunded_revenue, + COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0) AS payment_fees, + COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.release_id IS NOT NULL THEN alloc.release_id END) AS release_count, + COUNT(DISTINCT CASE WHEN o.status = 'paid' AND alloc.track_id IS NOT NULL THEN alloc.track_id END) AS track_count + FROM ac_store_order_item_allocations alloc + JOIN ac_store_orders o ON o.id = alloc.order_id + LEFT JOIN ac_artists ar ON ar.id = alloc.artist_id + LEFT JOIN ac_releases r ON r.id = alloc.release_id + {$whereSql} + GROUP BY COALESCE(alloc.artist_id, 0) + ORDER BY + ((COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN o.status = 'refunded' THEN alloc.gross_amount ELSE 0 END), 0)) - COALESCE(SUM(CASE WHEN o.status = 'paid' AND COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0), 0) > 0 THEN COALESCE(o.payment_fee, 0) * (alloc.gross_amount / COALESCE(NULLIF(o.payment_gross, 0), NULLIF(o.total, 0))) ELSE 0 END), 0)) DESC, + COALESCE(SUM(CASE WHEN o.status = 'paid' THEN alloc.gross_amount ELSE 0 END), 0) DESC, + artist_name ASC + {$limitSql} + "); + $stmt->execute($params); + $rows = array_values(array_filter($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [], static function (array $row): bool { + return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0; + })); + foreach ($rows as &$row) { + $row['payment_fees'] = (float)($row['payment_fees'] ?? 0); + $row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0); + $row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees']; + } + unset($row); + return $rows; + } catch (Throwable $e) { + return []; + } + } + + private function trackRows(PDO $db, array $filters): array + { + [$whereSql, $params] = $this->dateWhere($filters, 'o'); + if (!empty($filters['artist_id'])) { + $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . 'COALESCE(alloc.artist_id, r.artist_id) = :artist_id'; + $params[':artist_id'] = (int)$filters['artist_id']; + } + $q = trim((string)($filters['q'] ?? '')); + if ($q !== '') { + $whereSql .= ($whereSql === '' ? ' WHERE ' : ' AND ') . "(t.title LIKE :q OR t.mix_name LIKE :q OR ar.name LIKE :q OR r.title LIKE :q OR r.catalog_no LIKE :q)"; + $params[':q'] = '%' . $q . '%'; + } + $limitSql = !empty($filters['limit']) ? ' LIMIT ' . max(1, (int)$filters['limit']) : ''; + + try { + $stmt = $db->prepare(" + SELECT + alloc.id, + alloc.order_id, + alloc.track_id, + alloc.release_id, + alloc.artist_id, + alloc.qty, + alloc.gross_amount, + o.status, + COALESCE(o.payment_fee, 0) AS payment_fee, + COALESCE(o.payment_gross, 0) AS payment_gross, + COALESCE(o.total, 0) AS order_total, + COALESCE(NULLIF(MAX(t.title), ''), MAX(alloc.title_snapshot), 'Track') AS track_title, + COALESCE(NULLIF(MAX(t.mix_name), ''), '') AS mix_name, + COALESCE(NULLIF(MAX(ar.name), ''), NULLIF(MAX(r.artist_name), ''), 'Unknown Artist') AS artist_name, + COALESCE(NULLIF(MAX(r.title), ''), 'Release') AS release_title, + COALESCE(NULLIF(MAX(r.catalog_no), ''), '') AS catalog_no + FROM ac_store_order_item_allocations alloc + JOIN ac_store_orders o ON o.id = alloc.order_id + LEFT JOIN ac_release_tracks t ON t.id = alloc.track_id + LEFT JOIN ac_releases r ON r.id = COALESCE(alloc.release_id, t.release_id) + LEFT JOIN ac_artists ar ON ar.id = COALESCE(alloc.artist_id, r.artist_id) + {$whereSql} + GROUP BY alloc.id + ORDER BY alloc.id DESC + "); + $stmt->execute($params); + $allocations = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + $rows = []; + foreach ($allocations as $alloc) { + $releaseId = (int)($alloc['release_id'] ?? 0); + $trackId = (int)($alloc['track_id'] ?? 0); + if ($trackId > 0) { + $targets = [[ + 'track_id' => $trackId, + 'artist_name' => (string)($alloc['artist_name'] ?? 'Unknown Artist'), + 'release_title' => (string)($alloc['release_title'] ?? 'Release'), + 'catalog_no' => (string)($alloc['catalog_no'] ?? ''), + 'track_title' => (string)($alloc['track_title'] ?? 'Track'), + 'mix_name' => (string)($alloc['mix_name'] ?? ''), + 'weight' => 1.0, + ]]; + } elseif ($releaseId > 0) { + $targets = $this->releaseTracks($db, $releaseId, (string)($alloc['artist_name'] ?? 'Unknown Artist'), (string)($alloc['release_title'] ?? 'Release'), (string)($alloc['catalog_no'] ?? '')); + if (!$targets) { + continue; + } + } else { + continue; + } + + $totalWeight = 0.0; + foreach ($targets as $target) { + $totalWeight += max(0.0, (float)($target['weight'] ?? 0)); + } + if ($totalWeight <= 0) { + $totalWeight = (float)count($targets); + foreach ($targets as &$target) { + $target['weight'] = 1.0; + } + unset($target); + } + + foreach ($targets as $target) { + $key = (int)($target['track_id'] ?? 0); + if ($key <= 0) { + continue; + } + if (!isset($rows[$key])) { + $trackTitle = trim((string)($target['track_title'] ?? 'Track')); + $mixName = trim((string)($target['mix_name'] ?? '')); + $rows[$key] = [ + 'track_id' => $key, + 'artist_name' => (string)($target['artist_name'] ?? 'Unknown Artist'), + 'release_title' => (string)($target['release_title'] ?? 'Release'), + 'catalog_no' => (string)($target['catalog_no'] ?? ''), + 'track_title' => $trackTitle, + 'mix_name' => $mixName, + 'track_display' => $mixName !== '' ? $trackTitle . ' (' . $mixName . ')' : $trackTitle, + 'units_sold' => 0, + 'units_refunded' => 0, + 'gross_revenue' => 0.0, + 'refunded_revenue' => 0.0, + 'download_count' => $this->trackDownloadCount($db, $key), + 'payment_fees' => 0.0, + 'net_after_fees' => 0.0, + ]; + } + $share = max(0.0, (float)($target['weight'] ?? 0)) / $totalWeight; + $amount = (float)($alloc['gross_amount'] ?? 0) * $share; + $qty = (int)($alloc['qty'] ?? 0); + $feeBase = (float)($alloc['payment_gross'] ?? 0); + if ($feeBase <= 0) { + $feeBase = (float)($alloc['order_total'] ?? 0); + } + $feeShare = ((string)($alloc['status'] ?? '') === 'paid' && $feeBase > 0) + ? ((float)($alloc['payment_fee'] ?? 0) * (((float)($alloc['gross_amount'] ?? 0)) / $feeBase) * $share) + : 0.0; + if ((string)($alloc['status'] ?? '') === 'paid') { + $rows[$key]['units_sold'] += $qty; + $rows[$key]['gross_revenue'] += $amount; + $rows[$key]['payment_fees'] += $feeShare; + } elseif ((string)($alloc['status'] ?? '') === 'refunded') { + $rows[$key]['units_refunded'] += $qty; + $rows[$key]['refunded_revenue'] += $amount; + } + } + } + $rows = array_values(array_filter($rows, static function (array $row): bool { + return (float)($row['gross_revenue'] ?? 0) > 0 || (float)($row['refunded_revenue'] ?? 0) > 0; + })); + foreach ($rows as &$row) { + $row['payment_fees'] = (float)($row['payment_fees'] ?? 0); + $row['net_revenue'] = (float)($row['gross_revenue'] ?? 0) - (float)($row['refunded_revenue'] ?? 0); + $row['net_after_fees'] = $row['net_revenue'] - $row['payment_fees']; + } + unset($row); + usort($rows, static function (array $a, array $b): int { + $cmp = ((float)($b['net_revenue'] ?? 0)) <=> ((float)($a['net_revenue'] ?? 0)); + if ($cmp !== 0) { + return $cmp; + } + $cmp = ((float)($b['net_after_fees'] ?? 0)) <=> ((float)($a['net_after_fees'] ?? 0)); + if ($cmp !== 0) { + return $cmp; + } + return strcasecmp((string)($a['track_display'] ?? ''), (string)($b['track_display'] ?? '')); + }); + if (!empty($filters['limit'])) { + $rows = array_slice($rows, 0, max(1, (int)$filters['limit'])); + } + return $rows; + } catch (Throwable $e) { + return []; + } + } + + private function releaseTracks(PDO $db, int $releaseId, string $artistName, string $releaseTitle, string $catalogNo): array + { + if ($releaseId <= 0) { + return []; + } + if (isset($this->releaseTrackCache[$releaseId])) { + return $this->releaseTrackCache[$releaseId]; + } + try { + $stmt = $db->prepare(" + SELECT + t.id, + t.title, + COALESCE(t.mix_name, '') AS mix_name, + COALESCE(sp.track_price, 0) AS track_price + FROM ac_release_tracks t + LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1 + WHERE t.release_id = :release_id + ORDER BY t.track_no ASC, t.id ASC + "); + $stmt->execute([':release_id' => $releaseId]); + $tracks = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + $targets = []; + foreach ($tracks as $track) { + $targets[] = [ + 'track_id' => (int)($track['id'] ?? 0), + 'artist_name' => $artistName, + 'release_title' => $releaseTitle, + 'catalog_no' => $catalogNo, + 'track_title' => (string)($track['title'] ?? 'Track'), + 'mix_name' => (string)($track['mix_name'] ?? ''), + 'weight' => max(0.0, (float)($track['track_price'] ?? 0)), + ]; + } + $this->releaseTrackCache[$releaseId] = $targets; + return $targets; + } catch (Throwable $e) { + return []; + } + } + + private function trackDownloadCount(PDO $db, int $trackId): int + { + if ($trackId <= 0) { + return 0; + } + if (isset($this->trackDownloadCountCache[$trackId])) { + return $this->trackDownloadCountCache[$trackId]; + } + try { + $stmt = $db->prepare(" + SELECT COUNT(e.id) AS download_count + FROM ac_store_files f + LEFT JOIN ac_store_download_events e ON e.file_id = f.id + WHERE f.scope_type = 'track' AND f.scope_id = :track_id + "); + $stmt->execute([':track_id' => $trackId]); + $count = (int)(($stmt->fetch(PDO::FETCH_ASSOC) ?: [])['download_count'] ?? 0); + $this->trackDownloadCountCache[$trackId] = $count; + return $count; + } catch (Throwable $e) { + return 0; + } + } + + private function artistOptions(PDO $db): array + { + try { + $stmt = $db->query("SELECT id, name FROM ac_artists WHERE name IS NOT NULL AND name <> '' ORDER BY name ASC"); + return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; + } catch (Throwable $e) { + return []; + } + } + + private function dateWhere(array $filters, string $alias): array + { + $where = []; + $params = []; + $from = trim((string)($filters['from'] ?? '')); + $to = trim((string)($filters['to'] ?? '')); + if ($from !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) { + $where[] = $alias . '.created_at >= :from_date'; + $params[':from_date'] = $from . ' 00:00:00'; + } + if ($to !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { + $where[] = $alias . '.created_at <= :to_date'; + $params[':to_date'] = $to . ' 23:59:59'; + } + return [$where ? ' WHERE ' . implode(' AND ', $where) : '', $params]; + } +} diff --git a/plugins/advanced-reporting/plugin.json b/plugins/advanced-reporting/plugin.json new file mode 100644 index 0000000..f9ddec0 --- /dev/null +++ b/plugins/advanced-reporting/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "Advanced Reporting", + "version": "0.1.0", + "description": "Expanded sales reporting for the store layer.", + "author": "AudioCore", + "admin_nav": { + "label": "Sales Reports", + "url": "/admin/store/reports", + "roles": ["admin", "manager"], + "icon": "fa-solid fa-chart-line" + }, + "entry": "plugin.php", + "default_enabled": false +} diff --git a/plugins/advanced-reporting/plugin.php b/plugins/advanced-reporting/plugin.php new file mode 100644 index 0000000..c0ca9a2 --- /dev/null +++ b/plugins/advanced-reporting/plugin.php @@ -0,0 +1,13 @@ +get('/admin/store/reports', [$controller, 'adminIndex']); + $router->get('/admin/store/reports/export', [$controller, 'adminExport']); +}; diff --git a/plugins/advanced-reporting/views/admin/index.php b/plugins/advanced-reporting/views/admin/index.php new file mode 100644 index 0000000..f98fad9 --- /dev/null +++ b/plugins/advanced-reporting/views/admin/index.php @@ -0,0 +1,387 @@ + 'Overview', + 'artists' => 'Artists', + 'tracks' => 'Tracks', +]; +$activeTabLabel = $tabLabels[$tab] ?? 'Overview'; +$overviewGross = (float)($overview['gross_revenue'] ?? 0); +$overviewRefunded = (float)($overview['refunded_revenue'] ?? 0); +$overviewNet = (float)($overview['net_revenue'] ?? 0); +$overviewNetAfterFees = (float)($overview['net_after_fees'] ?? 0); +$selectedFrom = (string)($filters['from'] ?? ''); +$selectedTo = (string)($filters['to'] ?? ''); +$selectedArtistId = (int)($filters['artist_id'] ?? 0); +$selectedQuery = (string)($filters['q'] ?? ''); +$today = new DateTimeImmutable('today'); +$monthStart = $today->modify('first day of this month'); +$monthEnd = $today->modify('last day of this month'); +$lastMonthStart = $monthStart->modify('-1 month'); +$lastMonthEnd = $monthStart->modify('-1 day'); +$quarter = (int)floor((((int)$today->format('n')) - 1) / 3); +$quarterStartMonth = ($quarter * 3) + 1; +$quarterStart = new DateTimeImmutable($today->format('Y') . '-' . str_pad((string)$quarterStartMonth, 2, '0', STR_PAD_LEFT) . '-01'); +$lastQuarterEnd = $quarterStart->modify('-1 day'); +$lastQuarterStartMonth = (int)$lastQuarterEnd->format('n') - (((int)$lastQuarterEnd->format('n') - 1) % 3); +$lastQuarterStart = new DateTimeImmutable($lastQuarterEnd->format('Y') . '-' . str_pad((string)$lastQuarterStartMonth, 2, '0', STR_PAD_LEFT) . '-01'); +$quickRanges = [ + '7d' => ['label' => 'Last 7 days', 'from' => $today->modify('-6 days')->format('Y-m-d'), 'to' => $today->format('Y-m-d')], + '30d' => ['label' => 'Last 30 days', 'from' => $today->modify('-29 days')->format('Y-m-d'), 'to' => $today->format('Y-m-d')], + 'month' => ['label' => 'This month', 'from' => $monthStart->format('Y-m-d'), 'to' => $monthEnd->format('Y-m-d')], + 'last-month' => ['label' => 'Last month', 'from' => $lastMonthStart->format('Y-m-d'), 'to' => $lastMonthEnd->format('Y-m-d')], + 'quarter' => ['label' => 'This quarter', 'from' => $quarterStart->format('Y-m-d'), 'to' => $today->format('Y-m-d')], + 'last-quarter' => ['label' => 'Last quarter', 'from' => $lastQuarterStart->format('Y-m-d'), 'to' => $lastQuarterEnd->format('Y-m-d')], + 'ytd' => ['label' => 'Year to date', 'from' => $today->format('Y') . '-01-01', 'to' => $today->format('Y-m-d')], +]; +$activeRangeLabel = 'Custom range'; +foreach ($quickRanges as $range) { + if ($selectedFrom === $range['from'] && $selectedTo === $range['to']) { + $activeRangeLabel = $range['label']; + break; + } +} +$artistExportUrl = '/admin/store/reports/export?type=artists' + . '&from=' . rawurlencode($selectedFrom) + . '&to=' . rawurlencode($selectedTo) + . '&artist_id=' . $selectedArtistId + . '&q=' . rawurlencode($selectedQuery); +$trackExportUrl = '/admin/store/reports/export?type=tracks' + . '&from=' . rawurlencode($selectedFrom) + . '&to=' . rawurlencode($selectedTo) + . '&artist_id=' . $selectedArtistId + . '&q=' . rawurlencode($selectedQuery); +$exportUrl = '/admin/store/reports/export?type=' . ($tab === 'tracks' ? 'tracks' : 'artists') + . '&from=' . rawurlencode($selectedFrom) + . '&to=' . rawurlencode($selectedTo) + . '&artist_id=' . $selectedArtistId + . '&q=' . rawurlencode($selectedQuery); +ob_start(); +?> +
+
+
+
Store Analytics
+

Sales Reports

+

Revenue, allocations, and performance reporting across orders, artists, releases, and tracks.

+
+
+
+ Current View + + +
+ Back to Store +
+
+ + +
+ Reporting tables are not ready. + Initialize the Store plugin first so allocation and reporting tables exist. +
+ + + +
+
+ Reporting Period + + +
+
+ $range): ?> + + + +
+
+ +
+ + + + + +
+ + Reset +
+
+ + +
+
+ Before Fees + + Total sales before processor fees. +
+
+ After Fees + + Total sales after captured PayPal fees. +
+
+ +
+
+
+
+

Top Artists

+

Best performing artist allocations for the selected period.

+
+ Net After Fees +
+ +
+ No artist sales in this period. + Try widening the date range or clearing the artist filter. +
+ +
+ $row): ?> +
+
+
+ + sold / paid orders / releases +
+
+
+ +
+ +
+ +
+
+
+

Top Tracks

+

Derived track performance from direct, release, and bundle allocations.

+
+ Net After Fees +
+ +
+ No track sales in this period. + Track-level allocations appear once paid orders exist in the selected range. +
+ +
+ $row): ?> +
+
+
+ + / +
+
+
+ +
+ +
+
+ +
+
+
+

Artist Ledger

+

Grouped artist performance with paid orders, units, refunds, captured fees, and net after fees.

+
+ rows +
+ +
+ No artist sales found. + Try widening the date range or clearing the artist filter. +
+ +
+ +
+
+ + releases / direct tracks +
+
+
Paid Orders
+
Units
+
Refunded Units
+
Gross
+
Refunded
+
PayPal Fees
+
Net After Fees
+
+
+ +
+ +
+ +
+
+
+

Track Ledger

+

Track-level breakdown derived from direct sales, release sales, bundle allocations, and captured PayPal fees.

+
+ rows +
+ +
+ No track sales found. + Track metrics will populate once paid orders exist in the selected range. +
+ +
+ +
+
+ + / +
+
+
Sold
+
Refunded Units
+
Downloads
+
Gross
+
Refunded
+
PayPal Fees
+
Net After Fees
+
+
+ +
+ +
+ + +
+ +resolveArtistId($db, $artistName); + $dupStmt = $id > 0 ? $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug AND id != :id LIMIT 1") : $db->prepare("SELECT id FROM ac_releases WHERE slug = :slug LIMIT 1"); @@ -1334,13 +1336,14 @@ class ReleasesController if ($id > 0) { $stmt = $db->prepare(" UPDATE ac_releases - SET title = :title, slug = :slug, artist_name = :artist_name, description = :description, credits = :credits, catalog_no = :catalog_no, release_date = :release_date, + SET title = :title, slug = :slug, artist_id = :artist_id, artist_name = :artist_name, description = :description, credits = :credits, catalog_no = :catalog_no, release_date = :release_date, cover_url = :cover_url, sample_url = :sample_url, is_published = :is_published WHERE id = :id "); $stmt->execute([ ':title' => $title, ':slug' => $slug, + ':artist_id' => $artistId > 0 ? $artistId : null, ':artist_name' => $artistName !== '' ? $artistName : null, ':description' => $description !== '' ? $description : null, ':credits' => $credits !== '' ? $credits : null, @@ -1356,12 +1359,13 @@ class ReleasesController $releaseId = $id; } else { $stmt = $db->prepare(" - INSERT INTO ac_releases (title, slug, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published) - VALUES (:title, :slug, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published) + INSERT INTO ac_releases (title, slug, artist_id, artist_name, description, credits, catalog_no, release_date, cover_url, sample_url, is_published) + VALUES (:title, :slug, :artist_id, :artist_name, :description, :credits, :catalog_no, :release_date, :cover_url, :sample_url, :is_published) "); $stmt->execute([ ':title' => $title, ':slug' => $slug, + ':artist_id' => $artistId > 0 ? $artistId : null, ':artist_name' => $artistName !== '' ? $artistName : null, ':description' => $description !== '' ? $description : null, ':credits' => $credits !== '' ? $credits : null, @@ -1527,6 +1531,7 @@ class ReleasesController id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL, slug VARCHAR(200) NOT NULL UNIQUE, + artist_id INT UNSIGNED NULL, artist_name VARCHAR(200) NULL, description MEDIUMTEXT NULL, credits MEDIUMTEXT NULL, @@ -1539,6 +1544,7 @@ class ReleasesController updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; "); + $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug"); $db->exec("ALTER TABLE ac_releases ADD COLUMN sample_url VARCHAR(255) NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN cover_url VARCHAR(255) NULL"); $db->exec("ALTER TABLE ac_releases ADD COLUMN release_date DATE NULL"); @@ -1628,6 +1634,40 @@ class ReleasesController } } catch (Throwable $e) { } + try { + $probe = $db->query("SHOW COLUMNS FROM ac_releases LIKE 'artist_id'"); + $exists = (bool)($probe && $probe->fetch(PDO::FETCH_ASSOC)); + if (!$exists) { + $db->exec("ALTER TABLE ac_releases ADD COLUMN artist_id INT UNSIGNED NULL AFTER slug"); + } + } catch (Throwable $e) { + } + 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 function resolveArtistId(PDO $db, string $artistName): int + { + $artistName = trim($artistName); + if ($artistName === '') { + return 0; + } + try { + $stmt = $db->prepare("SELECT id FROM ac_artists WHERE name = :name LIMIT 1"); + $stmt->execute([':name' => $artistName]); + return (int)($stmt->fetchColumn() ?: 0); + } catch (Throwable $e) { + return 0; + } } /** diff --git a/plugins/releases/plugin.php b/plugins/releases/plugin.php index eb3c43b..951cb25 100644 --- a/plugins/releases/plugin.php +++ b/plugins/releases/plugin.php @@ -115,4 +115,6 @@ return function (Router $router): void { $router->post('/admin/releases/tracks/save', [$controller, 'adminTrackSave']); $router->post('/admin/releases/tracks/delete', [$controller, 'adminTrackDelete']); $router->post('/admin/releases/tracks/upload', [$controller, 'adminTrackUpload']); + $router->post('/admin/releases/tracks/sample/generate', [$controller, 'adminTrackGenerateSample']); + $router->get('/admin/releases/tracks/source', [$controller, 'adminTrackSource']); }; diff --git a/plugins/releases/views/admin/edit.php b/plugins/releases/views/admin/edit.php index aa042fc..0709ba1 100644 --- a/plugins/releases/views/admin/edit.php +++ b/plugins/releases/views/admin/edit.php @@ -60,21 +60,6 @@ ob_start();
-
-
Upload sample (MP3)
- - - -
- -
-
- - @@ -137,30 +122,6 @@ ob_start(); coverName.textContent = coverFile.files.length ? coverFile.files[0].name : 'No file selected'; }); } - - const sampleDrop = document.getElementById('releaseSampleDropzone'); - const sampleFile = document.getElementById('releaseSampleFile'); - const sampleName = document.getElementById('releaseSampleFileName'); - if (sampleDrop && sampleFile && sampleName) { - sampleDrop.addEventListener('dragover', (event) => { - event.preventDefault(); - sampleDrop.style.borderColor = 'var(--accent)'; - }); - sampleDrop.addEventListener('dragleave', () => { - sampleDrop.style.borderColor = 'rgba(255,255,255,0.2)'; - }); - sampleDrop.addEventListener('drop', (event) => { - event.preventDefault(); - sampleDrop.style.borderColor = 'rgba(255,255,255,0.2)'; - if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length) { - sampleFile.files = event.dataTransfer.files; - sampleName.textContent = event.dataTransfer.files[0].name; - } - }); - sampleFile.addEventListener('change', () => { - sampleName.textContent = sampleFile.files.length ? sampleFile.files[0].name : 'No file selected'; - }); - } })(); + +
+
Bundle Deals
+
+ + +
+ +
+
+
+ 0 ? $bundleCount . ' releases' : 'Multi-release bundle' ?> + 0): ?> + Save + +
+
+
+ + + + + + + + + +
+
+ +
+
+ +
Credits
@@ -192,6 +251,15 @@ ob_start(); .track-buy-btn{height:34px;border:1px solid rgba(255,255,255,.18);border-radius:999px;background:rgba(255,255,255,.08);color:#e9eefc;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 14px;font-size:12px;white-space:nowrap} .track-buy-btn:hover{background:rgba(255,255,255,.14)} .track-play-btn[disabled]{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:var(--muted);cursor:not-allowed} + .bundle-card{display:grid;grid-template-columns:max-content minmax(0,1fr) auto;gap:14px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.1);background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.01))} + .bundle-stack{position:relative;height:70px;width:calc(70px + (max(0, var(--cover-count, 1) - 1) * 16px));} + .bundle-cover{position:absolute;left:calc(var(--i,0) * 16px);top:0;width:70px;height:70px;border-radius:10px;overflow:hidden;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);box-shadow:0 8px 20px rgba(0,0,0,.35)} + .bundle-cover img{width:100%;height:100%;object-fit:cover} + .bundle-fallback{display:grid;place-items:center;font-size:11px;color:var(--muted)} + .bundle-copy{min-width:0} + .bundle-title{font-size:17px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} + .bundle-meta{margin-top:6px;font-size:12px;color:var(--muted);display:flex;flex-wrap:wrap;gap:10px;align-items:center} + .bundle-save{color:#9ff7d5;font-weight:700;letter-spacing:.08em;text-transform:uppercase;font-size:11px} .ac-dock{position:fixed;left:14px;right:14px;bottom:14px;z-index:50} .ac-dock-inner{display:grid;grid-template-columns:minmax(210px,280px) 96px minmax(160px,1fr) 110px 110px 44px;gap:10px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,11,15,.95)} .ac-dock-meta{display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:10px;min-width:0} @@ -221,7 +289,10 @@ ob_start(); gap: 10px !important; } .track-buy-btn { font-size: 11px; padding: 0 10px; } - .ac-dock { + .bundle-card{grid-template-columns:1fr;gap:10px} + .bundle-stack{height:62px} + .bundle-cover{width:62px;height:62px} + .ac-dock { left: 8px; right: 8px; bottom: 8px; diff --git a/plugins/store/StoreController.php b/plugins/store/StoreController.php index 424ea98..2c56254 100644 --- a/plugins/store/StoreController.php +++ b/plugins/store/StoreController.php @@ -1,1079 +1,1292 @@ -applyStoreTimezone(); - $this->view = new View(__DIR__ . '/views'); - } - - public function adminIndex(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $this->ensureAnalyticsSchema(); - - $tablesReady = $this->tablesReady(); +use Core\Views\View; +use PDO; +use Plugins\Store\Gateways\Gateways; +use Throwable; + +class StoreController +{ + private View $view; + + public function __construct() + { + $this->applyStoreTimezone(); + $this->view = new View(__DIR__ . '/views'); + } + + public function adminIndex(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + + $tablesReady = $this->tablesReady(); $stats = [ 'total_orders' => 0, 'paid_orders' => 0, - 'total_revenue' => 0.0, + 'before_fees' => 0.0, + 'paypal_fees' => 0.0, + 'after_fees' => 0.0, 'total_customers' => 0, ]; - $recentOrders = []; - $newCustomers = []; - - if ($tablesReady) { - $db = Database::get(); - if ($db instanceof PDO) { - try { - $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders"); - $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; - $stats['total_orders'] = (int)($row['c'] ?? 0); - } catch (Throwable $e) { - } - - try { - $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders WHERE status = 'paid'"); - $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; - $stats['paid_orders'] = (int)($row['c'] ?? 0); - } catch (Throwable $e) { - } - - try { - $stmt = $db->query("SELECT COALESCE(SUM(total), 0) AS revenue FROM ac_store_orders WHERE status = 'paid'"); - $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; - $stats['total_revenue'] = (float)($row['revenue'] ?? 0); - } catch (Throwable $e) { - } - - try { - $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_customers"); - $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; - $stats['total_customers'] = (int)($row['c'] ?? 0); - } catch (Throwable $e) { - } - + $recentOrders = []; + $newCustomers = []; + + if ($tablesReady) { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['total_orders'] = (int)($row['c'] ?? 0); + } catch (Throwable $e) { + } + + try { + $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders WHERE status = 'paid'"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['paid_orders'] = (int)($row['c'] ?? 0); + } catch (Throwable $e) { + } + try { $stmt = $db->query(" - SELECT order_no, email, status, currency, total, created_at + SELECT + COALESCE(SUM(COALESCE(payment_gross, total)), 0) AS before_fees, + COALESCE(SUM(COALESCE(payment_fee, 0)), 0) AS paypal_fees, + COALESCE(SUM(COALESCE(payment_net, total)), 0) AS after_fees + FROM ac_store_orders + WHERE status = 'paid' + "); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['before_fees'] = (float)($row['before_fees'] ?? 0); + $stats['paypal_fees'] = (float)($row['paypal_fees'] ?? 0); + $stats['after_fees'] = (float)($row['after_fees'] ?? 0); + } catch (Throwable $e) { + } + + try { + $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_customers"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $stats['total_customers'] = (int)($row['c'] ?? 0); + } catch (Throwable $e) { + } + + try { + $stmt = $db->query(" + SELECT id, order_no, email, status, currency, total, payment_gross, payment_fee, payment_net, created_at FROM ac_store_orders ORDER BY created_at DESC LIMIT 5 - "); - $recentOrders = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; - } catch (Throwable $e) { - $recentOrders = []; - } - - try { - $stmt = $db->query(" - SELECT name, email, is_active, created_at - FROM ac_store_customers - ORDER BY created_at DESC - LIMIT 5 - "); - $newCustomers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; - } catch (Throwable $e) { - $newCustomers = []; - } - } - } - - return new Response($this->view->render('admin/index.php', [ - 'title' => 'Store', - 'tables_ready' => $tablesReady, - 'private_root' => Settings::get('store_private_root', $this->privateRoot()), - 'private_root_ready' => $this->privateRootReady(), - 'stats' => $stats, - 'recent_orders' => $recentOrders, - 'new_customers' => $newCustomers, - 'currency' => Settings::get('store_currency', 'GBP'), - ])); - } - - public function adminSettings(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $this->ensureDiscountSchema(); - $this->ensureSalesChartSchema(); - $settings = $this->settingsPayload(); - $gateways = []; - foreach (Gateways::all() as $gateway) { - $gateways[] = [ - 'key' => $gateway->key(), - 'label' => $gateway->label(), - 'enabled' => $gateway->isEnabled($settings), - ]; - } - - return new Response($this->view->render('admin/settings.php', [ - 'title' => 'Store Settings', - 'settings' => $settings, - 'gateways' => $gateways, - 'discounts' => $this->adminDiscountRows(), - 'private_root_ready' => $this->privateRootReady(), - 'tab' => (string)($_GET['tab'] ?? 'general'), - 'error' => (string)($_GET['error'] ?? ''), - 'saved' => (string)($_GET['saved'] ?? ''), - 'chart_rows' => $this->salesChartRows( - (string)($settings['store_sales_chart_default_scope'] ?? 'tracks'), - (string)($settings['store_sales_chart_default_window'] ?? 'latest'), - max(1, min(30, (int)($settings['store_sales_chart_limit'] ?? '10'))) - ), - 'chart_last_rebuild_at' => (string)$this->salesChartLastRebuildAt(), - 'chart_cron_url' => $this->salesChartCronUrl(), - 'chart_cron_cmd' => $this->salesChartCronCommand(), - ])); - } - - public function adminSaveSettings(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $current = $this->settingsPayload(); - - $currencyRaw = array_key_exists('store_currency', $_POST) ? (string)$_POST['store_currency'] : (string)($current['store_currency'] ?? 'GBP'); - $currency = strtoupper(trim($currencyRaw)); - if (!preg_match('/^[A-Z]{3}$/', $currency)) { - $currency = 'GBP'; - } - $privateRootRaw = array_key_exists('store_private_root', $_POST) ? (string)$_POST['store_private_root'] : (string)($current['store_private_root'] ?? $this->privateRoot()); - $privateRoot = trim($privateRootRaw); - if ($privateRoot === '') { - $privateRoot = $this->privateRoot(); - } - $downloadLimitRaw = array_key_exists('store_download_limit', $_POST) ? (string)$_POST['store_download_limit'] : (string)($current['store_download_limit'] ?? '5'); - $downloadLimit = max(1, (int)$downloadLimitRaw); - $expiryDaysRaw = array_key_exists('store_download_expiry_days', $_POST) ? (string)$_POST['store_download_expiry_days'] : (string)($current['store_download_expiry_days'] ?? '30'); - $expiryDays = max(1, (int)$expiryDaysRaw); - $orderPrefixRaw = array_key_exists('store_order_prefix', $_POST) ? (string)$_POST['store_order_prefix'] : (string)($current['store_order_prefix'] ?? 'AC-ORD'); - $orderPrefix = $this->sanitizeOrderPrefix($orderPrefixRaw); - $timezoneRaw = array_key_exists('store_timezone', $_POST) ? (string)$_POST['store_timezone'] : (string)($current['store_timezone'] ?? 'UTC'); - $timezone = $this->normalizeTimezone($timezoneRaw); - $testMode = array_key_exists('store_test_mode', $_POST) ? ((string)$_POST['store_test_mode'] === '1' ? '1' : '0') : (string)($current['store_test_mode'] ?? '1'); - $stripeEnabled = array_key_exists('store_stripe_enabled', $_POST) ? ((string)$_POST['store_stripe_enabled'] === '1' ? '1' : '0') : (string)($current['store_stripe_enabled'] ?? '0'); - $stripePublic = trim((string)(array_key_exists('store_stripe_public_key', $_POST) ? $_POST['store_stripe_public_key'] : ($current['store_stripe_public_key'] ?? ''))); - $stripeSecret = trim((string)(array_key_exists('store_stripe_secret_key', $_POST) ? $_POST['store_stripe_secret_key'] : ($current['store_stripe_secret_key'] ?? ''))); - $paypalEnabled = array_key_exists('store_paypal_enabled', $_POST) ? ((string)$_POST['store_paypal_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_enabled'] ?? '0'); - $paypalClientId = trim((string)(array_key_exists('store_paypal_client_id', $_POST) ? $_POST['store_paypal_client_id'] : ($current['store_paypal_client_id'] ?? ''))); - $paypalSecret = trim((string)(array_key_exists('store_paypal_secret', $_POST) ? $_POST['store_paypal_secret'] : ($current['store_paypal_secret'] ?? ''))); - $emailLogoUrl = trim((string)(array_key_exists('store_email_logo_url', $_POST) ? $_POST['store_email_logo_url'] : ($current['store_email_logo_url'] ?? ''))); - $orderEmailSubject = trim((string)(array_key_exists('store_order_email_subject', $_POST) ? $_POST['store_order_email_subject'] : ($current['store_order_email_subject'] ?? 'Your AudioCore order {{order_no}}'))); - $orderEmailHtml = trim((string)(array_key_exists('store_order_email_html', $_POST) ? $_POST['store_order_email_html'] : ($current['store_order_email_html'] ?? ''))); - if ($orderEmailHtml === '') { - $orderEmailHtml = $this->defaultOrderEmailHtml(); - } - $salesChartDefaultScope = strtolower(trim((string)(array_key_exists('store_sales_chart_default_scope', $_POST) ? $_POST['store_sales_chart_default_scope'] : ($current['store_sales_chart_default_scope'] ?? 'tracks')))); - if (!in_array($salesChartDefaultScope, ['tracks', 'releases'], true)) { - $salesChartDefaultScope = 'tracks'; - } - $salesChartDefaultWindow = strtolower(trim((string)(array_key_exists('store_sales_chart_default_window', $_POST) ? $_POST['store_sales_chart_default_window'] : ($current['store_sales_chart_default_window'] ?? 'latest')))); - if (!in_array($salesChartDefaultWindow, ['latest', 'weekly', 'all_time'], true)) { - $salesChartDefaultWindow = 'latest'; - } - $salesChartLimit = max(1, min(50, (int)(array_key_exists('store_sales_chart_limit', $_POST) ? $_POST['store_sales_chart_limit'] : ($current['store_sales_chart_limit'] ?? '10')))); - $latestHours = max(1, min(168, (int)(array_key_exists('store_sales_chart_latest_hours', $_POST) ? $_POST['store_sales_chart_latest_hours'] : ($current['store_sales_chart_latest_hours'] ?? '24')))); - $refreshMinutes = max(5, min(1440, (int)(array_key_exists('store_sales_chart_refresh_minutes', $_POST) ? $_POST['store_sales_chart_refresh_minutes'] : ($current['store_sales_chart_refresh_minutes'] ?? '180')))); - - Settings::set('store_currency', $currency); - Settings::set('store_private_root', $privateRoot); - Settings::set('store_download_limit', (string)$downloadLimit); - Settings::set('store_download_expiry_days', (string)$expiryDays); - Settings::set('store_order_prefix', $orderPrefix); - Settings::set('store_timezone', $timezone); - Settings::set('store_test_mode', $testMode); - Settings::set('store_stripe_enabled', $stripeEnabled); - Settings::set('store_stripe_public_key', $stripePublic); - Settings::set('store_stripe_secret_key', $stripeSecret); - Settings::set('store_paypal_enabled', $paypalEnabled); - Settings::set('store_paypal_client_id', $paypalClientId); - Settings::set('store_paypal_secret', $paypalSecret); - Settings::set('store_email_logo_url', $emailLogoUrl); - Settings::set('store_order_email_subject', $orderEmailSubject !== '' ? $orderEmailSubject : 'Your AudioCore order {{order_no}}'); - Settings::set('store_order_email_html', $orderEmailHtml); - Settings::set('store_sales_chart_default_scope', $salesChartDefaultScope); - Settings::set('store_sales_chart_default_window', $salesChartDefaultWindow); - Settings::set('store_sales_chart_limit', (string)$salesChartLimit); - Settings::set('store_sales_chart_latest_hours', (string)$latestHours); - Settings::set('store_sales_chart_refresh_minutes', (string)$refreshMinutes); - if (isset($_POST['store_sales_chart_regen_key'])) { - try { - Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24))); - } catch (Throwable $e) { - } - } - if (trim((string)Settings::get('store_sales_chart_cron_key', '')) === '') { - try { - Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24))); - } catch (Throwable $e) { - } - } - $this->ensureSalesChartSchema(); - - if (!$this->ensurePrivateRoot($privateRoot)) { - return new Response('', 302, ['Location' => '/admin/store/settings?error=Unable+to+create+or+write+private+download+folder']); - } - - $tab = trim((string)($_POST['tab'] ?? 'general')); - $tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'sales_chart'], true) ? $tab : 'general'; - return new Response('', 302, ['Location' => '/admin/store/settings?saved=1&tab=' . rawurlencode($tab)]); - } - - public function adminRebuildSalesChart(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - $this->ensureSalesChartSchema(); - $ok = $this->rebuildSalesChartCache(); - if (!$ok) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&error=Unable+to+rebuild+sales+chart']); - } - return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&saved=1']); - } - - public function salesChartCron(): Response - { - $this->ensureSalesChartSchema(); - $expected = trim((string)Settings::get('store_sales_chart_cron_key', '')); - $provided = trim((string)($_GET['key'] ?? '')); - if ($expected === '' || !hash_equals($expected, $provided)) { - return new Response('Unauthorized', 401); - } - $ok = $this->rebuildSalesChartCache(); - if (!$ok) { - return new Response('Sales chart rebuild failed', 500); - } - return new Response('OK'); - } - - public function adminDiscountCreate(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - $this->ensureDiscountSchema(); - $code = strtoupper(trim((string)($_POST['code'] ?? ''))); - $type = trim((string)($_POST['discount_type'] ?? 'percent')); - $value = (float)($_POST['discount_value'] ?? 0); - $maxUses = (int)($_POST['max_uses'] ?? 0); - $expiresAt = trim((string)($_POST['expires_at'] ?? '')); - $isActive = isset($_POST['is_active']) ? 1 : 0; - - if (!preg_match('/^[A-Z0-9_-]{3,32}$/', $code)) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+code+format']); - } - if (!in_array($type, ['percent', 'fixed'], true)) { - $type = 'percent'; - } - if ($value <= 0) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Discount+value+must+be+greater+than+0']); - } - if ($type === 'percent' && $value > 100) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Percent+cannot+exceed+100']); - } - if ($maxUses < 0) { - $maxUses = 0; - } - - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Database+unavailable']); - } - try { - $stmt = $db->prepare(" - INSERT INTO ac_store_discount_codes - (code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at, updated_at) - VALUES (:code, :discount_type, :discount_value, :max_uses, 0, :expires_at, :is_active, NOW(), NOW()) - ON DUPLICATE KEY UPDATE - discount_type = VALUES(discount_type), - discount_value = VALUES(discount_value), - max_uses = VALUES(max_uses), - expires_at = VALUES(expires_at), - is_active = VALUES(is_active), - updated_at = NOW() - "); - $stmt->execute([ - ':code' => $code, - ':discount_type' => $type, - ':discount_value' => $value, - ':max_uses' => $maxUses, - ':expires_at' => $expiresAt !== '' ? $expiresAt : null, - ':is_active' => $isActive, - ]); - } catch (Throwable $e) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+save+discount+code']); - } - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']); - } - - public function adminDiscountDelete(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - $this->ensureDiscountSchema(); - $id = (int)($_POST['id'] ?? 0); - if ($id <= 0) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+discount+id']); - } - $db = Database::get(); - if ($db instanceof PDO) { - try { - $stmt = $db->prepare("DELETE FROM ac_store_discount_codes WHERE id = :id LIMIT 1"); - $stmt->execute([':id' => $id]); - } catch (Throwable $e) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+delete+discount']); - } - } - return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']); - } - - public function adminSendTestEmail(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - $to = trim((string)($_POST['test_email_to'] ?? '')); - if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=Enter+a+valid+test+email']); - } - - $subjectTpl = trim((string)($_POST['store_order_email_subject'] ?? Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'))); - $htmlTpl = trim((string)($_POST['store_order_email_html'] ?? Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()))); - if ($htmlTpl === '') { - $htmlTpl = $this->defaultOrderEmailHtml(); - } - - $mockItems = [ - ['title' => 'Demo Track One', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'], - ['title' => 'Demo Track Two', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'], - ]; - $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore')); - $logoUrl = trim((string)Settings::get('store_email_logo_url', '')); - $logoHtml = $logoUrl !== '' - ? '' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '' - : ''; - $map = [ - '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'), - '{{order_no}}' => 'AC-TEST-' . date('YmdHis'), - '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'), - '{{currency}}' => 'GBP', - '{{total}}' => '2.98', - '{{status}}' => 'paid', - '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'), - '{{logo_html}}' => $logoHtml, - '{{items_html}}' => $this->renderItemsHtml($mockItems, 'GBP'), - '{{download_links_html}}' => '

Example download links appear here after payment.

', - ]; - $subject = strtr($subjectTpl !== '' ? $subjectTpl : 'Your AudioCore order {{order_no}}', $map); - $html = strtr($htmlTpl, $map); - $mailSettings = [ - 'smtp_host' => Settings::get('smtp_host', ''), - 'smtp_port' => Settings::get('smtp_port', '587'), - 'smtp_user' => Settings::get('smtp_user', ''), - 'smtp_pass' => Settings::get('smtp_pass', ''), - 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), - 'smtp_from_email' => Settings::get('smtp_from_email', ''), - 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'), - ]; - $result = Mailer::send($to, $subject, $html, $mailSettings); - $ref = 'mailtest-' . date('YmdHis') . '-' . random_int(100, 999); - $this->logMailDebug($ref, [ - 'to' => $to, - 'subject' => $subject, - 'result' => $result, - ]); - if (!($result['ok'] ?? false)) { - $msg = rawurlencode('Unable to send test email. Ref ' . $ref . ': ' . (string)($result['error'] ?? 'Unknown')); - return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=' . $msg]); - } - return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&saved=1']); - } - - public function adminTestPaypal(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $settings = $this->settingsPayload(); - $clientId = trim((string)($_POST['store_paypal_client_id'] ?? ($settings['store_paypal_client_id'] ?? ''))); - $secret = trim((string)($_POST['store_paypal_secret'] ?? ($settings['store_paypal_secret'] ?? ''))); - if ($clientId === '' || $secret === '') { - return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=Enter+PayPal+Client+ID+and+Secret+first']); - } - - $probeMode = strtolower(trim((string)($_POST['paypal_probe_mode'] ?? 'live'))); - $isSandbox = ($probeMode === 'sandbox'); - $result = $this->paypalTokenProbe($clientId, $secret, $isSandbox); - if (!($result['ok'] ?? false)) { - $err = rawurlencode((string)($result['error'] ?? 'PayPal validation failed')); - return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=' . $err]); - } - - return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&saved=1&paypal_test=' . rawurlencode($isSandbox ? 'sandbox' : 'live')]); - } - - public function adminCustomers(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $this->ensureAnalyticsSchema(); - $q = trim((string)($_GET['q'] ?? '')); - $like = '%' . $q . '%'; - - $rows = []; - $db = Database::get(); - if ($db instanceof PDO) { - try { - $sql = " - SELECT - c.id, - c.name, - c.email, - c.is_active, - c.created_at, + "); + $recentOrders = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + $recentOrders = []; + } + + try { + $stmt = $db->query(" + SELECT name, email, is_active, created_at + FROM ac_store_customers + ORDER BY created_at DESC + LIMIT 5 + "); + $newCustomers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + } catch (Throwable $e) { + $newCustomers = []; + } + } + } + + return new Response($this->view->render('admin/index.php', [ + 'title' => 'Store', + 'tables_ready' => $tablesReady, + 'private_root' => Settings::get('store_private_root', $this->privateRoot()), + 'private_root_ready' => $this->privateRootReady(), + 'stats' => $stats, + 'recent_orders' => $recentOrders, + 'new_customers' => $newCustomers, + 'currency' => Settings::get('store_currency', 'GBP'), + ])); + } + + public function adminSettings(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureDiscountSchema(); + $this->ensureBundleSchema(); + $this->ensureSalesChartSchema(); + $settings = $this->settingsPayload(); + $gateways = []; + foreach (Gateways::all() as $gateway) { + $gateways[] = [ + 'key' => $gateway->key(), + 'label' => $gateway->label(), + 'enabled' => $gateway->isEnabled($settings), + ]; + } + + return new Response($this->view->render('admin/settings.php', [ + 'title' => 'Store Settings', + 'settings' => $settings, + 'gateways' => $gateways, + 'discounts' => $this->adminDiscountRows(), + 'bundles' => $this->adminBundleRows(), + 'bundle_release_options' => $this->bundleReleaseOptions(), + 'private_root_ready' => $this->privateRootReady(), + 'tab' => (string)($_GET['tab'] ?? 'general'), + 'error' => (string)($_GET['error'] ?? ''), + 'saved' => (string)($_GET['saved'] ?? ''), + 'chart_rows' => $this->salesChartRows( + (string)($settings['store_sales_chart_default_scope'] ?? 'tracks'), + (string)($settings['store_sales_chart_default_window'] ?? 'latest'), + max(1, min(30, (int)($settings['store_sales_chart_limit'] ?? '10'))) + ), + 'chart_last_rebuild_at' => (string)$this->salesChartLastRebuildAt(), + 'chart_cron_url' => $this->salesChartCronUrl(), + 'chart_cron_cmd' => $this->salesChartCronCommand(), + ])); + } + + public function adminSaveSettings(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $current = $this->settingsPayload(); + + $currencyRaw = array_key_exists('store_currency', $_POST) ? (string)$_POST['store_currency'] : (string)($current['store_currency'] ?? 'GBP'); + $currency = strtoupper(trim($currencyRaw)); + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + $privateRootRaw = array_key_exists('store_private_root', $_POST) ? (string)$_POST['store_private_root'] : (string)($current['store_private_root'] ?? $this->privateRoot()); + $privateRoot = trim($privateRootRaw); + if ($privateRoot === '') { + $privateRoot = $this->privateRoot(); + } + $downloadLimitRaw = array_key_exists('store_download_limit', $_POST) ? (string)$_POST['store_download_limit'] : (string)($current['store_download_limit'] ?? '5'); + $downloadLimit = max(1, (int)$downloadLimitRaw); + $expiryDaysRaw = array_key_exists('store_download_expiry_days', $_POST) ? (string)$_POST['store_download_expiry_days'] : (string)($current['store_download_expiry_days'] ?? '30'); + $expiryDays = max(1, (int)$expiryDaysRaw); + $orderPrefixRaw = array_key_exists('store_order_prefix', $_POST) ? (string)$_POST['store_order_prefix'] : (string)($current['store_order_prefix'] ?? 'AC-ORD'); + $orderPrefix = $this->sanitizeOrderPrefix($orderPrefixRaw); + $timezoneRaw = array_key_exists('store_timezone', $_POST) ? (string)$_POST['store_timezone'] : (string)($current['store_timezone'] ?? 'UTC'); + $timezone = $this->normalizeTimezone($timezoneRaw); + $testMode = array_key_exists('store_test_mode', $_POST) ? ((string)$_POST['store_test_mode'] === '1' ? '1' : '0') : (string)($current['store_test_mode'] ?? '1'); + $stripeEnabled = array_key_exists('store_stripe_enabled', $_POST) ? ((string)$_POST['store_stripe_enabled'] === '1' ? '1' : '0') : (string)($current['store_stripe_enabled'] ?? '0'); + $stripePublic = trim((string)(array_key_exists('store_stripe_public_key', $_POST) ? $_POST['store_stripe_public_key'] : ($current['store_stripe_public_key'] ?? ''))); + $stripeSecret = trim((string)(array_key_exists('store_stripe_secret_key', $_POST) ? $_POST['store_stripe_secret_key'] : ($current['store_stripe_secret_key'] ?? ''))); + $paypalEnabled = array_key_exists('store_paypal_enabled', $_POST) ? ((string)$_POST['store_paypal_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_enabled'] ?? '0'); + $paypalClientId = trim((string)(array_key_exists('store_paypal_client_id', $_POST) ? $_POST['store_paypal_client_id'] : ($current['store_paypal_client_id'] ?? ''))); + $paypalSecret = trim((string)(array_key_exists('store_paypal_secret', $_POST) ? $_POST['store_paypal_secret'] : ($current['store_paypal_secret'] ?? ''))); + $paypalCardsEnabled = array_key_exists('store_paypal_cards_enabled', $_POST) ? ((string)$_POST['store_paypal_cards_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_cards_enabled'] ?? '0'); + $paypalSdkMode = strtolower(trim((string)(array_key_exists('store_paypal_sdk_mode', $_POST) ? $_POST['store_paypal_sdk_mode'] : ($current['store_paypal_sdk_mode'] ?? 'embedded_fields')))); + if (!in_array($paypalSdkMode, ['embedded_fields', 'paypal_only_fallback'], true)) { + $paypalSdkMode = 'embedded_fields'; + } + $paypalMerchantCountry = strtoupper(trim((string)(array_key_exists('store_paypal_merchant_country', $_POST) ? $_POST['store_paypal_merchant_country'] : ($current['store_paypal_merchant_country'] ?? '')))); + if ($paypalMerchantCountry !== '' && !preg_match('/^[A-Z]{2}$/', $paypalMerchantCountry)) { + $paypalMerchantCountry = ''; + } + $paypalCardBrandingText = trim((string)(array_key_exists('store_paypal_card_branding_text', $_POST) ? $_POST['store_paypal_card_branding_text'] : ($current['store_paypal_card_branding_text'] ?? 'Pay with card'))); + if ($paypalCardBrandingText === '') { + $paypalCardBrandingText = 'Pay with card'; + } + $emailLogoUrl = trim((string)(array_key_exists('store_email_logo_url', $_POST) ? $_POST['store_email_logo_url'] : ($current['store_email_logo_url'] ?? ''))); + $orderEmailSubject = trim((string)(array_key_exists('store_order_email_subject', $_POST) ? $_POST['store_order_email_subject'] : ($current['store_order_email_subject'] ?? 'Your AudioCore order {{order_no}}'))); + $orderEmailHtml = trim((string)(array_key_exists('store_order_email_html', $_POST) ? $_POST['store_order_email_html'] : ($current['store_order_email_html'] ?? ''))); + if ($orderEmailHtml === '') { + $orderEmailHtml = $this->defaultOrderEmailHtml(); + } + $salesChartDefaultScope = strtolower(trim((string)(array_key_exists('store_sales_chart_default_scope', $_POST) ? $_POST['store_sales_chart_default_scope'] : ($current['store_sales_chart_default_scope'] ?? 'tracks')))); + if (!in_array($salesChartDefaultScope, ['tracks', 'releases'], true)) { + $salesChartDefaultScope = 'tracks'; + } + $salesChartDefaultWindow = strtolower(trim((string)(array_key_exists('store_sales_chart_default_window', $_POST) ? $_POST['store_sales_chart_default_window'] : ($current['store_sales_chart_default_window'] ?? 'latest')))); + if (!in_array($salesChartDefaultWindow, ['latest', 'weekly', 'all_time'], true)) { + $salesChartDefaultWindow = 'latest'; + } + $salesChartLimit = max(1, min(50, (int)(array_key_exists('store_sales_chart_limit', $_POST) ? $_POST['store_sales_chart_limit'] : ($current['store_sales_chart_limit'] ?? '10')))); + $latestHours = max(1, min(168, (int)(array_key_exists('store_sales_chart_latest_hours', $_POST) ? $_POST['store_sales_chart_latest_hours'] : ($current['store_sales_chart_latest_hours'] ?? '24')))); + $refreshMinutes = max(5, min(1440, (int)(array_key_exists('store_sales_chart_refresh_minutes', $_POST) ? $_POST['store_sales_chart_refresh_minutes'] : ($current['store_sales_chart_refresh_minutes'] ?? '180')))); + + Settings::set('store_currency', $currency); + Settings::set('store_private_root', $privateRoot); + Settings::set('store_download_limit', (string)$downloadLimit); + Settings::set('store_download_expiry_days', (string)$expiryDays); + Settings::set('store_order_prefix', $orderPrefix); + Settings::set('store_timezone', $timezone); + Settings::set('store_test_mode', $testMode); + Settings::set('store_stripe_enabled', $stripeEnabled); + Settings::set('store_stripe_public_key', $stripePublic); + Settings::set('store_stripe_secret_key', $stripeSecret); + Settings::set('store_paypal_enabled', $paypalEnabled); + Settings::set('store_paypal_client_id', $paypalClientId); + Settings::set('store_paypal_secret', $paypalSecret); + Settings::set('store_paypal_cards_enabled', $paypalCardsEnabled); + Settings::set('store_paypal_sdk_mode', $paypalSdkMode); + Settings::set('store_paypal_merchant_country', $paypalMerchantCountry); + Settings::set('store_paypal_card_branding_text', $paypalCardBrandingText); + Settings::set('store_email_logo_url', $emailLogoUrl); + Settings::set('store_order_email_subject', $orderEmailSubject !== '' ? $orderEmailSubject : 'Your AudioCore order {{order_no}}'); + Settings::set('store_order_email_html', $orderEmailHtml); + Settings::set('store_sales_chart_default_scope', $salesChartDefaultScope); + Settings::set('store_sales_chart_default_window', $salesChartDefaultWindow); + Settings::set('store_sales_chart_limit', (string)$salesChartLimit); + Settings::set('store_sales_chart_latest_hours', (string)$latestHours); + Settings::set('store_sales_chart_refresh_minutes', (string)$refreshMinutes); + if (isset($_POST['store_sales_chart_regen_key'])) { + try { + Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24))); + } catch (Throwable $e) { + } + } + if (trim((string)Settings::get('store_sales_chart_cron_key', '')) === '') { + try { + Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24))); + } catch (Throwable $e) { + } + } + $this->ensureSalesChartSchema(); + + if (!$this->ensurePrivateRoot($privateRoot)) { + return new Response('', 302, ['Location' => '/admin/store/settings?error=Unable+to+create+or+write+private+download+folder']); + } + + $tab = trim((string)($_POST['tab'] ?? 'general')); + $tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'bundles', 'sales_chart'], true) ? $tab : 'general'; + return new Response('', 302, ['Location' => '/admin/store/settings?saved=1&tab=' . rawurlencode($tab)]); + } + + public function adminRebuildSalesChart(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureSalesChartSchema(); + $ok = $this->rebuildSalesChartCache(); + if (!$ok) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&error=Unable+to+rebuild+sales+chart']); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&saved=1']); + } + + public function salesChartCron(): Response + { + $this->ensureSalesChartSchema(); + $expected = trim((string)Settings::get('store_sales_chart_cron_key', '')); + $provided = trim((string)($_GET['key'] ?? '')); + if ($expected === '' || !hash_equals($expected, $provided)) { + return new Response('Unauthorized', 401); + } + $ok = $this->rebuildSalesChartCache(); + if (!$ok) { + return new Response('Sales chart rebuild failed', 500); + } + return new Response('OK'); + } + + public function adminDiscountCreate(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureDiscountSchema(); + $code = strtoupper(trim((string)($_POST['code'] ?? ''))); + $type = trim((string)($_POST['discount_type'] ?? 'percent')); + $value = (float)($_POST['discount_value'] ?? 0); + $maxUses = (int)($_POST['max_uses'] ?? 0); + $expiresAt = trim((string)($_POST['expires_at'] ?? '')); + $isActive = isset($_POST['is_active']) ? 1 : 0; + + if (!preg_match('/^[A-Z0-9_-]{3,32}$/', $code)) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+code+format']); + } + if (!in_array($type, ['percent', 'fixed'], true)) { + $type = 'percent'; + } + if ($value <= 0) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Discount+value+must+be+greater+than+0']); + } + if ($type === 'percent' && $value > 100) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Percent+cannot+exceed+100']); + } + if ($maxUses < 0) { + $maxUses = 0; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Database+unavailable']); + } + try { + $stmt = $db->prepare(" + INSERT INTO ac_store_discount_codes + (code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at, updated_at) + VALUES (:code, :discount_type, :discount_value, :max_uses, 0, :expires_at, :is_active, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + discount_type = VALUES(discount_type), + discount_value = VALUES(discount_value), + max_uses = VALUES(max_uses), + expires_at = VALUES(expires_at), + is_active = VALUES(is_active), + updated_at = NOW() + "); + $stmt->execute([ + ':code' => $code, + ':discount_type' => $type, + ':discount_value' => $value, + ':max_uses' => $maxUses, + ':expires_at' => $expiresAt !== '' ? $expiresAt : null, + ':is_active' => $isActive, + ]); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+save+discount+code']); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']); + } + + public function adminDiscountDelete(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureDiscountSchema(); + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+discount+id']); + } + $db = Database::get(); + if ($db instanceof PDO) { + try { + $stmt = $db->prepare("DELETE FROM ac_store_discount_codes WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+delete+discount']); + } + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']); + } + + public function adminBundleCreate(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureBundleSchema(); + + $name = trim((string)($_POST['name'] ?? '')); + $slug = trim((string)($_POST['slug'] ?? '')); + $price = (float)($_POST['bundle_price'] ?? 0); + $currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP'))); + $purchaseLabel = trim((string)($_POST['purchase_label'] ?? '')); + $isEnabled = isset($_POST['is_enabled']) ? 1 : 0; + $releaseIds = $_POST['release_ids'] ?? []; + + if ($name === '') { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Bundle+name+is+required']); + } + if ($price <= 0) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Bundle+price+must+be+greater+than+0']); + } + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + if ($slug === '') { + $slug = $this->slugify($name); + } else { + $slug = $this->slugify($slug); + } + if (!is_array($releaseIds) || !$releaseIds) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Choose+at+least+one+release']); + } + $releaseIds = array_values(array_unique(array_filter(array_map(static function ($id): int { + return (int)$id; + }, $releaseIds), static function ($id): bool { + return $id > 0; + }))); + if (!$releaseIds) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Choose+at+least+one+release']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Database+unavailable']); + } + + $bundleId = (int)($_POST['id'] ?? 0); + try { + $db->beginTransaction(); + if ($bundleId > 0) { + $stmt = $db->prepare(" + UPDATE ac_store_bundles + SET name = :name, slug = :slug, bundle_price = :bundle_price, currency = :currency, + purchase_label = :purchase_label, is_enabled = :is_enabled, updated_at = NOW() + WHERE id = :id + "); + $stmt->execute([ + ':name' => $name, + ':slug' => $slug, + ':bundle_price' => $price, + ':currency' => $currency, + ':purchase_label' => $purchaseLabel !== '' ? $purchaseLabel : null, + ':is_enabled' => $isEnabled, + ':id' => $bundleId, + ]); + } else { + $stmt = $db->prepare(" + INSERT INTO ac_store_bundles (name, slug, bundle_price, currency, purchase_label, is_enabled, created_at, updated_at) + VALUES (:name, :slug, :bundle_price, :currency, :purchase_label, :is_enabled, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + bundle_price = VALUES(bundle_price), + currency = VALUES(currency), + purchase_label = VALUES(purchase_label), + is_enabled = VALUES(is_enabled), + updated_at = NOW() + "); + $stmt->execute([ + ':name' => $name, + ':slug' => $slug, + ':bundle_price' => $price, + ':currency' => $currency, + ':purchase_label' => $purchaseLabel !== '' ? $purchaseLabel : null, + ':is_enabled' => $isEnabled, + ]); + $bundleId = (int)$db->lastInsertId(); + if ($bundleId <= 0) { + $lookup = $db->prepare("SELECT id FROM ac_store_bundles WHERE slug = :slug LIMIT 1"); + $lookup->execute([':slug' => $slug]); + $bundleId = (int)($lookup->fetchColumn() ?: 0); + } + } + + if ($bundleId <= 0) { + throw new \RuntimeException('Bundle id missing'); + } + + $del = $db->prepare("DELETE FROM ac_store_bundle_items WHERE bundle_id = :bundle_id"); + $del->execute([':bundle_id' => $bundleId]); + + $ins = $db->prepare(" + INSERT INTO ac_store_bundle_items (bundle_id, release_id, sort_order, created_at) + VALUES (:bundle_id, :release_id, :sort_order, NOW()) + "); + $sort = 1; + foreach ($releaseIds as $releaseId) { + $ins->execute([ + ':bundle_id' => $bundleId, + ':release_id' => $releaseId, + ':sort_order' => $sort++, + ]); + } + + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Unable+to+save+bundle']); + } + + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&saved=1']); + } + + public function adminBundleDelete(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $this->ensureBundleSchema(); + + $id = (int)($_POST['id'] ?? 0); + if ($id <= 0) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Invalid+bundle+id']); + } + + $db = Database::get(); + if ($db instanceof PDO) { + try { + $db->beginTransaction(); + $stmt = $db->prepare("DELETE FROM ac_store_bundle_items WHERE bundle_id = :id"); + $stmt->execute([':id' => $id]); + $stmt = $db->prepare("DELETE FROM ac_store_bundles WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $id]); + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Unable+to+delete+bundle']); + } + } + + return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&saved=1']); + } + + public function adminSendTestEmail(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + $to = trim((string)($_POST['test_email_to'] ?? '')); + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=Enter+a+valid+test+email']); + } + + $subjectTpl = trim((string)($_POST['store_order_email_subject'] ?? Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'))); + $htmlTpl = trim((string)($_POST['store_order_email_html'] ?? Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()))); + if ($htmlTpl === '') { + $htmlTpl = $this->defaultOrderEmailHtml(); + } + + $mockItems = [ + ['title' => 'Demo Track One', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'], + ['title' => 'Demo Track Two', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'], + ]; + $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore')); + $logoUrl = trim((string)Settings::get('store_email_logo_url', '')); + $logoHtml = $logoUrl !== '' + ? '' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '' + : ''; + $map = [ + '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'), + '{{order_no}}' => 'AC-TEST-' . date('YmdHis'), + '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'), + '{{currency}}' => 'GBP', + '{{total}}' => '2.98', + '{{status}}' => 'paid', + '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'), + '{{logo_html}}' => $logoHtml, + '{{items_html}}' => $this->renderItemsHtml($mockItems, 'GBP'), + '{{download_links_html}}' => '

Example download links appear here after payment.

', + ]; + $subject = strtr($subjectTpl !== '' ? $subjectTpl : 'Your AudioCore order {{order_no}}', $map); + $html = strtr($htmlTpl, $map); + $mailSettings = [ + 'smtp_host' => Settings::get('smtp_host', ''), + 'smtp_port' => Settings::get('smtp_port', '587'), + 'smtp_user' => Settings::get('smtp_user', ''), + 'smtp_pass' => Settings::get('smtp_pass', ''), + 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), + 'smtp_from_email' => Settings::get('smtp_from_email', ''), + 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'), + ]; + $result = Mailer::send($to, $subject, $html, $mailSettings); + $ref = 'mailtest-' . date('YmdHis') . '-' . random_int(100, 999); + $this->logMailDebug($ref, [ + 'to' => $to, + 'subject' => $subject, + 'result' => $result, + ]); + if (!($result['ok'] ?? false)) { + $msg = rawurlencode('Unable to send test email. Ref ' . $ref . ': ' . (string)($result['error'] ?? 'Unknown')); + return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=' . $msg]); + } + return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&saved=1']); + } + + public function adminTestPaypal(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $settings = $this->settingsPayload(); + $clientId = trim((string)($_POST['store_paypal_client_id'] ?? ($settings['store_paypal_client_id'] ?? ''))); + $secret = trim((string)($_POST['store_paypal_secret'] ?? ($settings['store_paypal_secret'] ?? ''))); + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=Enter+PayPal+Client+ID+and+Secret+first']); + } + + $probeMode = strtolower(trim((string)($_POST['paypal_probe_mode'] ?? 'live'))); + $isSandbox = ($probeMode === 'sandbox'); + $result = $this->paypalTokenProbe($clientId, $secret, $isSandbox); + if (!($result['ok'] ?? false)) { + $err = rawurlencode((string)($result['error'] ?? 'PayPal validation failed')); + return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=' . $err]); + } + + $cardProbe = $this->paypalCardCapabilityProbe($clientId, $secret, $isSandbox); + Settings::set('store_paypal_cards_capability_status', (string)($cardProbe['status'] ?? 'unknown')); + Settings::set('store_paypal_cards_capability_message', (string)($cardProbe['message'] ?? '')); + Settings::set('store_paypal_cards_capability_checked_at', gmdate('c')); + Settings::set('store_paypal_cards_capability_mode', $isSandbox ? 'sandbox' : 'live'); + + return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&saved=1&paypal_test=' . rawurlencode($isSandbox ? 'sandbox' : 'live')]); + } + + public function adminCustomers(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + $q = trim((string)($_GET['q'] ?? '')); + $like = '%' . $q . '%'; + + $rows = []; + $db = Database::get(); + if ($db instanceof PDO) { + try { + $sql = " + SELECT + c.id, + c.name, + c.email, + c.is_active, + c.created_at, COALESCE(os.order_count, 0) AS order_count, - COALESCE(os.revenue, 0) AS revenue, + COALESCE(os.before_fees, 0) AS before_fees, + COALESCE(os.paypal_fees, 0) AS paypal_fees, + COALESCE(os.after_fees, 0) AS after_fees, os.last_order_no, - os.last_order_id, - os.last_ip, - os.last_order_at - FROM ac_store_customers c - LEFT JOIN ( - SELECT - o.email, + os.last_order_id, + os.last_ip, + os.last_order_at + FROM ac_store_customers c + LEFT JOIN ( + SELECT + o.email, COUNT(*) AS order_count, - SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue, + SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_gross, o.total) ELSE 0 END) AS before_fees, + SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END) AS paypal_fees, + SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_net, o.total) ELSE 0 END) AS after_fees, MAX(o.created_at) AS last_order_at, - SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no, - SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id, - SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip - FROM ac_store_orders o - GROUP BY o.email - ) os ON os.email = c.email - "; - if ($q !== '') { - $sql .= " WHERE c.email LIKE :q OR c.name LIKE :q OR os.last_order_no LIKE :q "; - } - $sql .= " - ORDER BY COALESCE(os.last_order_at, c.created_at) DESC - LIMIT 500 - "; - $stmt = $db->prepare($sql); - if ($q !== '') { - $stmt->execute([':q' => $like]); - } else { - $stmt->execute(); - } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } catch (Throwable $e) { - $rows = []; - } - - try { - $guestSql = " - SELECT - NULL AS id, - '' AS name, - o.email AS email, - 1 AS is_active, - MIN(o.created_at) AS created_at, + SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no, + SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id, + SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip + FROM ac_store_orders o + GROUP BY o.email + ) os ON os.email = c.email + "; + if ($q !== '') { + $sql .= " WHERE c.email LIKE :q OR c.name LIKE :q OR os.last_order_no LIKE :q "; + } + $sql .= " + ORDER BY COALESCE(os.last_order_at, c.created_at) DESC + LIMIT 500 + "; + $stmt = $db->prepare($sql); + if ($q !== '') { + $stmt->execute([':q' => $like]); + } else { + $stmt->execute(); + } + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $rows = []; + } + + try { + $guestSql = " + SELECT + NULL AS id, + '' AS name, + o.email AS email, + 1 AS is_active, + MIN(o.created_at) AS created_at, COUNT(*) AS order_count, - SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue, + SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_gross, o.total) ELSE 0 END) AS before_fees, + SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END) AS paypal_fees, + SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_net, o.total) ELSE 0 END) AS after_fees, SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no, - SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id, - SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip, - MAX(o.created_at) AS last_order_at - FROM ac_store_orders o - LEFT JOIN ac_store_customers c ON c.email = o.email - WHERE c.id IS NULL - "; - if ($q !== '') { - $guestSql .= " AND (o.email LIKE :q OR o.order_no LIKE :q) "; - } - $guestSql .= " - GROUP BY o.email - ORDER BY MAX(o.created_at) DESC - LIMIT 500 - "; - $guestStmt = $db->prepare($guestSql); - if ($q !== '') { - $guestStmt->execute([':q' => $like]); - } else { - $guestStmt->execute(); - } - $guests = $guestStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - if ($guests) { - $rows = array_merge($rows, $guests); - } - } catch (Throwable $e) { - } - - if ($rows) { - $ipHistoryMap = $this->loadCustomerIpHistory($db); - foreach ($rows as &$row) { - $emailKey = strtolower(trim((string)($row['email'] ?? ''))); - $row['ips'] = $ipHistoryMap[$emailKey] ?? []; - } - unset($row); - } - } - - return new Response($this->view->render('admin/customers.php', [ - 'title' => 'Store Customers', - 'customers' => $rows, - 'currency' => Settings::get('store_currency', 'GBP'), - 'q' => $q, - ])); - } - - public function adminOrders(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $this->ensureAnalyticsSchema(); - $q = trim((string)($_GET['q'] ?? '')); - $like = '%' . $q . '%'; - - $rows = []; - $db = Database::get(); - if ($db instanceof PDO) { - try { - $sql = " - SELECT id, order_no, email, status, currency, total, created_at, customer_ip + SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id, + SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip, + MAX(o.created_at) AS last_order_at + FROM ac_store_orders o + LEFT JOIN ac_store_customers c ON c.email = o.email + WHERE c.id IS NULL + "; + if ($q !== '') { + $guestSql .= " AND (o.email LIKE :q OR o.order_no LIKE :q) "; + } + $guestSql .= " + GROUP BY o.email + ORDER BY MAX(o.created_at) DESC + LIMIT 500 + "; + $guestStmt = $db->prepare($guestSql); + if ($q !== '') { + $guestStmt->execute([':q' => $like]); + } else { + $guestStmt->execute(); + } + $guests = $guestStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($guests) { + $rows = array_merge($rows, $guests); + } + } catch (Throwable $e) { + } + + if ($rows) { + $ipHistoryMap = $this->loadCustomerIpHistory($db); + foreach ($rows as &$row) { + $emailKey = strtolower(trim((string)($row['email'] ?? ''))); + $row['ips'] = $ipHistoryMap[$emailKey] ?? []; + } + unset($row); + } + } + + return new Response($this->view->render('admin/customers.php', [ + 'title' => 'Store Customers', + 'customers' => $rows, + 'currency' => Settings::get('store_currency', 'GBP'), + 'q' => $q, + ])); + } + + public function adminOrders(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + $q = trim((string)($_GET['q'] ?? '')); + $like = '%' . $q . '%'; + + $rows = []; + $db = Database::get(); + if ($db instanceof PDO) { + try { + $sql = " + SELECT id, order_no, email, status, currency, total, payment_gross, payment_fee, payment_net, created_at, customer_ip FROM ac_store_orders - "; - if ($q !== '') { - $sql .= " WHERE order_no LIKE :q OR email LIKE :q OR customer_ip LIKE :q OR status LIKE :q "; - } - $sql .= " - ORDER BY created_at DESC - LIMIT 500 - "; - $stmt = $db->prepare($sql); - if ($q !== '') { - $stmt->execute([':q' => $like]); - } else { - $stmt->execute(); - } - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } catch (Throwable $e) { - $rows = []; - } - } - - return new Response($this->view->render('admin/orders.php', [ - 'title' => 'Store Orders', - 'orders' => $rows, - 'q' => $q, - 'saved' => (string)($_GET['saved'] ?? ''), - 'error' => (string)($_GET['error'] ?? ''), - ])); - } - - public function adminOrderCreate(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $email = strtolower(trim((string)($_POST['email'] ?? ''))); - $currency = strtoupper(trim((string)($_POST['currency'] ?? Settings::get('store_currency', 'GBP')))); - $total = (float)($_POST['total'] ?? 0); - $status = trim((string)($_POST['status'] ?? 'pending')); - $orderNo = trim((string)($_POST['order_no'] ?? '')); - - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Enter+a+valid+email']); - } - if (!preg_match('/^[A-Z]{3}$/', $currency)) { - $currency = 'GBP'; - } - if (!in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) { - $status = 'pending'; - } - if ($total < 0) { - $total = 0.0; - } - - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); - } - - if ($orderNo === '') { - $prefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD')); - $orderNo = $prefix . '-' . date('YmdHis') . '-' . random_int(100, 999); - } - - try { - $customerId = $this->upsertCustomerFromOrder($db, $email, $this->clientIp(), substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), $total); - $stmt = $db->prepare(" - INSERT INTO ac_store_orders - (order_no, customer_id, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at) - VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW()) - "); - $stmt->execute([ - ':order_no' => $orderNo, - ':customer_id' => $customerId > 0 ? $customerId : null, - ':email' => $email, - ':status' => $status, - ':currency' => $currency, - ':subtotal' => $total, - ':total' => $total, - ':provider' => 'manual', - ':payment_ref' => null, - ':customer_ip' => $this->clientIp(), - ':customer_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), - ]); - } catch (Throwable $e) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+create+order']); - } - - return new Response('', 302, ['Location' => '/admin/store/orders?saved=created']); - } - - public function adminOrderStatus(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $orderId = (int)($_POST['id'] ?? 0); - $status = trim((string)($_POST['status'] ?? '')); - if ($orderId <= 0 || !in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+update']); - } - - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); - } - - try { - $stmt = $db->prepare("UPDATE ac_store_orders SET status = :status, updated_at = NOW() WHERE id = :id LIMIT 1"); - $stmt->execute([ - ':status' => $status, - ':id' => $orderId, - ]); - $this->rebuildSalesChartCache(); - } catch (Throwable $e) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+update+order']); - } - - return new Response('', 302, ['Location' => '/admin/store/orders?saved=status']); - } - - public function adminOrderDelete(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $orderId = (int)($_POST['id'] ?? 0); - if ($orderId <= 0) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']); - } - - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); - } - - try { - $db->beginTransaction(); - $db->prepare("DELETE FROM ac_store_download_events WHERE order_id = :order_id")->execute([':order_id' => $orderId]); - $db->prepare("DELETE FROM ac_store_download_tokens WHERE order_id = :order_id")->execute([':order_id' => $orderId]); - $db->prepare("DELETE FROM ac_store_order_items WHERE order_id = :order_id")->execute([':order_id' => $orderId]); - $db->prepare("DELETE FROM ac_store_orders WHERE id = :id LIMIT 1")->execute([':id' => $orderId]); - $db->commit(); - $this->rebuildSalesChartCache(); - } catch (Throwable $e) { - if ($db->inTransaction()) { - $db->rollBack(); - } - return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+delete+order']); - } - - return new Response('', 302, ['Location' => '/admin/store/orders?saved=deleted']); - } - - public function adminOrderRefund(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $orderId = (int)($_POST['id'] ?? 0); - if ($orderId <= 0) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']); - } - - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); - } - - try { - $stmt = $db->prepare(" - SELECT id, status, payment_provider, payment_ref, currency, total + "; + if ($q !== '') { + $sql .= " WHERE order_no LIKE :q OR email LIKE :q OR customer_ip LIKE :q OR status LIKE :q "; + } + $sql .= " + ORDER BY created_at DESC + LIMIT 500 + "; + $stmt = $db->prepare($sql); + if ($q !== '') { + $stmt->execute([':q' => $like]); + } else { + $stmt->execute(); + } + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $rows = []; + } + } + + return new Response($this->view->render('admin/orders.php', [ + 'title' => 'Store Orders', + 'orders' => $rows, + 'q' => $q, + 'saved' => (string)($_GET['saved'] ?? ''), + 'error' => (string)($_GET['error'] ?? ''), + ])); + } + + public function adminOrderCreate(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $email = strtolower(trim((string)($_POST['email'] ?? ''))); + $currency = strtoupper(trim((string)($_POST['currency'] ?? Settings::get('store_currency', 'GBP')))); + $total = (float)($_POST['total'] ?? 0); + $status = trim((string)($_POST['status'] ?? 'pending')); + $orderNo = trim((string)($_POST['order_no'] ?? '')); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Enter+a+valid+email']); + } + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + if (!in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) { + $status = 'pending'; + } + if ($total < 0) { + $total = 0.0; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + if ($orderNo === '') { + $prefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD')); + $orderNo = $prefix . '-' . date('YmdHis') . '-' . random_int(100, 999); + } + + try { + $customerId = $this->upsertCustomerFromOrder($db, $email, $this->clientIp(), substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), $total); + $stmt = $db->prepare(" + INSERT INTO ac_store_orders + (order_no, customer_id, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at) + VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW()) + "); + $stmt->execute([ + ':order_no' => $orderNo, + ':customer_id' => $customerId > 0 ? $customerId : null, + ':email' => $email, + ':status' => $status, + ':currency' => $currency, + ':subtotal' => $total, + ':total' => $total, + ':provider' => 'manual', + ':payment_ref' => null, + ':customer_ip' => $this->clientIp(), + ':customer_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+create+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=created']); + } + + public function adminOrderStatus(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $orderId = (int)($_POST['id'] ?? 0); + $status = trim((string)($_POST['status'] ?? '')); + if ($orderId <= 0 || !in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+update']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + try { + $currentStmt = $db->prepare("SELECT status FROM ac_store_orders WHERE id = :id LIMIT 1"); + $currentStmt->execute([':id' => $orderId]); + $currentStatus = (string)($currentStmt->fetchColumn() ?: ''); + $stmt = $db->prepare("UPDATE ac_store_orders SET status = :status, updated_at = NOW() WHERE id = :id LIMIT 1"); + $stmt->execute([ + ':status' => $status, + ':id' => $orderId, + ]); + $this->rebuildSalesChartCache(); + if ($status === 'paid' && $currentStatus !== 'paid') { + ApiLayer::dispatchSaleWebhooksForOrder($orderId); + } + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+update+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=status']); + } + + public function adminOrderDelete(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $orderId = (int)($_POST['id'] ?? 0); + if ($orderId <= 0) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + try { + $db->beginTransaction(); + $db->prepare("DELETE FROM ac_store_download_events WHERE order_id = :order_id")->execute([':order_id' => $orderId]); + $db->prepare("DELETE FROM ac_store_download_tokens WHERE order_id = :order_id")->execute([':order_id' => $orderId]); + $db->prepare("DELETE FROM ac_store_order_item_allocations WHERE order_id = :order_id")->execute([':order_id' => $orderId]); + $db->prepare("DELETE FROM ac_store_order_items WHERE order_id = :order_id")->execute([':order_id' => $orderId]); + $db->prepare("DELETE FROM ac_store_orders WHERE id = :id LIMIT 1")->execute([':id' => $orderId]); + $db->commit(); + $this->rebuildSalesChartCache(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+delete+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=deleted']); + } + + public function adminOrderRefund(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $orderId = (int)($_POST['id'] ?? 0); + if ($orderId <= 0) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']); + } + + try { + $stmt = $db->prepare(" + SELECT id, status, payment_provider, payment_ref, currency, total + FROM ac_store_orders + WHERE id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $orderId]); + $order = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$order) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Order+not+found']); + } + + $status = (string)($order['status'] ?? ''); + if ($status === 'refunded') { + return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']); + } + if ($status !== 'paid') { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Only+paid+orders+can+be+refunded']); + } + + $provider = strtolower(trim((string)($order['payment_provider'] ?? ''))); + $paymentRef = trim((string)($order['payment_ref'] ?? '')); + + if ($provider === 'paypal') { + if ($paymentRef === '') { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Missing+PayPal+capture+reference']); + } + $clientId = trim((string)Settings::get('store_paypal_client_id', '')); + $secret = trim((string)Settings::get('store_paypal_secret', '')); + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/admin/store/orders?error=PayPal+credentials+missing']); + } + $refund = $this->paypalRefundCapture( + $clientId, + $secret, + $this->isEnabledSetting(Settings::get('store_test_mode', '1')), + $paymentRef, + (string)($order['currency'] ?? 'GBP'), + (float)($order['total'] ?? 0) + ); + if (!($refund['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=' . rawurlencode((string)($refund['error'] ?? 'Refund failed'))]); + } + } + + $upd = $db->prepare("UPDATE ac_store_orders SET status = 'refunded', updated_at = NOW() WHERE id = :id LIMIT 1"); + $upd->execute([':id' => $orderId]); + $revoke = $db->prepare(" + UPDATE ac_store_download_tokens + SET downloads_used = download_limit, + expires_at = NOW() + WHERE order_id = :order_id + "); + $revoke->execute([':order_id' => $orderId]); + $this->rebuildSalesChartCache(); + } catch (Throwable $e) { + return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+refund+order']); + } + + return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']); + } + + public function adminOrderView(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $this->ensureAnalyticsSchema(); + + $orderId = (int)($_GET['id'] ?? 0); + if ($orderId <= 0) { + return new Response('', 302, ['Location' => '/admin/store/orders']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/admin/store/orders']); + } + + $order = null; + $items = []; + $downloadsByItem = []; + $downloadEvents = []; + try { + $orderStmt = $db->prepare(" + SELECT id, order_no, email, status, currency, subtotal, total, payment_provider, payment_ref, payment_gross, payment_fee, payment_net, payment_currency, customer_ip, created_at, updated_at FROM ac_store_orders - WHERE id = :id - LIMIT 1 - "); - $stmt->execute([':id' => $orderId]); - $order = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$order) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Order+not+found']); - } - - $status = (string)($order['status'] ?? ''); - if ($status === 'refunded') { - return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']); - } - if ($status !== 'paid') { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Only+paid+orders+can+be+refunded']); - } - - $provider = strtolower(trim((string)($order['payment_provider'] ?? ''))); - $paymentRef = trim((string)($order['payment_ref'] ?? '')); - - if ($provider === 'paypal') { - if ($paymentRef === '') { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Missing+PayPal+capture+reference']); - } - $clientId = trim((string)Settings::get('store_paypal_client_id', '')); - $secret = trim((string)Settings::get('store_paypal_secret', '')); - if ($clientId === '' || $secret === '') { - return new Response('', 302, ['Location' => '/admin/store/orders?error=PayPal+credentials+missing']); - } - $refund = $this->paypalRefundCapture( - $clientId, - $secret, - $this->isEnabledSetting(Settings::get('store_test_mode', '1')), - $paymentRef, - (string)($order['currency'] ?? 'GBP'), - (float)($order['total'] ?? 0) - ); - if (!($refund['ok'] ?? false)) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=' . rawurlencode((string)($refund['error'] ?? 'Refund failed'))]); - } - } - - $upd = $db->prepare("UPDATE ac_store_orders SET status = 'refunded', updated_at = NOW() WHERE id = :id LIMIT 1"); - $upd->execute([':id' => $orderId]); - $revoke = $db->prepare(" - UPDATE ac_store_download_tokens - SET downloads_used = download_limit, - expires_at = NOW() - WHERE order_id = :order_id - "); - $revoke->execute([':order_id' => $orderId]); - $this->rebuildSalesChartCache(); - } catch (Throwable $e) { - return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+refund+order']); - } - - return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']); - } - - public function adminOrderView(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $this->ensureAnalyticsSchema(); - - $orderId = (int)($_GET['id'] ?? 0); - if ($orderId <= 0) { - return new Response('', 302, ['Location' => '/admin/store/orders']); - } - - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/admin/store/orders']); - } - - $order = null; - $items = []; - $downloadsByItem = []; - $downloadEvents = []; - try { - $orderStmt = $db->prepare(" - SELECT id, order_no, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, created_at, updated_at - FROM ac_store_orders - WHERE id = :id - LIMIT 1 - "); - $orderStmt->execute([':id' => $orderId]); - $order = $orderStmt->fetch(PDO::FETCH_ASSOC) ?: null; - if (!$order) { - return new Response('', 302, ['Location' => '/admin/store/orders']); - } - - $itemStmt = $db->prepare(" - SELECT - oi.id, - oi.item_type, - oi.item_id, - oi.title_snapshot, - oi.unit_price_snapshot, - oi.currency_snapshot, - oi.qty, - oi.line_total, - oi.created_at, - t.id AS token_id, - t.download_limit, - t.downloads_used, - t.expires_at, - f.file_name, - f.file_url - FROM ac_store_order_items oi - LEFT JOIN ac_store_download_tokens t ON t.order_item_id = oi.id - LEFT JOIN ac_store_files f ON f.id = t.file_id - WHERE oi.order_id = :order_id - ORDER BY oi.id ASC - "); - $itemStmt->execute([':order_id' => $orderId]); - $items = $itemStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - - $eventStmt = $db->prepare(" - SELECT - e.id, - e.order_item_id, - e.file_id, - e.ip_address, - e.user_agent, - e.downloaded_at, - f.file_name - FROM ac_store_download_events e - LEFT JOIN ac_store_files f ON f.id = e.file_id - WHERE e.order_id = :order_id - ORDER BY e.downloaded_at DESC - LIMIT 500 - "); - $eventStmt->execute([':order_id' => $orderId]); - $downloadEvents = $eventStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } catch (Throwable $e) { - } - - foreach ($downloadEvents as $event) { - $key = (int)($event['order_item_id'] ?? 0); - if ($key <= 0) { - continue; - } - if (!isset($downloadsByItem[$key])) { - $downloadsByItem[$key] = [ - 'count' => 0, - 'ips' => [], - ]; - } - $downloadsByItem[$key]['count']++; - $ip = trim((string)($event['ip_address'] ?? '')); - if ($ip !== '' && !in_array($ip, $downloadsByItem[$key]['ips'], true)) { - $downloadsByItem[$key]['ips'][] = $ip; - } - } - - return new Response($this->view->render('admin/order.php', [ - 'title' => 'Order Detail', - 'order' => $order, - 'items' => $items, - 'downloads_by_item' => $downloadsByItem, - 'download_events' => $downloadEvents, - ])); - } - - public function adminInstall(): Response - { - if ($guard = $this->guard()) { - return $guard; - } - - $db = Database::get(); - if ($db instanceof PDO) { - try { - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_release_products ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - release_id INT UNSIGNED NOT NULL UNIQUE, - is_enabled TINYINT(1) NOT NULL DEFAULT 0, - bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, - currency CHAR(3) NOT NULL DEFAULT 'GBP', - purchase_label VARCHAR(120) NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_track_products ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - release_track_id INT UNSIGNED NOT NULL UNIQUE, - is_enabled TINYINT(1) NOT NULL DEFAULT 0, - track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, - currency CHAR(3) NOT NULL DEFAULT 'GBP', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_files ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - scope_type ENUM('release','track') NOT NULL, - scope_id INT UNSIGNED NOT NULL, - file_url VARCHAR(1024) NOT NULL, - file_name VARCHAR(255) NOT NULL, - file_size BIGINT UNSIGNED NULL, - mime_type VARCHAR(128) NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_customers ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(140) NULL, - email VARCHAR(190) NOT NULL UNIQUE, - password_hash VARCHAR(255) NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_orders ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - order_no VARCHAR(32) NOT NULL UNIQUE, - customer_id INT UNSIGNED NULL, - email VARCHAR(190) NOT NULL, - status ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending', - currency CHAR(3) NOT NULL DEFAULT 'GBP', - subtotal DECIMAL(10,2) NOT NULL DEFAULT 0.00, - total DECIMAL(10,2) NOT NULL DEFAULT 0.00, - discount_code VARCHAR(64) NULL, - discount_amount DECIMAL(10,2) NULL, - payment_provider VARCHAR(40) NULL, - payment_ref VARCHAR(120) NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_order_items ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - order_id INT UNSIGNED NOT NULL, - item_type ENUM('release','track') NOT NULL, - item_id INT UNSIGNED NOT NULL, - title_snapshot VARCHAR(255) NOT NULL, - unit_price_snapshot DECIMAL(10,2) NOT NULL DEFAULT 0.00, - currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP', - qty INT UNSIGNED NOT NULL DEFAULT 1, - line_total DECIMAL(10,2) NOT NULL DEFAULT 0.00, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_download_tokens ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - order_id INT UNSIGNED NOT NULL, - order_item_id INT UNSIGNED NOT NULL, - file_id INT UNSIGNED NOT NULL, - email VARCHAR(190) NOT NULL, - token VARCHAR(96) NOT NULL UNIQUE, - download_limit INT UNSIGNED NOT NULL DEFAULT 5, - downloads_used INT UNSIGNED NOT NULL DEFAULT 0, - expires_at DATETIME NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_download_events ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - token_id INT UNSIGNED NOT NULL, - order_id INT UNSIGNED NOT NULL, - order_item_id INT UNSIGNED NOT NULL, - file_id INT UNSIGNED NOT NULL, - email VARCHAR(190) NULL, - ip_address VARCHAR(64) NULL, - user_agent VARCHAR(255) NULL, - downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - KEY idx_store_download_events_order (order_id), - KEY idx_store_download_events_item (order_item_id), - KEY idx_store_download_events_ip (ip_address) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_login_tokens ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(190) NOT NULL, - token_hash CHAR(64) NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - used_at DATETIME NULL, - request_ip VARCHAR(64) NULL, - request_user_agent VARCHAR(255) NULL, - used_ip VARCHAR(64) NULL, - used_user_agent VARCHAR(255) NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - KEY idx_store_login_tokens_email (email), - KEY idx_store_login_tokens_expires (expires_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code"); - } catch (Throwable $e) { - } - } catch (Throwable $e) { - } - } - - $this->ensurePrivateRoot(Settings::get('store_private_root', $this->privateRoot())); - $this->ensureSalesChartSchema(); - return new Response('', 302, ['Location' => '/admin/store']); - } - + WHERE id = :id + LIMIT 1 + "); + $orderStmt->execute([':id' => $orderId]); + $order = $orderStmt->fetch(PDO::FETCH_ASSOC) ?: null; + if (!$order) { + return new Response('', 302, ['Location' => '/admin/store/orders']); + } + + $itemStmt = $db->prepare(" + SELECT + oi.id, + oi.item_type, + oi.item_id, + oi.title_snapshot, + oi.unit_price_snapshot, + oi.currency_snapshot, + oi.qty, + oi.line_total, + oi.created_at, + t.id AS token_id, + t.download_limit, + t.downloads_used, + t.expires_at, + f.file_name, + f.file_url + FROM ac_store_order_items oi + LEFT JOIN ac_store_download_tokens t ON t.order_item_id = oi.id + LEFT JOIN ac_store_files f ON f.id = t.file_id + WHERE oi.order_id = :order_id + ORDER BY oi.id ASC + "); + $itemStmt->execute([':order_id' => $orderId]); + $items = $itemStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + + $eventStmt = $db->prepare(" + SELECT + e.id, + e.order_item_id, + e.file_id, + e.ip_address, + e.user_agent, + e.downloaded_at, + f.file_name + FROM ac_store_download_events e + LEFT JOIN ac_store_files f ON f.id = e.file_id + WHERE e.order_id = :order_id + ORDER BY e.downloaded_at DESC + LIMIT 500 + "); + $eventStmt->execute([':order_id' => $orderId]); + $downloadEvents = $eventStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + } + + foreach ($downloadEvents as $event) { + $key = (int)($event['order_item_id'] ?? 0); + if ($key <= 0) { + continue; + } + if (!isset($downloadsByItem[$key])) { + $downloadsByItem[$key] = [ + 'count' => 0, + 'ips' => [], + ]; + } + $downloadsByItem[$key]['count']++; + $ip = trim((string)($event['ip_address'] ?? '')); + if ($ip !== '' && !in_array($ip, $downloadsByItem[$key]['ips'], true)) { + $downloadsByItem[$key]['ips'][] = $ip; + } + } + + return new Response($this->view->render('admin/order.php', [ + 'title' => 'Order Detail', + 'order' => $order, + 'items' => $items, + 'downloads_by_item' => $downloadsByItem, + 'download_events' => $downloadEvents, + ])); + } + + public function adminInstall(): Response + { + if ($guard = $this->guard()) { + return $guard; + } + + $db = Database::get(); + if ($db instanceof PDO) { + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_release_products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + release_id INT UNSIGNED NOT NULL UNIQUE, + is_enabled TINYINT(1) NOT NULL DEFAULT 0, + bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency CHAR(3) NOT NULL DEFAULT 'GBP', + purchase_label VARCHAR(120) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_track_products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + release_track_id INT UNSIGNED NOT NULL UNIQUE, + is_enabled TINYINT(1) NOT NULL DEFAULT 0, + track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency CHAR(3) NOT NULL DEFAULT 'GBP', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_files ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + scope_type ENUM('release','track') NOT NULL, + scope_id INT UNSIGNED NOT NULL, + file_url VARCHAR(1024) NOT NULL, + file_name VARCHAR(255) NOT NULL, + file_size BIGINT UNSIGNED NULL, + mime_type VARCHAR(128) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_customers ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(140) NULL, + email VARCHAR(190) NOT NULL UNIQUE, + password_hash VARCHAR(255) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_orders ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_no VARCHAR(32) NOT NULL UNIQUE, + customer_id INT UNSIGNED NULL, + email VARCHAR(190) NOT NULL, + status ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending', + currency CHAR(3) NOT NULL DEFAULT 'GBP', + subtotal DECIMAL(10,2) NOT NULL DEFAULT 0.00, + total DECIMAL(10,2) NOT NULL DEFAULT 0.00, + discount_code VARCHAR(64) NULL, + discount_amount DECIMAL(10,2) NULL, + payment_provider VARCHAR(40) NULL, + payment_ref VARCHAR(120) NULL, + payment_gross DECIMAL(10,2) NULL, + payment_fee DECIMAL(10,2) NULL, + payment_net DECIMAL(10,2) NULL, + payment_currency CHAR(3) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_order_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id INT UNSIGNED NOT NULL, + item_type ENUM('release','track','bundle') NOT NULL, + item_id INT UNSIGNED NOT NULL, + title_snapshot VARCHAR(255) NOT NULL, + unit_price_snapshot DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP', + qty INT UNSIGNED NOT NULL DEFAULT 1, + line_total DECIMAL(10,2) NOT NULL DEFAULT 0.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_download_tokens ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + order_id INT UNSIGNED NOT NULL, + order_item_id INT UNSIGNED NOT NULL, + file_id INT UNSIGNED NOT NULL, + email VARCHAR(190) NOT NULL, + token VARCHAR(96) NOT NULL UNIQUE, + download_limit INT UNSIGNED NOT NULL DEFAULT 5, + downloads_used INT UNSIGNED NOT NULL DEFAULT 0, + expires_at DATETIME NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_download_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + token_id INT UNSIGNED NOT NULL, + order_id INT UNSIGNED NOT NULL, + order_item_id INT UNSIGNED NOT NULL, + file_id INT UNSIGNED NOT NULL, + email VARCHAR(190) NULL, + ip_address VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_download_events_order (order_id), + KEY idx_store_download_events_item (order_item_id), + KEY idx_store_download_events_ip (ip_address) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_login_tokens ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(190) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + request_ip VARCHAR(64) NULL, + request_user_agent VARCHAR(255) NULL, + used_ip VARCHAR(64) NULL, + used_user_agent VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_login_tokens_email (email), + KEY idx_store_login_tokens_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code"); + } catch (Throwable $e) { + } + } catch (Throwable $e) { + } + } + + $this->ensurePrivateRoot(Settings::get('store_private_root', $this->privateRoot())); + $this->ensureBundleSchema(); + $this->ensureSalesChartSchema(); + return new Response('', 302, ['Location' => '/admin/store']); + } + public function accountIndex(): Response { if (session_status() !== PHP_SESSION_ACTIVE) { @@ -1082,832 +1295,1110 @@ class StoreController $this->ensureAnalyticsSchema(); $email = strtolower(trim((string)($_SESSION['ac_store_customer_email'] ?? ''))); - $flash = (string)($_GET['message'] ?? ''); - $error = (string)($_GET['error'] ?? ''); - $orders = []; - $downloads = []; - - if ($email !== '') { - $db = Database::get(); - if ($db instanceof PDO) { - try { - $orderStmt = $db->prepare(" - SELECT id, order_no, status, currency, total, created_at - FROM ac_store_orders - WHERE email = :email - ORDER BY created_at DESC - LIMIT 100 - "); - $orderStmt->execute([':email' => $email]); - $orders = $orderStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } catch (Throwable $e) { - $orders = []; - } - + $flash = $this->consumeAccountFlash('message'); + $error = $this->consumeAccountFlash('error'); + $orders = []; + $downloads = []; + + if ($email !== '') { + $db = Database::get(); + if ($db instanceof PDO) { + try { + $orderStmt = $db->prepare(" + SELECT id, order_no, status, currency, total, created_at + FROM ac_store_orders + WHERE email = :email + ORDER BY created_at DESC + LIMIT 100 + "); + $orderStmt->execute([':email' => $email]); + $orders = $orderStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $orders = []; + } + try { $downloadStmt = $db->prepare(" SELECT o.order_no, + t.file_id, f.file_name, t.download_limit, t.downloads_used, t.expires_at, t.token - FROM ac_store_download_tokens t - JOIN ac_store_orders o ON o.id = t.order_id - JOIN ac_store_files f ON f.id = t.file_id - WHERE t.email = :email - ORDER BY t.created_at DESC - LIMIT 500 - "); - $downloadStmt->execute([':email' => $email]); - $rows = $downloadStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - foreach ($rows as $row) { - $token = trim((string)($row['token'] ?? '')); - if ($token === '') { - continue; - } + FROM ac_store_download_tokens t + JOIN ac_store_orders o ON o.id = t.order_id + JOIN ac_store_files f ON f.id = t.file_id + WHERE t.email = :email + ORDER BY t.created_at DESC + LIMIT 500 + "); + $downloadStmt->execute([':email' => $email]); + $rows = $downloadStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($rows as $row) { + $token = trim((string)($row['token'] ?? '')); + if ($token === '') { + continue; + } $downloads[] = [ 'order_no' => (string)($row['order_no'] ?? ''), - 'file_name' => (string)($row['file_name'] ?? 'Download'), + 'file_name' => $this->buildDownloadLabel( + $db, + (int)($row['file_id'] ?? 0), + (string)($row['file_name'] ?? 'Download') + ), 'download_limit' => (int)($row['download_limit'] ?? 0), 'downloads_used' => (int)($row['downloads_used'] ?? 0), 'expires_at' => (string)($row['expires_at'] ?? ''), 'url' => '/store/download?token=' . rawurlencode($token), ]; - } - } catch (Throwable $e) { - $downloads = []; - } - } - } - - return new Response($this->view->render('site/account.php', [ - 'title' => 'Account', - 'is_logged_in' => ($email !== ''), - 'email' => $email, - 'orders' => $orders, - 'downloads' => $downloads, - 'message' => $flash, - 'error' => $error, - 'download_limit' => (int)Settings::get('store_download_limit', '5'), - 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'), - ])); - } - + } + } catch (Throwable $e) { + $downloads = []; + } + } + } + + return new Response($this->view->render('site/account.php', [ + 'title' => 'Account', + 'is_logged_in' => ($email !== ''), + 'email' => $email, + 'orders' => $orders, + 'downloads' => $downloads, + 'message' => $flash, + 'error' => $error, + 'download_limit' => (int)Settings::get('store_download_limit', '5'), + 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'), + ])); + } + public function accountRequestLogin(): Response { $email = strtolower(trim((string)($_POST['email'] ?? ''))); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - return new Response('', 302, ['Location' => '/account?error=Enter+a+valid+email+address']); + $this->setAccountFlash('error', 'Enter a valid email address'); + return new Response('', 302, ['Location' => '/account']); + } + $limitKey = sha1($email . '|' . $this->clientIp()); + if (RateLimiter::tooMany('store_account_login_request', $limitKey, 8, 600)) { + $this->setAccountFlash('error', 'Too many login requests. Please wait 10 minutes'); + return new Response('', 302, ['Location' => '/account']); } $db = Database::get(); if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']); + $this->setAccountFlash('error', 'Account login service is currently unavailable'); + return new Response('', 302, ['Location' => '/account']); } - $this->ensureAnalyticsSchema(); - - try { - // Rate limit token requests per email. - $limitStmt = $db->prepare(" - SELECT COUNT(*) AS c - FROM ac_store_login_tokens - WHERE email = :email - AND created_at >= (NOW() - INTERVAL 10 MINUTE) - "); + $this->ensureAnalyticsSchema(); + + try { + // Rate limit token requests per email. + $limitStmt = $db->prepare(" + SELECT COUNT(*) AS c + FROM ac_store_login_tokens + WHERE email = :email + AND created_at >= (NOW() - INTERVAL 10 MINUTE) + "); $limitStmt->execute([':email' => $email]); $limitRow = $limitStmt->fetch(PDO::FETCH_ASSOC); if ((int)($limitRow['c'] ?? 0) >= 5) { - return new Response('', 302, ['Location' => '/account?error=Too+many+login+requests.+Please+wait+10+minutes']); + $this->setAccountFlash('error', 'Too many login requests. Please wait 10 minutes'); + return new Response('', 302, ['Location' => '/account']); } - - // Send generic response even if no orders exist. - $orderStmt = $db->prepare("SELECT COUNT(*) AS c FROM ac_store_orders WHERE email = :email"); - $orderStmt->execute([':email' => $email]); - $orderCount = (int)(($orderStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0)); - - if ($orderCount > 0) { - $rawToken = bin2hex(random_bytes(24)); - $tokenHash = hash('sha256', $rawToken); - $expiresAt = (new \DateTimeImmutable('now'))->modify('+15 minutes')->format('Y-m-d H:i:s'); - - $ins = $db->prepare(" - INSERT INTO ac_store_login_tokens - (email, token_hash, expires_at, request_ip, request_user_agent, created_at) - VALUES (:email, :token_hash, :expires_at, :request_ip, :request_user_agent, NOW()) - "); - $ins->execute([ - ':email' => $email, - ':token_hash' => $tokenHash, - ':expires_at' => $expiresAt, - ':request_ip' => $this->clientIp(), - ':request_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), - ]); - - $siteName = trim((string)Settings::get('site_title', '')); - if ($siteName === '') { - $siteName = 'AudioCore V1.5'; + + // Send generic response even if no orders exist. + $orderStmt = $db->prepare("SELECT COUNT(*) AS c FROM ac_store_orders WHERE email = :email"); + $orderStmt->execute([':email' => $email]); + $orderCount = (int)(($orderStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0)); + + if ($orderCount > 0) { + $rawToken = bin2hex(random_bytes(24)); + $tokenHash = hash('sha256', $rawToken); + $expiresAt = (new \DateTimeImmutable('now'))->modify('+15 minutes')->format('Y-m-d H:i:s'); + + $ins = $db->prepare(" + INSERT INTO ac_store_login_tokens + (email, token_hash, expires_at, request_ip, request_user_agent, created_at) + VALUES (:email, :token_hash, :expires_at, :request_ip, :request_user_agent, NOW()) + "); + $ins->execute([ + ':email' => $email, + ':token_hash' => $tokenHash, + ':expires_at' => $expiresAt, + ':request_ip' => $this->clientIp(), + ':request_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + + $siteName = trim((string)Settings::get('site_title', '')); + if ($siteName === '') { + $siteName = 'AudioCore V1.5.1'; + } + $loginUrl = $this->baseUrl() . '/account/login?token=' . rawurlencode($rawToken); + $subject = $siteName . ' account access link'; + $html = '

Hello,

' + . '

Use this secure link to access your downloads:

' + . '

' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '

' + . '

This link expires in 15 minutes and can only be used once.

'; + $mailSettings = [ + 'smtp_host' => Settings::get('smtp_host', ''), + 'smtp_port' => Settings::get('smtp_port', '587'), + 'smtp_user' => Settings::get('smtp_user', ''), + 'smtp_pass' => Settings::get('smtp_pass', ''), + 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), + 'smtp_from_email' => Settings::get('smtp_from_email', ''), + 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'), + ]; + $send = Mailer::send($email, $subject, $html, $mailSettings); + if (!($send['ok'] ?? false)) { + error_log('AC account login mail failed: ' . (string)($send['error'] ?? 'unknown error')); + if (!empty($send['debug'])) { + error_log('AC account login mail debug: ' . str_replace(["\r", "\n"], ' | ', (string)$send['debug'])); + } + $this->setAccountFlash('error', 'Unable to send login email right now'); + return new Response('', 302, ['Location' => '/account']); } - $loginUrl = $this->baseUrl() . '/account/login?token=' . rawurlencode($rawToken); - $subject = $siteName . ' account access link'; - $html = '

Hello,

' - . '

Use this secure link to access your downloads:

' - . '

' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '

' - . '

This link expires in 15 minutes and can only be used once.

'; - $mailSettings = [ - 'smtp_host' => Settings::get('smtp_host', ''), - 'smtp_port' => Settings::get('smtp_port', '587'), - 'smtp_user' => Settings::get('smtp_user', ''), - 'smtp_pass' => Settings::get('smtp_pass', ''), - 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), - 'smtp_from_email' => Settings::get('smtp_from_email', ''), - 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'), - ]; - Mailer::send($email, $subject, $html, $mailSettings); } } catch (Throwable $e) { - return new Response('', 302, ['Location' => '/account?error=Unable+to+send+login+email+right+now']); + $this->setAccountFlash('error', 'Unable to send login email right now'); + return new Response('', 302, ['Location' => '/account']); } - return new Response('', 302, ['Location' => '/account?message=If+we+found+orders+for+that+email%2C+a+login+link+has+been+sent']); + $this->setAccountFlash('message', 'If we found orders for that email, a login link has been sent'); + return new Response('', 302, ['Location' => '/account']); } - + public function accountLogin(): Response { $token = trim((string)($_GET['token'] ?? '')); if ($token === '') { - return new Response('', 302, ['Location' => '/account?error=Invalid+login+token']); + $this->setAccountFlash('error', 'Invalid login token'); + return new Response('', 302, ['Location' => '/account']); } $db = Database::get(); if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']); + $this->setAccountFlash('error', 'Account login service is currently unavailable'); + return new Response('', 302, ['Location' => '/account']); } - $this->ensureAnalyticsSchema(); - - try { - $hash = hash('sha256', $token); - $stmt = $db->prepare(" - SELECT id, email, expires_at, used_at - FROM ac_store_login_tokens - WHERE token_hash = :token_hash - LIMIT 1 - "); + $this->ensureAnalyticsSchema(); + + try { + $hash = hash('sha256', $token); + $stmt = $db->prepare(" + SELECT id, email, expires_at, used_at + FROM ac_store_login_tokens + WHERE token_hash = :token_hash + LIMIT 1 + "); $stmt->execute([':token_hash' => $hash]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { - return new Response('', 302, ['Location' => '/account?error=Login+link+is+invalid']); + $this->setAccountFlash('error', 'Login link is invalid'); + return new Response('', 302, ['Location' => '/account']); } if (!empty($row['used_at'])) { - return new Response('', 302, ['Location' => '/account?error=Login+link+has+already+been+used']); + $this->setAccountFlash('error', 'Login link has already been used'); + return new Response('', 302, ['Location' => '/account']); } $expiresAt = (string)($row['expires_at'] ?? ''); if ($expiresAt !== '') { if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) { - return new Response('', 302, ['Location' => '/account?error=Login+link+has+expired']); + $this->setAccountFlash('error', 'Login link has expired'); + return new Response('', 302, ['Location' => '/account']); } } - - $upd = $db->prepare(" - UPDATE ac_store_login_tokens - SET used_at = NOW(), used_ip = :used_ip, used_user_agent = :used_user_agent - WHERE id = :id - "); - $upd->execute([ - ':id' => (int)$row['id'], - ':used_ip' => $this->clientIp(), - ':used_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), - ]); - + + $upd = $db->prepare(" + UPDATE ac_store_login_tokens + SET used_at = NOW(), used_ip = :used_ip, used_user_agent = :used_user_agent + WHERE id = :id + "); + $upd->execute([ + ':id' => (int)$row['id'], + ':used_ip' => $this->clientIp(), + ':used_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } + session_regenerate_id(true); $_SESSION['ac_store_customer_email'] = strtolower(trim((string)($row['email'] ?? ''))); } catch (Throwable $e) { - return new Response('', 302, ['Location' => '/account?error=Unable+to+complete+login']); + $this->setAccountFlash('error', 'Unable to complete login'); + return new Response('', 302, ['Location' => '/account']); } - return new Response('', 302, ['Location' => '/account?message=Signed+in+successfully']); + $this->setAccountFlash('message', 'Signed in successfully'); + return new Response('', 302, ['Location' => '/account']); } - + public function accountLogout(): Response { if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } unset($_SESSION['ac_store_customer_email']); - return new Response('', 302, ['Location' => '/account?message=You+have+been+signed+out']); + unset($_SESSION['ac_store_flash_message'], $_SESSION['ac_store_flash_error']); + session_regenerate_id(true); + $this->setAccountFlash('message', 'You have been signed out'); + return new Response('', 302, ['Location' => '/account']); } - - public function cartAdd(): Response + + public function cartAdd(): Response + { + $itemType = trim((string)($_POST['item_type'] ?? 'track')); + if (!in_array($itemType, ['track', 'release', 'bundle'], true)) { + $itemType = 'track'; + } + + $itemId = (int)($_POST['item_id'] ?? 0); + $title = trim((string)($_POST['title'] ?? 'Item')); + $coverUrl = trim((string)($_POST['cover_url'] ?? '')); + $currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP'))); + $price = (float)($_POST['price'] ?? 0); + $qty = max(1, (int)($_POST['qty'] ?? 1)); + $returnUrl = trim((string)($_POST['return_url'] ?? '/releases')); + $itemTitle = $title !== '' ? $title : 'Item'; + + if ($itemId <= 0 || $price <= 0) { + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $cart = $_SESSION['ac_cart'] ?? []; + if (!is_array($cart)) { + $cart = []; + } + + $db = Database::get(); + if ($db instanceof PDO) { + if (!$this->isItemReleased($db, $itemType, $itemId)) { + $_SESSION['ac_site_notice'] = [ + 'type' => 'info', + 'text' => 'This release is scheduled and is not available yet.', + ]; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + } + + if ($itemType === 'bundle') { + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + $bundle = $this->loadBundleForCart($db, $itemId); + if (!$bundle) { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'This bundle is unavailable right now.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $bundleKey = 'bundle:' . $itemId; + $cart[$bundleKey] = [ + 'key' => $bundleKey, + 'item_type' => 'bundle', + 'item_id' => $itemId, + 'title' => (string)$bundle['name'], + 'cover_url' => (string)($bundle['cover_url'] ?? $coverUrl), + 'price' => (float)$bundle['bundle_price'], + 'currency' => (string)$bundle['currency'], + 'release_count' => (int)($bundle['release_count'] ?? 0), + 'track_count' => (int)($bundle['track_count'] ?? 0), + 'qty' => 1, + ]; + $_SESSION['ac_cart'] = $cart; + $_SESSION['ac_site_notice'] = [ + 'type' => 'ok', + 'text' => '"' . (string)$bundle['name'] . '" bundle added to your cart.', + ]; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + if ($itemType === 'release') { + if ($db instanceof PDO) { + try { + $trackStmt = $db->prepare(" + SELECT t.id, + t.title, + t.mix_name, + COALESCE(sp.track_price, 0.00) AS track_price, + COALESCE(sp.currency, :currency) AS currency + FROM ac_release_tracks t + JOIN ac_store_track_products sp ON sp.release_track_id = t.id + WHERE t.release_id = :release_id + AND sp.is_enabled = 1 + AND sp.track_price > 0 + ORDER BY t.track_no ASC, t.id ASC + "); + $trackStmt->execute([ + ':release_id' => $itemId, + ':currency' => $currency, + ]); + $trackRows = $trackStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($trackRows) { + $releaseKey = 'release:' . $itemId; + $removedTracks = 0; + foreach ($trackRows as $row) { + $trackId = (int)($row['id'] ?? 0); + if ($trackId <= 0) { + continue; + } + $trackKey = 'track:' . $trackId; + if (isset($cart[$trackKey])) { + unset($cart[$trackKey]); + $removedTracks++; + } + } + + $cart[$releaseKey] = [ + 'key' => $releaseKey, + 'item_type' => 'release', + 'item_id' => $itemId, + 'title' => $itemTitle, + 'cover_url' => $coverUrl, + 'price' => $price, + 'currency' => $currency, + 'qty' => 1, + ]; + $_SESSION['ac_cart'] = $cart; + $msg = '"' . $itemTitle . '" added as full release.'; + if ($removedTracks > 0) { + $msg .= ' Removed ' . $removedTracks . ' individual track' . ($removedTracks === 1 ? '' : 's') . ' from cart.'; + } + $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => $msg]; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + } catch (Throwable $e) { + } + } + } + + $key = $itemType . ':' . $itemId; + if (isset($cart[$key]) && is_array($cart[$key])) { + if ($itemType === 'track') { + $_SESSION['ac_site_notice'] = [ + 'type' => 'info', + 'text' => '"' . $itemTitle . '" is already in your cart.', + ]; + } else { + $cart[$key]['qty'] = max(1, (int)($cart[$key]['qty'] ?? 1)) + $qty; + $cart[$key]['price'] = $price; + $cart[$key]['currency'] = $currency; + $cart[$key]['title'] = $itemTitle; + if ($coverUrl !== '') { + $cart[$key]['cover_url'] = $coverUrl; + } + $_SESSION['ac_site_notice'] = [ + 'type' => 'ok', + 'text' => '"' . $itemTitle . '" quantity updated in your cart.', + ]; + } + } else { + $cart[$key] = [ + 'key' => $key, + 'item_type' => $itemType, + 'item_id' => $itemId, + 'title' => $itemTitle, + 'cover_url' => $coverUrl, + 'price' => $price, + 'currency' => $currency, + 'qty' => $qty, + ]; + $_SESSION['ac_site_notice'] = [ + 'type' => 'ok', + 'text' => '"' . $itemTitle . '" added to your cart.', + ]; + } + $_SESSION['ac_cart'] = $cart; + + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + public function cartApplyDiscount(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $code = strtoupper(trim((string)($_POST['discount_code'] ?? ''))); + $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); + if ($code === '') { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Enter a discount code first.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount service unavailable right now.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $this->ensureDiscountSchema(); + $discount = $this->loadActiveDiscount($db, $code); + if (!$discount) { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'That discount code is invalid or expired.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $cart = $_SESSION['ac_cart'] ?? []; + $hasDiscountableItems = false; + if (is_array($cart)) { + foreach ($cart as $item) { + if (!is_array($item)) { + continue; + } + $itemType = (string)($item['item_type'] ?? ''); + $price = (float)($item['price'] ?? 0); + $qty = max(1, (int)($item['qty'] ?? 1)); + if ($itemType !== 'bundle' && $price > 0 && $qty > 0) { + $hasDiscountableItems = true; + break; + } + } + } + if (!$hasDiscountableItems) { + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount codes do not apply to bundles.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + $_SESSION['ac_discount_code'] = (string)$discount['code']; + $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => 'Discount code "' . (string)$discount['code'] . '" applied.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + public function cartClearDiscount(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); + unset($_SESSION['ac_discount_code']); + $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount code removed.']; + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + public function cartIndex(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $cart = $_SESSION['ac_cart'] ?? []; + if (!is_array($cart)) { + $cart = []; + } + + $items = array_values(array_filter($cart, static function ($item): bool { + return is_array($item); + })); + + $db = Database::get(); + if ($db instanceof PDO) { + $filtered = []; + $removed = 0; + foreach ($items as $item) { + $itemType = (string)($item['item_type'] ?? 'track'); + $itemId = (int)($item['item_id'] ?? 0); + if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) { + $filtered[] = $item; + continue; + } + $removed++; + $key = (string)($item['key'] ?? ''); + if ($key !== '' && isset($_SESSION['ac_cart'][$key])) { + unset($_SESSION['ac_cart'][$key]); + } + } + if ($removed > 0) { + $_SESSION['ac_site_notice'] = [ + 'type' => 'info', + 'text' => 'Unreleased items were removed from your cart.', + ]; + } + $items = $filtered; + } + + if ($db instanceof PDO) { + foreach ($items as $idx => $item) { + $cover = trim((string)($item['cover_url'] ?? '')); + if ($cover !== '') { + continue; + } + $itemType = (string)($item['item_type'] ?? ''); + $itemId = (int)($item['item_id'] ?? 0); + if ($itemId <= 0) { + continue; + } + try { + if ($itemType === 'track') { + $stmt = $db->prepare(" + SELECT r.cover_url + FROM ac_release_tracks t + JOIN ac_releases r ON r.id = t.release_id + WHERE t.id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $itemId]); + } elseif ($itemType === 'release') { + $stmt = $db->prepare("SELECT cover_url FROM ac_releases WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $itemId]); + } else { + continue; + } + $row = $stmt->fetch(PDO::FETCH_ASSOC); + $coverUrl = trim((string)($row['cover_url'] ?? '')); + if ($coverUrl !== '') { + $items[$idx]['cover_url'] = $coverUrl; + } + } catch (Throwable $e) { + } + } + } + + $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); + $totals = $this->buildCartTotals($items, $discountCode); + if ($totals['discount_code'] === '') { + unset($_SESSION['ac_discount_code']); + } else { + $_SESSION['ac_discount_code'] = $totals['discount_code']; + } + + return new Response($this->view->render('site/cart.php', [ + 'title' => 'Cart', + 'items' => $items, + 'totals' => $totals, + ])); + } + + public function cartRemove(): Response + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $key = trim((string)($_POST['key'] ?? '')); + $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); + + if ($key !== '' && isset($_SESSION['ac_cart']) && is_array($_SESSION['ac_cart'])) { + unset($_SESSION['ac_cart'][$key]); + } + + return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); + } + + private function jsonResponse(array $payload, int $status = 200): Response + { + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + if (!is_string($json)) { + $json = '{"ok":false,"error":"JSON encoding failed"}'; + $status = 500; + } + + return new Response($json, $status, [ + 'Content-Type' => 'application/json; charset=utf-8', + ]); + } + + private function checkoutCartFingerprint(array $items, string $email, string $currency, float $total, string $discountCode): string + { + $normalized = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $normalized[] = [ + 'item_type' => (string)($item['item_type'] ?? 'track'), + 'item_id' => (int)($item['item_id'] ?? 0), + 'qty' => max(1, (int)($item['qty'] ?? 1)), + 'price' => round((float)($item['price'] ?? 0), 2), + 'currency' => (string)($item['currency'] ?? $currency), + 'title' => (string)($item['title'] ?? ''), + ]; + } + + return sha1((string)json_encode([ + 'email' => strtolower(trim($email)), + 'currency' => $currency, + 'total' => number_format($total, 2, '.', ''), + 'discount' => $discountCode, + 'items' => $normalized, + ], JSON_UNESCAPED_SLASHES)); + } + + private function nextOrderNo(): string + { + $orderPrefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD')); + return $orderPrefix . '-' . date('YmdHis') . '-' . random_int(100, 999); + } + + private function buildCheckoutContext(string $email = '', bool $acceptedTerms = true): array + { + if (!$acceptedTerms) { + return ['ok' => false, 'error' => 'Please accept the terms to continue']; + } + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + $cart = $_SESSION['ac_cart'] ?? []; + if (!is_array($cart) || !$cart) { + return ['ok' => false, 'error' => 'Your cart is empty']; + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return ['ok' => false, 'error' => 'Database unavailable']; + } + + $email = trim($email); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => 'Please enter a valid email address']; + } + + $items = array_values(array_filter($cart, static function ($item): bool { + return is_array($item); + })); + $validItems = []; + $removed = 0; + + foreach ($items as $item) { + $itemType = (string)($item['item_type'] ?? 'track'); + $itemId = (int)($item['item_id'] ?? 0); + $key = (string)($item['key'] ?? ''); + + if ($itemType === 'bundle' && $itemId > 0) { + $liveBundle = $this->loadBundleForCart($db, $itemId); + if ($liveBundle) { + $item['title'] = (string)($liveBundle['name'] ?? ($item['title'] ?? 'Bundle')); + $item['price'] = (float)($liveBundle['bundle_price'] ?? ($item['price'] ?? 0)); + $item['currency'] = (string)($liveBundle['currency'] ?? ($item['currency'] ?? 'GBP')); + $item['cover_url'] = (string)($liveBundle['cover_url'] ?? ($item['cover_url'] ?? '')); + $item['release_count'] = (int)($liveBundle['release_count'] ?? 0); + $item['track_count'] = (int)($liveBundle['track_count'] ?? 0); + } + } + + if ($itemId > 0 + && $this->isItemReleased($db, $itemType, $itemId) + && $this->hasDownloadableFiles($db, $itemType, $itemId) + ) { + $validItems[] = $item; + if ($key !== '' && isset($_SESSION['ac_cart'][$key]) && is_array($_SESSION['ac_cart'][$key])) { + $_SESSION['ac_cart'][$key] = $item; + } + continue; + } + + $removed++; + if ($key !== '' && isset($_SESSION['ac_cart'][$key])) { + unset($_SESSION['ac_cart'][$key]); + } + } + + if (!$validItems) { + unset($_SESSION['ac_cart']); + return ['ok' => false, 'error' => 'Selected items are not yet released']; + } + + if ($removed > 0) { + $_SESSION['ac_site_notice'] = [ + 'type' => 'info', + 'text' => 'Some unavailable items were removed from your cart.', + ]; + } + + $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); + $totals = $this->buildCartTotals($validItems, $discountCode); + if ((string)$totals['discount_code'] === '') { + unset($_SESSION['ac_discount_code']); + } else { + $_SESSION['ac_discount_code'] = (string)$totals['discount_code']; + } + + return [ + 'ok' => true, + 'db' => $db, + 'email' => $email, + 'items' => $validItems, + 'currency' => (string)$totals['currency'], + 'subtotal' => (float)$totals['subtotal'], + 'discount_amount' => (float)$totals['discount_amount'], + 'discount_code' => (string)$totals['discount_code'], + 'total' => (float)$totals['amount'], + 'fingerprint' => $this->checkoutCartFingerprint( + $validItems, + $email, + (string)$totals['currency'], + (float)$totals['amount'], + (string)$totals['discount_code'] + ), + ]; + } + + private function reusablePendingOrder(PDO $db, string $fingerprint): ?array + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + $pending = $_SESSION['ac_checkout_pending'] ?? null; + if (!is_array($pending)) { + return null; + } + if ((string)($pending['fingerprint'] ?? '') !== $fingerprint) { + $this->clearPendingOrder(); + return null; + } + + $orderId = (int)($pending['order_id'] ?? 0); + if ($orderId <= 0) { + $this->clearPendingOrder(); + return null; + } + + try { + $stmt = $db->prepare("SELECT * FROM ac_store_orders WHERE id = :id AND status = 'pending' LIMIT 1"); + $stmt->execute([':id' => $orderId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + $this->clearPendingOrder(); + return null; + } + + return $row; + } catch (Throwable $e) { + $this->clearPendingOrder(); + return null; + } + } + + private function rememberPendingOrder(int $orderId, string $orderNo, string $fingerprint, string $paypalOrderId = ''): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + $_SESSION['ac_checkout_pending'] = [ + 'order_id' => $orderId, + 'order_no' => $orderNo, + 'fingerprint' => $fingerprint, + 'paypal_order_id' => $paypalOrderId, + 'updated_at' => time(), + ]; + } + + private function clearPendingOrder(): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + + unset($_SESSION['ac_checkout_pending']); + } + + private function pendingOrderId(array $order): int { - $itemType = trim((string)($_POST['item_type'] ?? 'track')); - if (!in_array($itemType, ['track', 'release'], true)) { - $itemType = 'track'; - } - - $itemId = (int)($_POST['item_id'] ?? 0); - $title = trim((string)($_POST['title'] ?? 'Item')); - $coverUrl = trim((string)($_POST['cover_url'] ?? '')); - $currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP'))); - $price = (float)($_POST['price'] ?? 0); - $qty = max(1, (int)($_POST['qty'] ?? 1)); - $returnUrl = trim((string)($_POST['return_url'] ?? '/releases')); - $itemTitle = $title !== '' ? $title : 'Item'; - - if ($itemId <= 0 || $price <= 0) { - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - if (!preg_match('/^[A-Z]{3}$/', $currency)) { - $currency = 'GBP'; - } - - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $cart = $_SESSION['ac_cart'] ?? []; - if (!is_array($cart)) { - $cart = []; - } - - $db = Database::get(); - if ($db instanceof PDO) { - if (!$this->isItemReleased($db, $itemType, $itemId)) { - $_SESSION['ac_site_notice'] = [ - 'type' => 'info', - 'text' => 'This release is scheduled and is not available yet.', - ]; - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - } - - if ($itemType === 'release') { - if ($db instanceof PDO) { - try { - $trackStmt = $db->prepare(" - SELECT t.id, - t.title, - t.mix_name, - COALESCE(sp.track_price, 0.00) AS track_price, - COALESCE(sp.currency, :currency) AS currency - FROM ac_release_tracks t - JOIN ac_store_track_products sp ON sp.release_track_id = t.id - WHERE t.release_id = :release_id - AND sp.is_enabled = 1 - AND sp.track_price > 0 - ORDER BY t.track_no ASC, t.id ASC - "); - $trackStmt->execute([ - ':release_id' => $itemId, - ':currency' => $currency, - ]); - $trackRows = $trackStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - if ($trackRows) { - $releaseKey = 'release:' . $itemId; - $removedTracks = 0; - foreach ($trackRows as $row) { - $trackId = (int)($row['id'] ?? 0); - if ($trackId <= 0) { - continue; - } - $trackKey = 'track:' . $trackId; - if (isset($cart[$trackKey])) { - unset($cart[$trackKey]); - $removedTracks++; - } - } - - $cart[$releaseKey] = [ - 'key' => $releaseKey, - 'item_type' => 'release', - 'item_id' => $itemId, - 'title' => $itemTitle, - 'cover_url' => $coverUrl, - 'price' => $price, - 'currency' => $currency, - 'qty' => 1, - ]; - $_SESSION['ac_cart'] = $cart; - $msg = '"' . $itemTitle . '" added as full release.'; - if ($removedTracks > 0) { - $msg .= ' Removed ' . $removedTracks . ' individual track' . ($removedTracks === 1 ? '' : 's') . ' from cart.'; - } - $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => $msg]; - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - } catch (Throwable $e) { - } - } - } - - $key = $itemType . ':' . $itemId; - if (isset($cart[$key]) && is_array($cart[$key])) { - if ($itemType === 'track') { - $_SESSION['ac_site_notice'] = [ - 'type' => 'info', - 'text' => '"' . $itemTitle . '" is already in your cart.', - ]; - } else { - $cart[$key]['qty'] = max(1, (int)($cart[$key]['qty'] ?? 1)) + $qty; - $cart[$key]['price'] = $price; - $cart[$key]['currency'] = $currency; - $cart[$key]['title'] = $itemTitle; - if ($coverUrl !== '') { - $cart[$key]['cover_url'] = $coverUrl; - } - $_SESSION['ac_site_notice'] = [ - 'type' => 'ok', - 'text' => '"' . $itemTitle . '" quantity updated in your cart.', - ]; - } - } else { - $cart[$key] = [ - 'key' => $key, - 'item_type' => $itemType, - 'item_id' => $itemId, - 'title' => $itemTitle, - 'cover_url' => $coverUrl, - 'price' => $price, - 'currency' => $currency, - 'qty' => $qty, - ]; - $_SESSION['ac_site_notice'] = [ - 'type' => 'ok', - 'text' => '"' . $itemTitle . '" added to your cart.', - ]; - } - $_SESSION['ac_cart'] = $cart; - - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - - public function cartApplyDiscount(): Response + return (int)($order['order_id'] ?? $order['id'] ?? 0); + } + + private function createPendingOrder(PDO $db, array $context, string $paymentProvider = 'paypal'): array + { + $existing = $this->reusablePendingOrder($db, (string)$context['fingerprint']); + if ($existing) { + return [ + 'ok' => true, + 'order_id' => (int)($existing['id'] ?? 0), + 'order_no' => (string)($existing['order_no'] ?? ''), + 'email' => (string)($existing['email'] ?? $context['email']), + ]; + } + + $this->ensureAnalyticsSchema(); + $customerIp = $this->clientIp(); + $customerUserAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); + $customerId = $this->upsertCustomerFromOrder($db, (string)$context['email'], $customerIp, $customerUserAgent, (float)$context['total']); + $orderNo = $this->nextOrderNo(); + + try { + $db->beginTransaction(); + $insOrder = $db->prepare(" + INSERT INTO ac_store_orders + (order_no, customer_id, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at) + VALUES (:order_no, :customer_id, :email, 'pending', :currency, :subtotal, :total, :discount_code, :discount_amount, :provider, NULL, :customer_ip, :customer_user_agent, NOW(), NOW()) + "); + $insOrder->execute([ + ':order_no' => $orderNo, + ':customer_id' => $customerId > 0 ? $customerId : null, + ':email' => (string)$context['email'], + ':currency' => (string)$context['currency'], + ':subtotal' => (float)$context['subtotal'], + ':total' => (float)$context['total'], + ':discount_code' => (string)$context['discount_code'] !== '' ? (string)$context['discount_code'] : null, + ':discount_amount' => (float)$context['discount_amount'] > 0 ? (float)$context['discount_amount'] : null, + ':provider' => $paymentProvider, + ':customer_ip' => $customerIp, + ':customer_user_agent' => $customerUserAgent !== '' ? $customerUserAgent : null, + ]); + $orderId = (int)$db->lastInsertId(); + + $insItem = $db->prepare(" + INSERT INTO ac_store_order_items + (order_id, item_type, item_id, artist_id, title_snapshot, unit_price_snapshot, currency_snapshot, qty, line_total, created_at) + VALUES (:order_id, :item_type, :item_id, :artist_id, :title, :price, :currency, :qty, :line_total, NOW()) + "); + foreach ((array)$context['items'] as $item) { + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $lineTotal = ((string)($item['item_type'] ?? '') === 'bundle') ? $price : ($price * $qty); + $artistId = $this->resolveOrderItemArtistId( + $db, + (string)($item['item_type'] ?? 'track'), + (int)($item['item_id'] ?? 0) + ); + $insItem->execute([ + ':order_id' => $orderId, + ':item_type' => (string)($item['item_type'] ?? 'track'), + ':item_id' => (int)($item['item_id'] ?? 0), + ':artist_id' => $artistId > 0 ? $artistId : null, + ':title' => (string)($item['title'] ?? 'Item'), + ':price' => $price, + ':currency' => (string)($item['currency'] ?? $context['currency']), + ':qty' => $qty, + ':line_total' => $lineTotal, + ]); + ApiLayer::syncOrderItemAllocations($db, $orderId, (int)$db->lastInsertId()); + } + $db->commit(); + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + + return ['ok' => false, 'error' => 'Unable to create order']; + } + + $this->rememberPendingOrder($orderId, $orderNo, (string)$context['fingerprint']); + + return [ + 'ok' => true, + 'order_id' => $orderId, + 'order_no' => $orderNo, + 'email' => (string)$context['email'], + ]; + } + + private function finalizeOrderAsPaid(PDO $db, int $orderId, string $paymentProvider, string $paymentRef = '', array $paymentBreakdown = []): array + { + $stmt = $db->prepare("SELECT * FROM ac_store_orders WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $orderId]); + $order = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$order) { + return ['ok' => false, 'error' => 'Order not found']; + } + + $orderNo = (string)($order['order_no'] ?? ''); + if ($orderNo === '') { + return ['ok' => false, 'error' => 'Order number missing']; + } + + if ((string)($order['status'] ?? '') !== 'paid') { + try { + $upd = $db->prepare(" + UPDATE ac_store_orders + SET status = 'paid', + payment_provider = :provider, + payment_ref = :payment_ref, + payment_gross = :payment_gross, + payment_fee = :payment_fee, + payment_net = :payment_net, + payment_currency = :payment_currency, + updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':provider' => $paymentProvider, + ':payment_ref' => $paymentRef !== '' ? $paymentRef : ((string)($order['payment_ref'] ?? '') !== '' ? (string)$order['payment_ref'] : null), + ':payment_gross' => array_key_exists('gross', $paymentBreakdown) ? (float)$paymentBreakdown['gross'] : (((string)($order['payment_gross'] ?? '') !== '') ? (float)$order['payment_gross'] : null), + ':payment_fee' => array_key_exists('fee', $paymentBreakdown) ? (float)$paymentBreakdown['fee'] : (((string)($order['payment_fee'] ?? '') !== '') ? (float)$order['payment_fee'] : null), + ':payment_net' => array_key_exists('net', $paymentBreakdown) ? (float)$paymentBreakdown['net'] : (((string)($order['payment_net'] ?? '') !== '') ? (float)$order['payment_net'] : null), + ':payment_currency' => !empty($paymentBreakdown['currency']) ? (string)$paymentBreakdown['currency'] : (((string)($order['payment_currency'] ?? '') !== '') ? (string)$order['payment_currency'] : null), + ':id' => $orderId, + ]); + } catch (Throwable $e) { + return ['ok' => false, 'error' => 'Unable to finalize order']; + } + + $itemsForEmail = $this->orderItemsForEmail($db, $orderId); + $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, (string)($order['email'] ?? ''), 'paid'); + $discountCode = trim((string)($order['discount_code'] ?? '')); + if ($discountCode !== '') { + $this->bumpDiscountUsage($db, $discountCode); + } + $this->rebuildSalesChartCache(); + ApiLayer::dispatchSaleWebhooksForOrder($orderId); + $this->sendOrderEmail( + (string)($order['email'] ?? ''), + $orderNo, + (string)($order['currency'] ?? 'GBP'), + (float)($order['total'] ?? 0), + $itemsForEmail, + 'paid', + $downloadLinksHtml + ); + } + + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $_SESSION['ac_last_order_no'] = $orderNo; + $_SESSION['ac_cart'] = []; + unset($_SESSION['ac_discount_code']); + $this->clearPendingOrder(); + + return [ + 'ok' => true, + 'order_no' => $orderNo, + 'redirect' => '/checkout?success=1&order_no=' . rawurlencode($orderNo), + ]; + } + + public function checkoutIndex(): Response + { + $success = (string)($_GET['success'] ?? ''); + $orderNo = (string)($_GET['order_no'] ?? ''); + $error = (string)($_GET['error'] ?? ''); + $downloadLinks = []; + $downloadNotice = ''; + + $context = $this->buildCheckoutContext('preview@example.com', true); + $items = []; + $subtotal = 0.0; + $discountAmount = 0.0; + $discountCode = ''; + $currency = strtoupper(trim((string)Settings::get('store_currency', 'GBP'))); + if (!preg_match('/^[A-Z]{3}$/', $currency)) { + $currency = 'GBP'; + } + $total = 0.0; + if ($context['ok'] ?? false) { + $items = (array)($context['items'] ?? []); + $subtotal = (float)($context['subtotal'] ?? 0); + $discountAmount = (float)($context['discount_amount'] ?? 0); + $discountCode = (string)($context['discount_code'] ?? ''); + $currency = (string)($context['currency'] ?? $currency); + $total = (float)($context['total'] ?? 0); + } + + $db = Database::get(); + if ($success !== '' && $orderNo !== '' && $db instanceof PDO) { + try { + $orderStmt = $db->prepare("SELECT id, status FROM ac_store_orders WHERE order_no = :order_no LIMIT 1"); + $orderStmt->execute([':order_no' => $orderNo]); + $orderRow = $orderStmt->fetch(PDO::FETCH_ASSOC); + if ($orderRow) { + $orderId = (int)($orderRow['id'] ?? 0); + $orderStatus = (string)($orderRow['status'] ?? ''); + if ($orderId > 0) { + $tokenStmt = $db->prepare(" + SELECT + t.token, + t.file_id, + f.file_name, + COALESCE(NULLIF(oi.title_snapshot, ''), f.file_name) AS fallback_label + FROM ac_store_download_tokens t + JOIN ac_store_files f ON f.id = t.file_id + LEFT JOIN ac_store_order_items oi ON oi.id = t.order_item_id + WHERE t.order_id = :order_id + ORDER BY t.id DESC + "); + $tokenStmt->execute([':order_id' => $orderId]); + foreach ($tokenStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $token = trim((string)($row['token'] ?? '')); + if ($token === '') { + continue; + } + $fileId = (int)($row['file_id'] ?? 0); + $downloadLinks[] = [ + 'label' => $this->buildDownloadLabel($db, $fileId, (string)($row['fallback_label'] ?? $row['file_name'] ?? 'Download')), + 'url' => '/store/download?token=' . rawurlencode($token), + ]; + } + if (!$downloadLinks) { + $downloadNotice = $orderStatus === 'paid' + ? 'No downloadable files are attached to this order yet.' + : 'Download links will appear here once payment is confirmed.'; + } + } + } + } catch (Throwable $e) { + $downloadNotice = 'Unable to load download links right now.'; + } + } + + $settings = $this->settingsPayload(); + $paypalEnabled = $this->isEnabledSetting($settings['store_paypal_enabled'] ?? '0'); + $paypalCardsEnabled = $paypalEnabled && $this->isEnabledSetting($settings['store_paypal_cards_enabled'] ?? '0'); + $paypalCapabilityStatus = (string)($settings['store_paypal_cards_capability_status'] ?? 'unknown'); + $paypalCardsAvailable = false; + $paypalClientToken = ''; + if ($paypalCardsEnabled && $paypalCapabilityStatus === 'available') { + $clientId = trim((string)($settings['store_paypal_client_id'] ?? '')); + $secret = trim((string)($settings['store_paypal_secret'] ?? '')); + if ($clientId !== '' && $secret !== '') { + $token = $this->paypalGenerateClientToken( + $clientId, + $secret, + $this->isEnabledSetting($settings['store_test_mode'] ?? '1') + ); + if ($token['ok'] ?? false) { + $paypalClientToken = (string)($token['client_token'] ?? ''); + $paypalCardsAvailable = $paypalClientToken !== ''; + } + } + } + + return new Response($this->view->render('site/checkout.php', [ + 'title' => 'Checkout', + 'items' => $items, + 'total' => $total, + 'subtotal' => $subtotal, + 'discount_amount' => $discountAmount, + 'discount_code' => $discountCode, + 'currency' => $currency, + 'success' => $success, + 'order_no' => $orderNo, + 'error' => $error, + 'download_links' => $downloadLinks, + 'download_notice' => $downloadNotice, + 'download_limit' => (int)Settings::get('store_download_limit', '5'), + 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'), + 'paypal_enabled' => $paypalEnabled, + 'paypal_cards_enabled' => $paypalCardsEnabled, + 'paypal_cards_available' => $paypalCardsAvailable, + 'paypal_client_id' => (string)($settings['store_paypal_client_id'] ?? ''), + 'paypal_client_token' => $paypalClientToken, + 'paypal_sdk_mode' => (string)($settings['store_paypal_sdk_mode'] ?? 'embedded_fields'), + 'paypal_merchant_country' => (string)($settings['store_paypal_merchant_country'] ?? ''), + 'paypal_card_branding_text' => (string)($settings['store_paypal_card_branding_text'] ?? 'Pay with card'), + 'paypal_capability_status' => $paypalCapabilityStatus, + 'paypal_capability_message' => (string)($settings['store_paypal_cards_capability_message'] ?? ''), + 'paypal_test_mode' => $this->isEnabledSetting($settings['store_test_mode'] ?? '1'), + ])); + } + + public function checkoutCardStart(): Response { - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $code = strtoupper(trim((string)($_POST['discount_code'] ?? ''))); - $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); - if ($code === '') { - $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Enter a discount code first.']; - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - - $db = Database::get(); - if (!($db instanceof PDO)) { - $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount service unavailable right now.']; - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - - $this->ensureDiscountSchema(); - $discount = $this->loadActiveDiscount($db, $code); - if (!$discount) { - $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'That discount code is invalid or expired.']; - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - - $_SESSION['ac_discount_code'] = (string)$discount['code']; - $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => 'Discount code "' . (string)$discount['code'] . '" applied.']; - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - - public function cartClearDiscount(): Response - { - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); - unset($_SESSION['ac_discount_code']); - $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount code removed.']; - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - - public function cartIndex(): Response - { - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $cart = $_SESSION['ac_cart'] ?? []; - if (!is_array($cart)) { - $cart = []; - } - - $items = array_values(array_filter($cart, static function ($item): bool { - return is_array($item); - })); - - $db = Database::get(); - if ($db instanceof PDO) { - $filtered = []; - $removed = 0; - foreach ($items as $item) { - $itemType = (string)($item['item_type'] ?? 'track'); - $itemId = (int)($item['item_id'] ?? 0); - if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) { - $filtered[] = $item; - continue; - } - $removed++; - $key = (string)($item['key'] ?? ''); - if ($key !== '' && isset($_SESSION['ac_cart'][$key])) { - unset($_SESSION['ac_cart'][$key]); - } - } - if ($removed > 0) { - $_SESSION['ac_site_notice'] = [ - 'type' => 'info', - 'text' => 'Unreleased items were removed from your cart.', - ]; - } - $items = $filtered; - } - - if ($db instanceof PDO) { - foreach ($items as $idx => $item) { - $cover = trim((string)($item['cover_url'] ?? '')); - if ($cover !== '') { - continue; - } - $itemType = (string)($item['item_type'] ?? ''); - $itemId = (int)($item['item_id'] ?? 0); - if ($itemId <= 0) { - continue; - } - try { - if ($itemType === 'track') { - $stmt = $db->prepare(" - SELECT r.cover_url - FROM ac_release_tracks t - JOIN ac_releases r ON r.id = t.release_id - WHERE t.id = :id - LIMIT 1 - "); - $stmt->execute([':id' => $itemId]); - } elseif ($itemType === 'release') { - $stmt = $db->prepare("SELECT cover_url FROM ac_releases WHERE id = :id LIMIT 1"); - $stmt->execute([':id' => $itemId]); - } else { - continue; - } - $row = $stmt->fetch(PDO::FETCH_ASSOC); - $coverUrl = trim((string)($row['cover_url'] ?? '')); - if ($coverUrl !== '') { - $items[$idx]['cover_url'] = $coverUrl; - } - } catch (Throwable $e) { - } - } - } - - $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); - $totals = $this->buildCartTotals($items, $discountCode); - if ($totals['discount_code'] === '') { - unset($_SESSION['ac_discount_code']); - } else { - $_SESSION['ac_discount_code'] = $totals['discount_code']; - } - - return new Response($this->view->render('site/cart.php', [ - 'title' => 'Cart', - 'items' => $items, - 'totals' => $totals, - ])); - } - - public function cartRemove(): Response - { - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $key = trim((string)($_POST['key'] ?? '')); - $returnUrl = trim((string)($_POST['return_url'] ?? '/cart')); - - if ($key !== '' && isset($_SESSION['ac_cart']) && is_array($_SESSION['ac_cart'])) { - unset($_SESSION['ac_cart'][$key]); - } - - return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]); - } - - public function checkoutIndex(): Response - { - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $cart = $_SESSION['ac_cart'] ?? []; - if (!is_array($cart)) { - $cart = []; - } - $items = array_values(array_filter($cart, static function ($item): bool { - return is_array($item); - })); - $db = Database::get(); - if ($db instanceof PDO) { - $filtered = []; - foreach ($items as $item) { - $itemType = (string)($item['item_type'] ?? 'track'); - $itemId = (int)($item['item_id'] ?? 0); - if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) { - $filtered[] = $item; - continue; - } - $key = (string)($item['key'] ?? ''); - if ($key !== '' && isset($_SESSION['ac_cart'][$key])) { - unset($_SESSION['ac_cart'][$key]); - } - } - $items = $filtered; - } - $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); - $totals = $this->buildCartTotals($items, $discountCode); - if ($totals['discount_code'] === '') { - unset($_SESSION['ac_discount_code']); - } else { - $_SESSION['ac_discount_code'] = $totals['discount_code']; - } - $total = (float)$totals['amount']; - $currency = (string)$totals['currency']; - - $downloadLinks = []; - $downloadNotice = ''; - $success = (string)($_GET['success'] ?? ''); - $orderNo = (string)($_GET['order_no'] ?? ''); - if ($success !== '' && $orderNo !== '') { - if ($db instanceof PDO) { - try { - $orderStmt = $db->prepare("SELECT id, status, email FROM ac_store_orders WHERE order_no = :order_no LIMIT 1"); - $orderStmt->execute([':order_no' => $orderNo]); - $orderRow = $orderStmt->fetch(PDO::FETCH_ASSOC); - if ($orderRow) { - $orderId = (int)($orderRow['id'] ?? 0); - $orderStatus = (string)($orderRow['status'] ?? ''); - if ($orderId > 0) { - $tokenStmt = $db->prepare(" - SELECT t.token, f.file_name - FROM ac_store_download_tokens t - JOIN ac_store_files f ON f.id = t.file_id - WHERE t.order_id = :order_id - ORDER BY t.id DESC - "); - $tokenStmt->execute([':order_id' => $orderId]); - $rows = $tokenStmt->fetchAll(PDO::FETCH_ASSOC); - foreach ($rows as $row) { - $token = trim((string)($row['token'] ?? '')); - if ($token === '') { - continue; - } - $downloadLinks[] = [ - 'label' => (string)($row['file_name'] ?? 'Download'), - 'url' => '/store/download?token=' . rawurlencode($token), - ]; - } - if (!$downloadLinks) { - $downloadNotice = $orderStatus === 'paid' - ? 'No downloadable files are attached to this order yet.' - : 'Download links will appear here once payment is confirmed.'; - } - } - } - } catch (Throwable $e) { - $downloadNotice = 'Unable to load download links right now.'; - } - } - } - - return new Response($this->view->render('site/checkout.php', [ - 'title' => 'Checkout', - 'items' => $items, - 'total' => $total, - 'subtotal' => (float)$totals['subtotal'], - 'discount_amount' => (float)$totals['discount_amount'], - 'discount_code' => (string)$totals['discount_code'], - 'currency' => $currency, - 'success' => $success, - 'order_no' => $orderNo, - 'error' => (string)($_GET['error'] ?? ''), - 'download_links' => $downloadLinks, - 'download_notice' => $downloadNotice, - 'download_limit' => (int)Settings::get('store_download_limit', '5'), - 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'), - ])); - } - - public function checkoutSandbox(): Response - { - return $this->checkoutPlace(); - } - - public function checkoutPlace(): Response - { - if (!isset($_POST['accept_terms'])) { - return new Response('', 302, ['Location' => '/checkout?error=Please+accept+the+terms+to+continue']); - } - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $cart = $_SESSION['ac_cart'] ?? []; - if (!is_array($cart) || !$cart) { - return new Response('', 302, ['Location' => '/checkout']); - } - - $items = array_values(array_filter($cart, static function ($item): bool { - return is_array($item); - })); - if (!$items) { - return new Response('', 302, ['Location' => '/checkout']); - } - - $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? ''))); - $totals = $this->buildCartTotals($items, $discountCode); - $currency = (string)$totals['currency']; - $subtotal = (float)$totals['subtotal']; - $discountAmount = (float)$totals['discount_amount']; - $total = (float)$totals['amount']; - $appliedDiscountCode = (string)$totals['discount_code']; - $testMode = $this->isEnabledSetting(Settings::get('store_test_mode', '1')); - $paypalEnabled = $this->isEnabledSetting(Settings::get('store_paypal_enabled', '0')); - $orderPrefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD')); - $orderNo = $orderPrefix . '-' . date('YmdHis') . '-' . random_int(100, 999); $email = trim((string)($_POST['email'] ?? '')); - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - return new Response('', 302, ['Location' => '/checkout?error=Please+enter+a+valid+email+address']); + $acceptedTerms = isset($_POST['accept_terms']); + $context = $this->buildCheckoutContext($email, $acceptedTerms); + if (!(bool)($context['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)((string)($context['error'] ?? '') !== '' ? $context['error'] : 'CARD_START_CONTEXT_FAIL'))]); } - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('', 302, ['Location' => '/checkout']); - } - $validItems = []; - $removed = 0; - foreach ($items as $item) { - $itemType = (string)($item['item_type'] ?? 'track'); - $itemId = (int)($item['item_id'] ?? 0); - if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) { - $validItems[] = $item; - continue; - } - $removed++; - $key = (string)($item['key'] ?? ''); - if ($key !== '' && isset($_SESSION['ac_cart'][$key])) { - unset($_SESSION['ac_cart'][$key]); - } - } - if (!$validItems) { - return new Response('', 302, ['Location' => '/checkout?error=Selected+items+are+not+yet+released']); - } - if ($removed > 0) { - $_SESSION['ac_site_notice'] = [ - 'type' => 'info', - 'text' => 'Some unreleased items were removed from your cart.', - ]; - } - $items = $validItems; - - $this->ensureAnalyticsSchema(); - $customerIp = $this->clientIp(); - $customerUserAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255); - - try { - $customerId = $this->upsertCustomerFromOrder($db, $email, $customerIp, $customerUserAgent, $total); - $db->beginTransaction(); - $insOrder = $db->prepare(" - INSERT INTO ac_store_orders - (order_no, customer_id, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at) - VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :discount_code, :discount_amount, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW()) - "); - $insOrder->execute([ - ':order_no' => $orderNo, - ':customer_id' => $customerId > 0 ? $customerId : null, - ':email' => $email, - ':status' => $paypalEnabled ? 'pending' : ($testMode ? 'paid' : 'pending'), - ':currency' => $currency, - ':subtotal' => $subtotal, - ':total' => $total, - ':discount_code' => $appliedDiscountCode !== '' ? $appliedDiscountCode : null, - ':discount_amount' => $discountAmount > 0 ? $discountAmount : null, - ':provider' => $paypalEnabled ? 'paypal' : ($testMode ? 'test' : 'checkout'), - ':payment_ref' => $paypalEnabled ? null : ($testMode ? 'test' : null), - ':customer_ip' => $customerIp, - ':customer_user_agent' => $customerUserAgent !== '' ? $customerUserAgent : null, - ]); - $orderId = (int)$db->lastInsertId(); - - $insItem = $db->prepare(" - INSERT INTO ac_store_order_items - (order_id, item_type, item_id, title_snapshot, unit_price_snapshot, currency_snapshot, qty, line_total, created_at) - VALUES (:order_id, :item_type, :item_id, :title, :price, :currency, :qty, :line_total, NOW()) - "); - foreach ($items as $item) { - $qty = max(1, (int)($item['qty'] ?? 1)); - $price = (float)($item['price'] ?? 0); - $insItem->execute([ - ':order_id' => $orderId, - ':item_type' => (string)($item['item_type'] ?? 'track'), - ':item_id' => (int)($item['item_id'] ?? 0), - ':title' => (string)($item['title'] ?? 'Item'), - ':price' => $price, - ':currency' => (string)($item['currency'] ?? $currency), - ':qty' => $qty, - ':line_total' => ($price * $qty), - ]); - } - $db->commit(); - if ($total <= 0.0) { - try { - $upd = $db->prepare(" - UPDATE ac_store_orders - SET status = 'paid', payment_provider = 'discount', payment_ref = :payment_ref, updated_at = NOW() - WHERE id = :id - "); - $upd->execute([ - ':payment_ref' => 'discount-zero-total', - ':id' => $orderId, - ]); - } catch (Throwable $e) { - } - $_SESSION['ac_last_order_no'] = $orderNo; - $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, 'paid'); - $this->sendOrderEmail($email, $orderNo, $currency, 0.0, $items, 'paid', $downloadLinksHtml); - if ($appliedDiscountCode !== '') { - $this->bumpDiscountUsage($db, $appliedDiscountCode); - } - $this->rebuildSalesChartCache(); - $_SESSION['ac_cart'] = []; - unset($_SESSION['ac_discount_code']); - return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); - } - if ($paypalEnabled) { - $clientId = trim((string)Settings::get('store_paypal_client_id', '')); - $secret = trim((string)Settings::get('store_paypal_secret', '')); - if ($clientId === '' || $secret === '') { - return new Response('', 302, ['Location' => '/checkout?error=PayPal+is+enabled+but+credentials+are+missing']); - } - - $returnUrl = $this->baseUrl() . '/checkout/paypal/return'; - $cancelUrl = $this->baseUrl() . '/checkout/paypal/cancel'; - $create = $this->paypalCreateOrder( - $clientId, - $secret, - $testMode, - $currency, - $total, - $orderNo, - $returnUrl, - $cancelUrl - ); - if (!($create['ok'] ?? false)) { - return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($create['error'] ?? 'Unable to start PayPal checkout'))]); - } - - $paypalOrderId = (string)($create['order_id'] ?? ''); - $approvalUrl = (string)($create['approval_url'] ?? ''); - if ($paypalOrderId === '' || $approvalUrl === '') { - return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+did+not+return+approval+url']); - } - - try { - $upd = $db->prepare(" - UPDATE ac_store_orders - SET payment_provider = 'paypal', payment_ref = :payment_ref, updated_at = NOW() - WHERE id = :id - "); - $upd->execute([ - ':payment_ref' => $paypalOrderId, - ':id' => $orderId, - ]); - } catch (Throwable $e) { - } - - return new Response('', 302, ['Location' => $approvalUrl]); - } - - $_SESSION['ac_last_order_no'] = $orderNo; - $status = $testMode ? 'paid' : 'pending'; - $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, $status); - $this->sendOrderEmail($email, $orderNo, $currency, $total, $items, $status, $downloadLinksHtml); - if ($testMode) { - if ($appliedDiscountCode !== '') { - $this->bumpDiscountUsage($db, $appliedDiscountCode); - } - $this->rebuildSalesChartCache(); - $_SESSION['ac_cart'] = []; - unset($_SESSION['ac_discount_code']); - return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); - } - return new Response('', 302, ['Location' => '/checkout?error=No+live+payment+gateway+is+enabled']); - } catch (Throwable $e) { - if ($db->inTransaction()) { - $db->rollBack(); - } - return new Response('', 302, ['Location' => '/checkout']); - } - } - - public function checkoutPaypalReturn(): Response - { - $paypalOrderId = trim((string)($_GET['token'] ?? '')); - if ($paypalOrderId === '') { - return new Response('', 302, ['Location' => '/checkout?error=Missing+PayPal+order+token']); + if ((float)$context['total'] <= 0.0) { + return $this->checkoutPlace(); } $db = Database::get(); @@ -1915,948 +2406,1503 @@ class StoreController return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']); } - $orderStmt = $db->prepare(" - SELECT id, order_no, email, status, currency, total, discount_code - FROM ac_store_orders - WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref - LIMIT 1 - "); - $orderStmt->execute([':payment_ref' => $paypalOrderId]); - $order = $orderStmt->fetch(PDO::FETCH_ASSOC); - if (!$order) { - return new Response('', 302, ['Location' => '/checkout?error=Order+not+found+for+PayPal+payment']); + $order = $this->createPendingOrder($db, $context, 'paypal'); + if (!($order['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($order['error'] ?? 'CARD_START_ORDER_FAIL'))]); } - $orderId = (int)($order['id'] ?? 0); - $orderNo = (string)($order['order_no'] ?? ''); - $email = (string)($order['email'] ?? ''); - $status = (string)($order['status'] ?? 'pending'); - if ($orderId <= 0 || $orderNo === '') { - return new Response('', 302, ['Location' => '/checkout?error=Invalid+order+record']); - } - - if ($status === 'paid') { - if (session_status() !== PHP_SESSION_ACTIVE) { - session_start(); - } - $_SESSION['ac_last_order_no'] = $orderNo; - $_SESSION['ac_cart'] = []; - unset($_SESSION['ac_discount_code']); - return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); - } - - $clientId = trim((string)Settings::get('store_paypal_client_id', '')); - $secret = trim((string)Settings::get('store_paypal_secret', '')); - if ($clientId === '' || $secret === '') { - return new Response('', 302, ['Location' => '/checkout?error=PayPal+credentials+missing']); - } - - $capture = $this->paypalCaptureOrder( - $clientId, - $secret, - $this->isEnabledSetting(Settings::get('store_test_mode', '1')), - $paypalOrderId - ); - if (!($capture['ok'] ?? false)) { - return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($capture['error'] ?? 'PayPal capture failed'))]); - } - - try { - $captureRef = trim((string)($capture['capture_id'] ?? '')); - $upd = $db->prepare(" - UPDATE ac_store_orders - SET status = 'paid', payment_ref = :payment_ref, updated_at = NOW() - WHERE id = :id - "); - $upd->execute([ - ':id' => $orderId, - ':payment_ref' => $captureRef !== '' ? $captureRef : $paypalOrderId, - ]); - } catch (Throwable $e) { - } - - $itemsForEmail = $this->orderItemsForEmail($db, $orderId); - $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, 'paid'); - $discountCode = trim((string)($order['discount_code'] ?? '')); - if ($discountCode !== '') { - $this->bumpDiscountUsage($db, $discountCode); - } - $this->rebuildSalesChartCache(); - $this->sendOrderEmail( - $email, - $orderNo, - (string)($order['currency'] ?? 'GBP'), - (float)($order['total'] ?? 0), - $itemsForEmail, - 'paid', - $downloadLinksHtml - ); - if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } - $_SESSION['ac_last_order_no'] = $orderNo; - $_SESSION['ac_cart'] = []; - unset($_SESSION['ac_discount_code']); - return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); + $_SESSION['ac_checkout_card_email'] = $email; + $_SESSION['ac_checkout_card_terms'] = 1; + return new Response('', 302, ['Location' => '/checkout/card']); } - public function checkoutPaypalCancel(): Response + public function checkoutCard(): Response { - return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+was+cancelled']); - } - - public function download(): Response - { - $token = trim((string)($_GET['token'] ?? '')); - if ($token === '') { - return new Response('Invalid download token.', 400); - } - $db = Database::get(); - if (!($db instanceof PDO)) { - return new Response('Download service unavailable.', 500); - } - $this->ensureAnalyticsSchema(); - try { - $stmt = $db->prepare(" - SELECT t.id, t.order_id, t.order_item_id, t.file_id, t.email, t.download_limit, t.downloads_used, t.expires_at, - f.file_url, f.file_name, f.mime_type, o.status AS order_status - FROM ac_store_download_tokens t - JOIN ac_store_files f ON f.id = t.file_id - JOIN ac_store_orders o ON o.id = t.order_id - WHERE t.token = :token - LIMIT 1 - "); - $stmt->execute([':token' => $token]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$row) { - return new Response('Download link is invalid.', 404); - } - $orderStatus = strtolower(trim((string)($row['order_status'] ?? ''))); - if ($orderStatus !== 'paid') { - return new Response('Downloads are no longer available for this order.', 410); - } - - $limit = (int)($row['download_limit'] ?? 0); - $used = (int)($row['downloads_used'] ?? 0); - if ($limit > 0 && $used >= $limit) { - return new Response('Download limit reached.', 410); - } - - $expiresAt = (string)($row['expires_at'] ?? ''); - if ($expiresAt !== '') { - try { - if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) { - return new Response('Download link expired.', 410); - } - } catch (Throwable $e) { - } - } - - $relative = ltrim((string)($row['file_url'] ?? ''), '/'); - if ($relative === '' || str_contains($relative, '..')) { - return new Response('Invalid file path.', 400); - } - $root = rtrim((string)Settings::get('store_private_root', $this->privateRoot()), '/'); - $path = $root . '/' . $relative; - if (!is_file($path) || !is_readable($path)) { - return new Response('File not found.', 404); - } - - $upd = $db->prepare("UPDATE ac_store_download_tokens SET downloads_used = downloads_used + 1 WHERE id = :id"); - $upd->execute([':id' => (int)$row['id']]); - try { - $evt = $db->prepare(" - INSERT INTO ac_store_download_events - (token_id, order_id, order_item_id, file_id, email, ip_address, user_agent, downloaded_at) - VALUES (:token_id, :order_id, :order_item_id, :file_id, :email, :ip_address, :user_agent, NOW()) - "); - $evt->execute([ - ':token_id' => (int)($row['id'] ?? 0), - ':order_id' => (int)($row['order_id'] ?? 0), - ':order_item_id' => (int)($row['order_item_id'] ?? 0), - ':file_id' => (int)($row['file_id'] ?? 0), - ':email' => (string)($row['email'] ?? ''), - ':ip_address' => $this->clientIp(), - ':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), - ]); - } catch (Throwable $e) { - } - - $fileName = (string)($row['file_name'] ?? basename($path)); - $mime = (string)($row['mime_type'] ?? ''); - if ($mime === '') { - $mime = 'application/octet-stream'; - } - header('Content-Type: ' . $mime); - header('Content-Length: ' . (string)filesize($path)); - header('Content-Disposition: attachment; filename="' . str_replace('"', '', $fileName) . '"'); - readfile($path); - exit; - } catch (Throwable $e) { - return new Response('Download failed.', 500); - } - } - - private function guard(): ?Response - { - if (!Auth::check()) { - return new Response('', 302, ['Location' => '/admin/login']); - } - if (!Auth::hasRole(['admin', 'manager'])) { - return new Response('', 302, ['Location' => '/admin']); - } - return null; - } - - private function ensureAnalyticsSchema(): void - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return; - } - $this->ensureSalesChartSchema(); - try { - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_download_events ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - token_id INT UNSIGNED NOT NULL, - order_id INT UNSIGNED NOT NULL, - order_item_id INT UNSIGNED NOT NULL, - file_id INT UNSIGNED NOT NULL, - email VARCHAR(190) NULL, - ip_address VARCHAR(64) NULL, - user_agent VARCHAR(255) NULL, - downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - KEY idx_store_download_events_order (order_id), - KEY idx_store_download_events_item (order_item_id), - KEY idx_store_download_events_ip (ip_address) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - } catch (Throwable $e) { - } - try { - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_login_tokens ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - email VARCHAR(190) NOT NULL, - token_hash CHAR(64) NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - used_at DATETIME NULL, - request_ip VARCHAR(64) NULL, - request_user_agent VARCHAR(255) NULL, - used_ip VARCHAR(64) NULL, - used_user_agent VARCHAR(255) NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - KEY idx_store_login_tokens_email (email), - KEY idx_store_login_tokens_expires (expires_at) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_ip VARCHAR(64) NULL AFTER is_active"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_user_agent VARCHAR(255) NULL AFTER last_order_ip"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_seen_at DATETIME NULL AFTER last_order_user_agent"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_customers ADD COLUMN orders_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_seen_at"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_customers ADD COLUMN total_spent DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER orders_count"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total"); - } catch (Throwable $e) { - } - try { - $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code"); - } catch (Throwable $e) { - } - } - - private function tablesReady(): bool - { - $db = Database::get(); - if (!$db instanceof PDO) { - return false; - } - try { - $db->query("SELECT 1 FROM ac_store_release_products LIMIT 1"); - $db->query("SELECT 1 FROM ac_store_track_products LIMIT 1"); - $db->query("SELECT 1 FROM ac_store_files LIMIT 1"); - $db->query("SELECT 1 FROM ac_store_orders LIMIT 1"); - return true; - } catch (Throwable $e) { - return false; - } - } - - private function privateRoot(): string - { - return '/home/audiocore.site/private_downloads'; - } - - private function ensurePrivateRoot(string $path): bool - { - $path = rtrim($path, '/'); - if ($path === '') { - return false; - } - if (!is_dir($path) && !mkdir($path, 0755, true)) { - return false; - } - if (!is_writable($path)) { - return false; - } - $tracks = $path . '/tracks'; - if (!is_dir($tracks) && !mkdir($tracks, 0755, true)) { - return false; - } - return is_writable($tracks); - } - - private function privateRootReady(): bool - { - $path = Settings::get('store_private_root', $this->privateRoot()); - $path = rtrim($path, '/'); - if ($path === '' || !is_dir($path) || !is_writable($path)) { - return false; - } - $tracks = $path . '/tracks'; - return is_dir($tracks) && is_writable($tracks); - } - - private function settingsPayload(): array - { - $cronKey = trim((string)Settings::get('store_sales_chart_cron_key', '')); - if ($cronKey === '') { - try { - $cronKey = bin2hex(random_bytes(24)); - Settings::set('store_sales_chart_cron_key', $cronKey); - } catch (Throwable $e) { - $cronKey = ''; - } - } - return [ - 'store_currency' => Settings::get('store_currency', 'GBP'), - 'store_private_root' => Settings::get('store_private_root', $this->privateRoot()), - 'store_download_limit' => Settings::get('store_download_limit', '5'), - 'store_download_expiry_days' => Settings::get('store_download_expiry_days', '30'), - 'store_order_prefix' => Settings::get('store_order_prefix', 'AC-ORD'), - 'store_timezone' => Settings::get('store_timezone', 'UTC'), - 'store_test_mode' => Settings::get('store_test_mode', '1'), - 'store_stripe_enabled' => Settings::get('store_stripe_enabled', '0'), - 'store_stripe_public_key' => Settings::get('store_stripe_public_key', ''), - 'store_stripe_secret_key' => Settings::get('store_stripe_secret_key', ''), - 'store_paypal_enabled' => Settings::get('store_paypal_enabled', '0'), - 'store_paypal_client_id' => Settings::get('store_paypal_client_id', ''), - 'store_paypal_secret' => Settings::get('store_paypal_secret', ''), - 'store_email_logo_url' => Settings::get('store_email_logo_url', ''), - 'store_order_email_subject' => Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'), - 'store_order_email_html' => Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()), - 'store_sales_chart_default_scope' => Settings::get('store_sales_chart_default_scope', 'tracks'), - 'store_sales_chart_default_window' => Settings::get('store_sales_chart_default_window', 'latest'), - 'store_sales_chart_limit' => Settings::get('store_sales_chart_limit', '10'), - 'store_sales_chart_latest_hours' => Settings::get('store_sales_chart_latest_hours', '24'), - 'store_sales_chart_refresh_minutes' => Settings::get('store_sales_chart_refresh_minutes', '180'), - 'store_sales_chart_cron_key' => $cronKey, - ]; - } - - private function normalizeTimezone(string $timezone): string - { - $timezone = trim($timezone); - if ($timezone === '') { - return 'UTC'; - } - return in_array($timezone, \DateTimeZone::listIdentifiers(), true) ? $timezone : 'UTC'; - } - - private function applyStoreTimezone(): void - { - $timezone = $this->normalizeTimezone((string)Settings::get('store_timezone', 'UTC')); - @date_default_timezone_set($timezone); - - $db = Database::get(); - if (!($db instanceof PDO)) { - return; + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); } - try { - $tz = new \DateTimeZone($timezone); - $now = new \DateTimeImmutable('now', $tz); - $offset = $tz->getOffset($now); - $sign = $offset < 0 ? '-' : '+'; - $offset = abs($offset); - $hours = str_pad((string)intdiv($offset, 3600), 2, '0', STR_PAD_LEFT); - $mins = str_pad((string)intdiv($offset % 3600, 60), 2, '0', STR_PAD_LEFT); - $dbTz = $sign . $hours . ':' . $mins; - $db->exec("SET time_zone = '" . $dbTz . "'"); - } catch (Throwable $e) { + $prefillEmail = trim((string)($_SESSION['ac_checkout_card_email'] ?? '')); + $acceptedTerms = ((int)($_SESSION['ac_checkout_card_terms'] ?? 0) === 1); + $context = $this->buildCheckoutContext($prefillEmail !== '' ? $prefillEmail : 'preview@example.com', true); + if (!(bool)($context['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)((string)($context['error'] ?? '') !== '' ? $context['error'] : 'CARD_CONTEXT_FAIL'))]); } - } - - private function ensureSalesChartSchema(): void - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return; + + $settings = $this->settingsPayload(); + $paypalEnabled = $this->isEnabledSetting($settings['store_paypal_enabled'] ?? '0'); + $paypalCardsEnabled = $paypalEnabled && $this->isEnabledSetting($settings['store_paypal_cards_enabled'] ?? '0'); + $paypalCapabilityStatus = (string)($settings['store_paypal_cards_capability_status'] ?? 'unknown'); + $paypalClientToken = ''; + $paypalCardsAvailable = false; + if ($paypalCardsEnabled && $paypalCapabilityStatus === 'available') { + $clientId = trim((string)($settings['store_paypal_client_id'] ?? '')); + $secret = trim((string)($settings['store_paypal_secret'] ?? '')); + if ($clientId !== '' && $secret !== '') { + $token = $this->paypalGenerateClientToken( + $clientId, + $secret, + $this->isEnabledSetting($settings['store_test_mode'] ?? '1') + ); + if ($token['ok'] ?? false) { + $paypalClientToken = (string)($token['client_token'] ?? ''); + $paypalCardsAvailable = $paypalClientToken !== ''; + } + } + } + + if (!$paypalCardsAvailable) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode('CARD_UNAVAILABLE_' . $paypalCapabilityStatus)]); } - try { - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_sales_chart_cache ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - chart_scope ENUM('tracks','releases') NOT NULL, - chart_window ENUM('latest','weekly','all_time') NOT NULL, - rank_no INT UNSIGNED NOT NULL, - item_key VARCHAR(190) NOT NULL, - item_label VARCHAR(255) NOT NULL, - units INT UNSIGNED NOT NULL DEFAULT 0, - revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00, - snapshot_from DATETIME NULL, - snapshot_to DATETIME NULL, - updated_at DATETIME NOT NULL, - UNIQUE KEY uniq_sales_chart_rank (chart_scope, chart_window, rank_no), - KEY idx_sales_chart_item (chart_scope, chart_window, item_key) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - } catch (Throwable $e) { - } - } - - public function rebuildSalesChartCache(): bool - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return false; - } - $this->ensureSalesChartSchema(); - - $latestHours = max(1, min(168, (int)Settings::get('store_sales_chart_latest_hours', '24'))); - $now = new \DateTimeImmutable('now'); - $ranges = [ - 'latest' => [ - 'from' => $now->modify('-' . $latestHours . ' hours')->format('Y-m-d H:i:s'), - 'to' => $now->format('Y-m-d H:i:s'), - ], - 'weekly' => [ - 'from' => $now->modify('-7 days')->format('Y-m-d H:i:s'), - 'to' => $now->format('Y-m-d H:i:s'), - ], - 'all_time' => [ - 'from' => null, - 'to' => $now->format('Y-m-d H:i:s'), - ], - ]; - - $maxRows = max(10, min(100, (int)Settings::get('store_sales_chart_limit', '10') * 2)); - - try { - $db->beginTransaction(); - $delete = $db->prepare("DELETE FROM ac_store_sales_chart_cache WHERE chart_scope = :scope AND chart_window = :window"); - $insert = $db->prepare(" - INSERT INTO ac_store_sales_chart_cache - (chart_scope, chart_window, rank_no, item_key, item_label, units, revenue, snapshot_from, snapshot_to, updated_at) - VALUES (:chart_scope, :chart_window, :rank_no, :item_key, :item_label, :units, :revenue, :snapshot_from, :snapshot_to, :updated_at) - "); - - foreach (['tracks', 'releases'] as $scope) { - foreach ($ranges as $window => $range) { - $delete->execute([':scope' => $scope, ':window' => $window]); - $rows = $scope === 'tracks' - ? $this->salesChartTrackRows($db, $range['from'], $maxRows) - : $this->salesChartReleaseRows($db, $range['from'], $maxRows); - - $rank = 1; - foreach ($rows as $row) { - $itemKey = trim((string)($row['item_key'] ?? '')); - $itemLabel = trim((string)($row['item_label'] ?? '')); - if ($itemLabel === '') { - continue; - } - if ($itemKey === '') { - $itemKey = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $itemLabel) ?? ''); - } - $insert->execute([ - ':chart_scope' => $scope, - ':chart_window' => $window, - ':rank_no' => $rank, - ':item_key' => substr($itemKey, 0, 190), - ':item_label' => substr($itemLabel, 0, 255), - ':units' => max(0, (int)($row['units'] ?? 0)), - ':revenue' => round((float)($row['revenue'] ?? 0), 2), - ':snapshot_from' => $range['from'], - ':snapshot_to' => $range['to'], - ':updated_at' => $now->format('Y-m-d H:i:s'), - ]); - $rank++; - } - } - } - - $db->commit(); - return true; - } catch (Throwable $e) { - if ($db->inTransaction()) { - $db->rollBack(); - } - return false; - } - } - - private function salesChartTrackRows(PDO $db, ?string $from, int $limit): array - { - $sql = " - SELECT - CONCAT('track:', CAST(oi.item_id AS CHAR)) AS item_key, - COALESCE(NULLIF(MAX(rt.title), ''), MAX(oi.title_snapshot)) AS item_label, - SUM(oi.qty) AS units, - SUM(oi.line_total) AS revenue - FROM ac_store_order_items oi - JOIN ac_store_orders o ON o.id = oi.order_id - LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id - WHERE o.status = 'paid' - AND oi.item_type = 'track' - "; - if ($from !== null) { - $sql .= " AND o.created_at >= :from "; - } - $sql .= " - GROUP BY oi.item_id - ORDER BY units DESC, revenue DESC, item_label ASC - LIMIT :lim - "; - $stmt = $db->prepare($sql); - if ($from !== null) { - $stmt->bindValue(':from', $from, PDO::PARAM_STR); - } - $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); - $stmt->execute(); - return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } - - private function salesChartReleaseRows(PDO $db, ?string $from, int $limit): array - { - $sql = " - SELECT - CONCAT('release:', CAST(COALESCE(ri.release_id, r.id, oi.item_id) AS CHAR)) AS item_key, - COALESCE(NULLIF(MAX(r.title), ''), NULLIF(MAX(rr.title), ''), MAX(oi.title_snapshot)) AS item_label, - SUM(oi.qty) AS units, - SUM(oi.line_total) AS revenue - FROM ac_store_order_items oi - JOIN ac_store_orders o ON o.id = oi.order_id - LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id - LEFT JOIN ac_releases rr ON oi.item_type = 'release' AND oi.item_id = rr.id - LEFT JOIN ac_releases r ON rt.release_id = r.id - LEFT JOIN ( - SELECT id, id AS release_id FROM ac_releases - ) ri ON oi.item_type = 'release' AND oi.item_id = ri.id - WHERE o.status = 'paid' - "; - if ($from !== null) { - $sql .= " AND o.created_at >= :from "; - } - $sql .= " - GROUP BY COALESCE(ri.release_id, r.id, oi.item_id) - ORDER BY units DESC, revenue DESC, item_label ASC - LIMIT :lim - "; - $stmt = $db->prepare($sql); - if ($from !== null) { - $stmt->bindValue(':from', $from, PDO::PARAM_STR); - } - $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); - $stmt->execute(); - return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } - - private function salesChartRows(string $scope, string $window, int $limit): array - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return []; - } - try { - $stmt = $db->prepare(" - SELECT rank_no, item_label, units, revenue, snapshot_from, snapshot_to, updated_at - FROM ac_store_sales_chart_cache - WHERE chart_scope = :scope - AND chart_window = :window - ORDER BY rank_no ASC - LIMIT :lim - "); - $stmt->bindValue(':scope', $scope, PDO::PARAM_STR); - $stmt->bindValue(':window', $window, PDO::PARAM_STR); - $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); - $stmt->execute(); - return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } catch (Throwable $e) { - return []; - } - } - - private function salesChartLastRebuildAt(): ?string - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return null; - } - try { - $stmt = $db->query("SELECT MAX(updated_at) AS updated_at FROM ac_store_sales_chart_cache"); - $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; - $value = trim((string)($row['updated_at'] ?? '')); - return $value !== '' ? $value : null; - } catch (Throwable $e) { - return null; - } - } - - private function salesChartCronUrl(): string - { - $base = $this->baseUrl(); - $key = trim((string)Settings::get('store_sales_chart_cron_key', '')); - if ($base === '' || $key === '') { - return ''; - } - return $base . '/store/sales-chart/rebuild?key=' . rawurlencode($key); - } - - private function salesChartCronCommand(): string - { - $url = $this->salesChartCronUrl(); - $minutes = max(5, min(1440, (int)Settings::get('store_sales_chart_refresh_minutes', '180'))); - $step = max(1, (int)floor($minutes / 60)); - $prefix = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *'; - if ($url === '') { - return ''; - } - return $prefix . " /usr/bin/curl -fsS '" . $url . "' >/dev/null 2>&1"; - } - - private function ensureDiscountSchema(): void - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return; - } - try { - $db->exec(" - CREATE TABLE IF NOT EXISTS ac_store_discount_codes ( - id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(64) NOT NULL UNIQUE, - discount_type ENUM('percent','fixed') NOT NULL DEFAULT 'percent', - discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00, - max_uses INT UNSIGNED NOT NULL DEFAULT 0, - used_count INT UNSIGNED NOT NULL DEFAULT 0, - expires_at DATETIME NULL, - is_active TINYINT(1) NOT NULL DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "); - } catch (Throwable $e) { - } - } - - private function adminDiscountRows(): array - { - $db = Database::get(); - if (!($db instanceof PDO)) { - return []; - } - try { - $stmt = $db->query(" - SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at - FROM ac_store_discount_codes - ORDER BY created_at DESC - LIMIT 300 - "); - return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; - } catch (Throwable $e) { - return []; - } - } - - private function loadActiveDiscount(PDO $db, string $code): ?array - { - try { - $stmt = $db->prepare(" - SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active - FROM ac_store_discount_codes - WHERE code = :code - LIMIT 1 - "); - $stmt->execute([':code' => strtoupper(trim($code))]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); - if (!$row) { - return null; - } - if ((int)($row['is_active'] ?? 0) !== 1) { - return null; - } - $maxUses = (int)($row['max_uses'] ?? 0); - $used = (int)($row['used_count'] ?? 0); - if ($maxUses > 0 && $used >= $maxUses) { - return null; - } - $expires = trim((string)($row['expires_at'] ?? '')); - if ($expires !== '') { - try { - if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expires)) { - return null; - } - } catch (Throwable $e) { - return null; - } - } - return $row; - } catch (Throwable $e) { - return null; - } - } - - private function buildCartTotals(array $items, string $discountCode = ''): array - { - $totals = [ - 'count' => 0, - 'subtotal' => 0.0, - 'discount_amount' => 0.0, - 'amount' => 0.0, - 'currency' => Settings::get('store_currency', 'GBP'), - 'discount_code' => '', - ]; - foreach ($items as $item) { - if (!is_array($item)) { - continue; - } - $qty = max(1, (int)($item['qty'] ?? 1)); - $price = (float)($item['price'] ?? 0); - $totals['count'] += $qty; - $totals['subtotal'] += ($price * $qty); - if (!empty($item['currency'])) { - $totals['currency'] = (string)$item['currency']; - } - } - - $discountCode = strtoupper(trim($discountCode)); - if ($discountCode !== '' && $totals['subtotal'] > 0) { - $db = Database::get(); - if ($db instanceof PDO) { - $this->ensureDiscountSchema(); - $discount = $this->loadActiveDiscount($db, $discountCode); - if ($discount) { - $discountType = (string)($discount['discount_type'] ?? 'percent'); - $discountValue = (float)($discount['discount_value'] ?? 0); - if ($discountType === 'fixed') { - $totals['discount_amount'] = min($totals['subtotal'], max(0, round($discountValue, 2))); - } else { - $percent = min(100, max(0, $discountValue)); - $totals['discount_amount'] = min($totals['subtotal'], round($totals['subtotal'] * ($percent / 100), 2)); - } - $totals['discount_code'] = (string)($discount['code'] ?? ''); - } - } - } - - $totals['amount'] = max(0, round($totals['subtotal'] - $totals['discount_amount'], 2)); - return $totals; - } - - private function sendOrderEmail(string $to, string $orderNo, string $currency, float $total, array $items, string $status, string $downloadLinksHtml): void - { - if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { - return; - } - $subjectTpl = (string)Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'); - $htmlTpl = (string)Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()); - $itemsHtml = $this->renderItemsHtml($items, $currency); - $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore')); - $logoUrl = trim((string)Settings::get('store_email_logo_url', '')); - $logoHtml = $logoUrl !== '' - ? '' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '' - : ''; - if ($downloadLinksHtml === '') { - $downloadLinksHtml = $status === 'paid' - ? '

Your download links will be available in your account/downloads area.

' - : '

Download links are sent once payment is confirmed.

'; - } - $map = [ - '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'), - '{{order_no}}' => htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'), - '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'), - '{{currency}}' => htmlspecialchars($currency, ENT_QUOTES, 'UTF-8'), - '{{total}}' => number_format($total, 2), - '{{status}}' => htmlspecialchars($status, ENT_QUOTES, 'UTF-8'), - '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'), - '{{logo_html}}' => $logoHtml, - '{{items_html}}' => $itemsHtml, - '{{download_links_html}}' => $downloadLinksHtml, - ]; - $subject = strtr($subjectTpl, $map); - $html = strtr($htmlTpl, $map); - $mailSettings = [ - 'smtp_host' => Settings::get('smtp_host', ''), - 'smtp_port' => Settings::get('smtp_port', '587'), - 'smtp_user' => Settings::get('smtp_user', ''), - 'smtp_pass' => Settings::get('smtp_pass', ''), - 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), - 'smtp_from_email' => Settings::get('smtp_from_email', ''), - 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'), - ]; - Mailer::send($to, $subject, $html, $mailSettings); - } - - private function sanitizeOrderPrefix(string $prefix): string - { - $prefix = strtoupper(trim($prefix)); - $prefix = preg_replace('/[^A-Z0-9-]+/', '-', $prefix) ?? 'AC-ORD'; - $prefix = trim($prefix, '-'); - if ($prefix === '') { - return 'AC-ORD'; - } - return substr($prefix, 0, 20); - } - - private function provisionDownloadTokens(PDO $db, int $orderId, string $email, string $status): string - { - if ($status !== 'paid') { - return '

Download links are sent once payment is confirmed.

'; - } - $downloadLimit = max(1, (int)Settings::get('store_download_limit', '5')); - $expiryDays = max(1, (int)Settings::get('store_download_expiry_days', '30')); - $expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $expiryDays . ' days')->format('Y-m-d H:i:s'); - - try { - $itemStmt = $db->prepare("SELECT id, item_type, item_id, title_snapshot FROM ac_store_order_items WHERE order_id = :order_id ORDER BY id ASC"); - $itemStmt->execute([':order_id' => $orderId]); - $orderItems = $itemStmt->fetchAll(PDO::FETCH_ASSOC); - if (!$orderItems) { - return '

No downloadable items in this order.

'; - } - - $tokenStmt = $db->prepare(" - INSERT INTO ac_store_download_tokens - (order_id, order_item_id, file_id, email, token, download_limit, downloads_used, expires_at, created_at) - VALUES (:order_id, :order_item_id, :file_id, :email, :token, :download_limit, 0, :expires_at, NOW()) - "); - - $links = []; - foreach ($orderItems as $item) { - $type = (string)($item['item_type'] ?? ''); - $itemId = (int)($item['item_id'] ?? 0); - $orderItemId = (int)($item['id'] ?? 0); - if ($itemId <= 0 || $orderItemId <= 0) { - continue; - } - $files = []; - if ($type === 'release') { - $trackIdsStmt = $db->prepare(" - SELECT t.id - FROM ac_release_tracks t - JOIN ac_store_track_products sp ON sp.release_track_id = t.id - WHERE t.release_id = :release_id AND sp.is_enabled = 1 - ORDER BY t.track_no ASC, t.id ASC - "); - $trackIdsStmt->execute([':release_id' => $itemId]); - $trackIds = array_map(static fn($r) => (int)($r['id'] ?? 0), $trackIdsStmt->fetchAll(PDO::FETCH_ASSOC) ?: []); - $trackIds = array_values(array_filter($trackIds, static fn($id) => $id > 0)); - if ($trackIds) { - $placeholders = implode(',', array_fill(0, count($trackIds), '?')); - $fileStmt = $db->prepare(" - SELECT id, file_name - FROM ac_store_files - WHERE scope_type = 'track' AND scope_id IN ($placeholders) AND is_active = 1 - ORDER BY id DESC - "); - $fileStmt->execute($trackIds); - $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } - } else { - $fileStmt = $db->prepare(" - SELECT id, file_name - FROM ac_store_files - WHERE scope_type = 'track' AND scope_id = :scope_id AND is_active = 1 - ORDER BY id DESC - "); - $fileStmt->execute([':scope_id' => $itemId]); - $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; - } - foreach ($files as $file) { - $fileId = (int)($file['id'] ?? 0); - if ($fileId <= 0) { - continue; - } - $token = bin2hex(random_bytes(24)); - $tokenStmt->execute([ - ':order_id' => $orderId, - ':order_item_id' => $orderItemId, - ':file_id' => $fileId, - ':email' => $email, - ':token' => $token, - ':download_limit' => $downloadLimit, - ':expires_at' => $expiresAt, - ]); - $label = (string)($file['file_name'] ?? $item['title_snapshot'] ?? 'Download'); - $links[] = [ - 'url' => $this->baseUrl() . '/store/download?token=' . rawurlencode($token), - 'label' => $label, - ]; - } - } - if (!$links) { - return '

No downloadable files attached yet.

'; - } - $rows = []; - foreach ($links as $link) { - $rows[] = '
  • ' . htmlspecialchars($link['label'], ENT_QUOTES, 'UTF-8') . '
  • '; - } - return '

    Your Downloads

      ' . implode('', $rows) . '
    '; - } catch (Throwable $e) { - return '

    Download links could not be generated yet.

    '; - } - } - - private function renderItemsHtml(array $items, string $defaultCurrency): string - { - $rows = []; - foreach ($items as $item) { - if (!is_array($item)) { - continue; - } - $title = htmlspecialchars((string)($item['title'] ?? 'Item'), ENT_QUOTES, 'UTF-8'); - $qty = max(1, (int)($item['qty'] ?? 1)); - $price = (float)($item['price'] ?? 0); - $currency = htmlspecialchars((string)($item['currency'] ?? $defaultCurrency), ENT_QUOTES, 'UTF-8'); - $line = number_format($price * $qty, 2); - $rows[] = '' - . '' . $title . '' - . '' . $currency . ' ' . $line . '' - . ''; - } - if (!$rows) { - return '

    No items.

    '; - } - return '' . implode('', $rows) . '
    '; - } - + + return new Response($this->view->render('site/checkout_card.php', [ + 'title' => 'Card Checkout', + 'items' => (array)($context['items'] ?? []), + 'total' => (float)($context['total'] ?? 0), + 'subtotal' => (float)($context['subtotal'] ?? 0), + 'discount_amount' => (float)($context['discount_amount'] ?? 0), + 'discount_code' => (string)($context['discount_code'] ?? ''), + 'currency' => (string)($context['currency'] ?? 'GBP'), + 'email' => $prefillEmail, + 'accept_terms' => $acceptedTerms, + 'download_limit' => (int)Settings::get('store_download_limit', '5'), + 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'), + 'paypal_client_id' => (string)($settings['store_paypal_client_id'] ?? ''), + 'paypal_client_token' => $paypalClientToken, + 'paypal_merchant_country' => (string)($settings['store_paypal_merchant_country'] ?? ''), + 'paypal_card_branding_text' => (string)($settings['store_paypal_card_branding_text'] ?? 'Pay with card'), + ])); + } + + public function checkoutSandbox(): Response + { + return $this->checkoutPlace(); + } + + public function checkoutPlace(): Response + { + if ((string)($_POST['checkout_method'] ?? '') === 'card') { + return $this->checkoutCardStart(); + } + + $email = trim((string)($_POST['email'] ?? '')); + $acceptedTerms = isset($_POST['accept_terms']); + $context = $this->buildCheckoutContext($email, $acceptedTerms); + if (!(bool)($context['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)((string)($context['error'] ?? '') !== '' ? $context['error'] : 'Unable to process checkout.'))]); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']); + } + + $testMode = $this->isEnabledSetting(Settings::get('store_test_mode', '1')); + $paypalEnabled = $this->isEnabledSetting(Settings::get('store_paypal_enabled', '0')); + $clientId = trim((string)Settings::get('store_paypal_client_id', '')); + $secret = trim((string)Settings::get('store_paypal_secret', '')); + + if ((float)$context['total'] <= 0.0) { + $order = $this->createPendingOrder($db, $context, 'discount'); + $result = $this->finalizeOrderAsPaid($db, $this->pendingOrderId($order), 'discount', 'discount-zero-total'); + return new Response('', 302, ['Location' => (string)$result['redirect']]); + } + + if ($paypalEnabled) { + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/checkout?error=PayPal+is+enabled+but+credentials+are+missing']); + } + + $order = $this->createPendingOrder($db, $context, 'paypal'); + $create = $this->paypalCreateOrder( + $clientId, + $secret, + $testMode, + (string)$context['currency'], + (float)$context['total'], + (string)$order['order_no'], + $this->baseUrl() . '/checkout/paypal/return', + $this->baseUrl() . '/checkout/paypal/cancel' + ); + if (!($create['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($create['error'] ?? 'Unable+to+start+PayPal+checkout'))]); + } + + $paypalOrderId = trim((string)($create['order_id'] ?? '')); + $approvalUrl = trim((string)($create['approval_url'] ?? '')); + if ($paypalOrderId === '' || $approvalUrl === '') { + return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+did+not+return+approval+details']); + } + + $upd = $db->prepare(" + UPDATE ac_store_orders + SET payment_provider = 'paypal', payment_ref = :payment_ref, updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':payment_ref' => $paypalOrderId, + ':id' => $this->pendingOrderId($order), + ]); + $this->rememberPendingOrder($this->pendingOrderId($order), (string)$order['order_no'], (string)$context['fingerprint'], $paypalOrderId); + + return new Response('', 302, ['Location' => $approvalUrl]); + } + + if ($testMode) { + $order = $this->createPendingOrder($db, $context, 'test'); + $result = $this->finalizeOrderAsPaid($db, $this->pendingOrderId($order), 'test', 'test'); + return new Response('', 302, ['Location' => (string)$result['redirect']]); + } + + return new Response('', 302, ['Location' => '/checkout?error=No+live+payment+gateway+is+enabled']); + } + + public function checkoutPaypalCreateOrder(): Response + { + try { + $payload = $this->requestPayload(); + $this->checkoutDebugLog('paypal_create_entry', ['payload' => $payload]); + $email = trim((string)($payload['email'] ?? '')); + $acceptedTerms = $this->truthy($payload['accept_terms'] ?? false); + $context = $this->buildCheckoutContext($email, $acceptedTerms); + if (!(bool)($context['ok'] ?? false)) { + $error = (string)($context['error'] ?? 'Unable to validate checkout.'); + $this->checkoutDebugLog('paypal_create_context_error', ['error' => $error, 'ok' => $context['ok'] ?? null]); + return $this->jsonResponse(['ok' => false, 'error' => $error], 422); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->jsonResponse(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + + if ((float)$context['total'] <= 0.0) { + $order = $this->createPendingOrder($db, $context, 'discount'); + $result = $this->finalizeOrderAsPaid($db, $this->pendingOrderId($order), 'discount', 'discount-zero-total'); + return $this->jsonResponse([ + 'ok' => true, + 'completed' => true, + 'redirect' => (string)$result['redirect'], + 'order_no' => (string)$order['order_no'], + ]); + } + + $settings = $this->settingsPayload(); + if (!$this->isEnabledSetting($settings['store_paypal_enabled'] ?? '0')) { + return $this->jsonResponse(['ok' => false, 'error' => 'PayPal is not enabled.'], 422); + } + + $clientId = trim((string)($settings['store_paypal_client_id'] ?? '')); + $secret = trim((string)($settings['store_paypal_secret'] ?? '')); + if ($clientId === '' || $secret === '') { + return $this->jsonResponse(['ok' => false, 'error' => 'PayPal credentials are missing.'], 422); + } + + $existing = $this->reusablePendingOrder($db, (string)$context['fingerprint']); + if ($existing && trim((string)($existing['payment_ref'] ?? '')) !== '') { + return $this->jsonResponse([ + 'ok' => true, + 'local_order_id' => (int)($existing['id'] ?? 0), + 'order_no' => (string)($existing['order_no'] ?? ''), + 'paypal_order_id' => (string)($existing['payment_ref'] ?? ''), + 'orderID' => (string)($existing['payment_ref'] ?? ''), + ]); + } + + $order = $this->createPendingOrder($db, $context, 'paypal'); + $create = $this->paypalCreateOrder( + $clientId, + $secret, + $this->isEnabledSetting($settings['store_test_mode'] ?? '1'), + (string)$context['currency'], + (float)$context['total'], + (string)$order['order_no'], + $this->baseUrl() . '/checkout/paypal/return', + $this->baseUrl() . '/checkout/paypal/cancel' + ); + if (!($create['ok'] ?? false)) { + $this->checkoutDebugLog('paypal_create_fail', ['error' => (string)($create['error'] ?? 'Unable to start PayPal checkout.')]); + return $this->jsonResponse(['ok' => false, 'error' => (string)($create['error'] ?? 'Unable to start PayPal checkout.')], 422); + } + + $paypalOrderId = trim((string)($create['order_id'] ?? '')); + if ($paypalOrderId === '') { + return $this->jsonResponse(['ok' => false, 'error' => 'PayPal did not return an order id.'], 422); + } + + $upd = $db->prepare(" + UPDATE ac_store_orders + SET payment_provider = 'paypal', payment_ref = :payment_ref, updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':payment_ref' => $paypalOrderId, + ':id' => $this->pendingOrderId($order), + ]); + $this->rememberPendingOrder($this->pendingOrderId($order), (string)$order['order_no'], (string)$context['fingerprint'], $paypalOrderId); + + $this->checkoutDebugLog('paypal_create_ok', ['paypal_order_id' => $paypalOrderId, 'local_order_id' => $this->pendingOrderId($order)]); + return $this->jsonResponse([ + 'ok' => true, + 'local_order_id' => $this->pendingOrderId($order), + 'order_no' => (string)$order['order_no'], + 'paypal_order_id' => $paypalOrderId, + 'orderID' => $paypalOrderId, + 'approval_url' => (string)($create['approval_url'] ?? ''), + ]); + } catch (Throwable $e) { + $this->checkoutDebugLog('paypal_create_exception', ['message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()]); + return $this->jsonResponse(['ok' => false, 'error' => 'Server exception during PayPal order creation.'], 500); + } + } + + public function checkoutPaypalCaptureJson(): Response + { + $payload = $this->requestPayload(); + $paypalOrderId = trim((string)($payload['paypal_order_id'] ?? ($payload['orderID'] ?? ''))); + if ($paypalOrderId === '') { + return $this->jsonResponse(['ok' => false, 'error' => 'Missing PayPal order id.'], 422); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return $this->jsonResponse(['ok' => false, 'error' => 'Database unavailable.'], 500); + } + + $stmt = $db->prepare(" + SELECT id, order_no, status + FROM ac_store_orders + WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref + LIMIT 1 + "); + $stmt->execute([':payment_ref' => $paypalOrderId]); + $order = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$order) { + return $this->jsonResponse(['ok' => false, 'error' => 'Order not found for PayPal payment.'], 404); + } + + if (strtolower((string)($order['status'] ?? 'pending')) === 'paid') { + $this->clearPendingOrder(); + return $this->jsonResponse([ + 'ok' => true, + 'completed' => true, + 'redirect' => '/checkout?success=1&order_no=' . rawurlencode((string)($order['order_no'] ?? '')), + 'order_no' => (string)($order['order_no'] ?? ''), + ]); + } + + $settings = $this->settingsPayload(); + $clientId = trim((string)($settings['store_paypal_client_id'] ?? '')); + $secret = trim((string)($settings['store_paypal_secret'] ?? '')); + if ($clientId === '' || $secret === '') { + return $this->jsonResponse(['ok' => false, 'error' => 'PayPal credentials are missing.'], 422); + } + + $capture = $this->paypalCaptureOrder( + $clientId, + $secret, + $this->isEnabledSetting($settings['store_test_mode'] ?? '1'), + $paypalOrderId + ); + if (!($capture['ok'] ?? false)) { + return $this->jsonResponse(['ok' => false, 'error' => (string)($capture['error'] ?? 'PayPal capture failed.')], 422); + } + + $paymentRef = trim((string)($capture['capture_id'] ?? '')); + $result = $this->finalizeOrderAsPaid($db, (int)($order['id'] ?? 0), 'paypal', $paymentRef !== '' ? $paymentRef : $paypalOrderId, (array)($capture['payment_breakdown'] ?? [])); + return $this->jsonResponse([ + 'ok' => true, + 'completed' => true, + 'redirect' => (string)$result['redirect'], + 'order_no' => (string)$result['order_no'], + ]); + } + + public function checkoutPaypalReturn(): Response + { + $paypalOrderId = trim((string)($_GET['token'] ?? '')); + if ($paypalOrderId === '') { + return new Response('', 302, ['Location' => '/checkout?error=Missing+PayPal+order+token']); + } + + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']); + } + + $orderStmt = $db->prepare(" + SELECT id, order_no, email, status, currency, total, discount_code + FROM ac_store_orders + WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref + LIMIT 1 + "); + $orderStmt->execute([':payment_ref' => $paypalOrderId]); + $order = $orderStmt->fetch(PDO::FETCH_ASSOC); + if (!$order) { + return new Response('', 302, ['Location' => '/checkout?error=Order+not+found+for+PayPal+payment']); + } + + $orderId = (int)($order['id'] ?? 0); + $orderNo = (string)($order['order_no'] ?? ''); + $email = (string)($order['email'] ?? ''); + $status = (string)($order['status'] ?? 'pending'); + if ($orderId <= 0 || $orderNo === '') { + return new Response('', 302, ['Location' => '/checkout?error=Invalid+order+record']); + } + + if ($status === 'paid') { + $this->clearPendingOrder(); + return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]); + } + + $settings = $this->settingsPayload(); + $clientId = trim((string)($settings['store_paypal_client_id'] ?? '')); + $secret = trim((string)($settings['store_paypal_secret'] ?? '')); + if ($clientId === '' || $secret === '') { + return new Response('', 302, ['Location' => '/checkout?error=PayPal+credentials+missing']); + } + + $capture = $this->paypalCaptureOrder( + $clientId, + $secret, + $this->isEnabledSetting($settings['store_test_mode'] ?? '1'), + $paypalOrderId + ); + if (!($capture['ok'] ?? false)) { + return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($capture['error'] ?? 'PayPal capture failed'))]); + } + + $captureRef = trim((string)($capture['capture_id'] ?? '')); + $result = $this->finalizeOrderAsPaid($db, $orderId, 'paypal', $captureRef !== '' ? $captureRef : $paypalOrderId, (array)($capture['payment_breakdown'] ?? [])); + return new Response('', 302, ['Location' => (string)$result['redirect']]); + } + + public function checkoutPaypalCancel(): Response + { + $this->clearPendingOrder(); + return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+was+cancelled']); + } + + public function download(): Response + { + $token = trim((string)($_GET['token'] ?? '')); + if ($token === '') { + return new Response('Invalid download token.', 400); + } + $db = Database::get(); + if (!($db instanceof PDO)) { + return new Response('Download service unavailable.', 500); + } + $this->ensureAnalyticsSchema(); + try { + $stmt = $db->prepare(" + SELECT t.id, t.order_id, t.order_item_id, t.file_id, t.email, t.download_limit, t.downloads_used, t.expires_at, + f.file_url, f.file_name, f.mime_type, o.status AS order_status + FROM ac_store_download_tokens t + JOIN ac_store_files f ON f.id = t.file_id + JOIN ac_store_orders o ON o.id = t.order_id + WHERE t.token = :token + LIMIT 1 + "); + $stmt->execute([':token' => $token]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return new Response('Download link is invalid.', 404); + } + $orderStatus = strtolower(trim((string)($row['order_status'] ?? ''))); + if ($orderStatus !== 'paid') { + return new Response('Downloads are no longer available for this order.', 410); + } + + $limit = (int)($row['download_limit'] ?? 0); + $used = (int)($row['downloads_used'] ?? 0); + if ($limit > 0 && $used >= $limit) { + return new Response('Download limit reached.', 410); + } + + $expiresAt = (string)($row['expires_at'] ?? ''); + if ($expiresAt !== '') { + try { + if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) { + return new Response('Download link expired.', 410); + } + } catch (Throwable $e) { + } + } + + $relative = ltrim((string)($row['file_url'] ?? ''), '/'); + if ($relative === '' || str_contains($relative, '..')) { + return new Response('Invalid file path.', 400); + } + $root = rtrim((string)Settings::get('store_private_root', $this->privateRoot()), '/'); + $path = $root . '/' . $relative; + if (!is_file($path) || !is_readable($path)) { + return new Response('File not found.', 404); + } + + $upd = $db->prepare("UPDATE ac_store_download_tokens SET downloads_used = downloads_used + 1 WHERE id = :id"); + $upd->execute([':id' => (int)$row['id']]); + try { + $evt = $db->prepare(" + INSERT INTO ac_store_download_events + (token_id, order_id, order_item_id, file_id, email, ip_address, user_agent, downloaded_at) + VALUES (:token_id, :order_id, :order_item_id, :file_id, :email, :ip_address, :user_agent, NOW()) + "); + $evt->execute([ + ':token_id' => (int)($row['id'] ?? 0), + ':order_id' => (int)($row['order_id'] ?? 0), + ':order_item_id' => (int)($row['order_item_id'] ?? 0), + ':file_id' => (int)($row['file_id'] ?? 0), + ':email' => (string)($row['email'] ?? ''), + ':ip_address' => $this->clientIp(), + ':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), + ]); + } catch (Throwable $e) { + } + + $fileName = (string)($row['file_name'] ?? basename($path)); + $mime = (string)($row['mime_type'] ?? ''); + if ($mime === '') { + $mime = 'application/octet-stream'; + } + header('Content-Type: ' . $mime); + header('Content-Length: ' . (string)filesize($path)); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $fileName) . '"'); + readfile($path); + exit; + } catch (Throwable $e) { + return new Response('Download failed.', 500); + } + } + + private function guard(): ?Response + { + if (!Auth::check()) { + return new Response('', 302, ['Location' => '/admin/login']); + } + if (!Auth::hasRole(['admin', 'manager'])) { + return new Response('', 302, ['Location' => '/admin']); + } + return null; + } + + private function ensureAnalyticsSchema(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + ApiLayer::ensureSchema($db); + $this->ensureSalesChartSchema(); + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_download_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + token_id INT UNSIGNED NOT NULL, + order_id INT UNSIGNED NOT NULL, + order_item_id INT UNSIGNED NOT NULL, + file_id INT UNSIGNED NOT NULL, + email VARCHAR(190) NULL, + ip_address VARCHAR(64) NULL, + user_agent VARCHAR(255) NULL, + downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_download_events_order (order_id), + KEY idx_store_download_events_item (order_item_id), + KEY idx_store_download_events_ip (ip_address) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_login_tokens ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(190) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + request_ip VARCHAR(64) NULL, + request_user_agent VARCHAR(255) NULL, + used_ip VARCHAR(64) NULL, + used_user_agent VARCHAR(255) NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY idx_store_login_tokens_email (email), + KEY idx_store_login_tokens_expires (expires_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_ip VARCHAR(64) NULL AFTER is_active"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_user_agent VARCHAR(255) NULL AFTER last_order_ip"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_seen_at DATETIME NULL AFTER last_order_user_agent"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN orders_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_seen_at"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_customers ADD COLUMN total_spent DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER orders_count"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_gross DECIMAL(10,2) NULL AFTER payment_ref"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_fee DECIMAL(10,2) NULL AFTER payment_gross"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_net DECIMAL(10,2) NULL AFTER payment_fee"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_currency CHAR(3) NULL AFTER payment_net"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_currency"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code"); + } catch (Throwable $e) { + } + try { + $db->exec("ALTER TABLE ac_store_order_items MODIFY item_type ENUM('release','track','bundle') NOT NULL"); + } catch (Throwable $e) { + } + } + + private function tablesReady(): bool + { + $db = Database::get(); + if (!$db instanceof PDO) { + return false; + } + try { + $db->query("SELECT 1 FROM ac_store_release_products LIMIT 1"); + $db->query("SELECT 1 FROM ac_store_track_products LIMIT 1"); + $db->query("SELECT 1 FROM ac_store_files LIMIT 1"); + $db->query("SELECT 1 FROM ac_store_orders LIMIT 1"); + return true; + } catch (Throwable $e) { + return false; + } + } + + private function privateRoot(): string + { + return '/home/audiocore.site/private_downloads'; + } + + private function resolveOrderItemArtistId(PDO $db, string $itemType, int $itemId): int + { + if ($itemId <= 0) { + return 0; + } + try { + if ($itemType === 'track') { + $stmt = $db->prepare(" + SELECT 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' => $itemId]); + return (int)($stmt->fetchColumn() ?: 0); + } + if ($itemType === 'release') { + $stmt = $db->prepare("SELECT artist_id FROM ac_releases WHERE id = :id LIMIT 1"); + $stmt->execute([':id' => $itemId]); + return (int)($stmt->fetchColumn() ?: 0); + } + } catch (Throwable $e) { + } + return 0; + } + + private function ensurePrivateRoot(string $path): bool + { + $path = rtrim($path, '/'); + if ($path === '') { + return false; + } + if (!is_dir($path) && !mkdir($path, 0755, true)) { + return false; + } + if (!is_writable($path)) { + return false; + } + $tracks = $path . '/tracks'; + if (!is_dir($tracks) && !mkdir($tracks, 0755, true)) { + return false; + } + return is_writable($tracks); + } + + private function privateRootReady(): bool + { + $path = Settings::get('store_private_root', $this->privateRoot()); + $path = rtrim($path, '/'); + if ($path === '' || !is_dir($path) || !is_writable($path)) { + return false; + } + $tracks = $path . '/tracks'; + return is_dir($tracks) && is_writable($tracks); + } + + private function settingsPayload(): array + { + $cronKey = trim((string)Settings::get('store_sales_chart_cron_key', '')); + if ($cronKey === '') { + try { + $cronKey = bin2hex(random_bytes(24)); + Settings::set('store_sales_chart_cron_key', $cronKey); + } catch (Throwable $e) { + $cronKey = ''; + } + } + return [ + 'store_currency' => Settings::get('store_currency', 'GBP'), + 'store_private_root' => Settings::get('store_private_root', $this->privateRoot()), + 'store_download_limit' => Settings::get('store_download_limit', '5'), + 'store_download_expiry_days' => Settings::get('store_download_expiry_days', '30'), + 'store_order_prefix' => Settings::get('store_order_prefix', 'AC-ORD'), + 'store_timezone' => Settings::get('store_timezone', 'UTC'), + 'store_test_mode' => Settings::get('store_test_mode', '1'), + 'store_stripe_enabled' => Settings::get('store_stripe_enabled', '0'), + 'store_stripe_public_key' => Settings::get('store_stripe_public_key', ''), + 'store_stripe_secret_key' => Settings::get('store_stripe_secret_key', ''), + 'store_paypal_enabled' => Settings::get('store_paypal_enabled', '0'), + 'store_paypal_client_id' => Settings::get('store_paypal_client_id', ''), + 'store_paypal_secret' => Settings::get('store_paypal_secret', ''), + 'store_paypal_cards_enabled' => Settings::get('store_paypal_cards_enabled', '0'), + 'store_paypal_sdk_mode' => Settings::get('store_paypal_sdk_mode', 'embedded_fields'), + 'store_paypal_merchant_country' => Settings::get('store_paypal_merchant_country', ''), + 'store_paypal_card_branding_text' => Settings::get('store_paypal_card_branding_text', 'Pay with card'), + 'store_paypal_cards_capability_status' => Settings::get('store_paypal_cards_capability_status', 'unknown'), + 'store_paypal_cards_capability_message' => Settings::get('store_paypal_cards_capability_message', ''), + 'store_paypal_cards_capability_checked_at' => Settings::get('store_paypal_cards_capability_checked_at', ''), + 'store_paypal_cards_capability_mode' => Settings::get('store_paypal_cards_capability_mode', ''), + 'store_email_logo_url' => Settings::get('store_email_logo_url', ''), + 'store_order_email_subject' => Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'), + 'store_order_email_html' => Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()), + 'store_sales_chart_default_scope' => Settings::get('store_sales_chart_default_scope', 'tracks'), + 'store_sales_chart_default_window' => Settings::get('store_sales_chart_default_window', 'latest'), + 'store_sales_chart_limit' => Settings::get('store_sales_chart_limit', '10'), + 'store_sales_chart_latest_hours' => Settings::get('store_sales_chart_latest_hours', '24'), + 'store_sales_chart_refresh_minutes' => Settings::get('store_sales_chart_refresh_minutes', '180'), + 'store_sales_chart_cron_key' => $cronKey, + ]; + } + + private function normalizeTimezone(string $timezone): string + { + $timezone = trim($timezone); + if ($timezone === '') { + return 'UTC'; + } + return in_array($timezone, \DateTimeZone::listIdentifiers(), true) ? $timezone : 'UTC'; + } + + private function applyStoreTimezone(): void + { + $timezone = $this->normalizeTimezone((string)Settings::get('store_timezone', 'UTC')); + @date_default_timezone_set($timezone); + + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + + try { + $tz = new \DateTimeZone($timezone); + $now = new \DateTimeImmutable('now', $tz); + $offset = $tz->getOffset($now); + $sign = $offset < 0 ? '-' : '+'; + $offset = abs($offset); + $hours = str_pad((string)intdiv($offset, 3600), 2, '0', STR_PAD_LEFT); + $mins = str_pad((string)intdiv($offset % 3600, 60), 2, '0', STR_PAD_LEFT); + $dbTz = $sign . $hours . ':' . $mins; + $db->exec("SET time_zone = '" . $dbTz . "'"); + } catch (Throwable $e) { + } + } + + private function ensureSalesChartSchema(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_sales_chart_cache ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + chart_scope ENUM('tracks','releases') NOT NULL, + chart_window ENUM('latest','weekly','all_time') NOT NULL, + rank_no INT UNSIGNED NOT NULL, + item_key VARCHAR(190) NOT NULL, + item_label VARCHAR(255) NOT NULL, + units INT UNSIGNED NOT NULL DEFAULT 0, + revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00, + snapshot_from DATETIME NULL, + snapshot_to DATETIME NULL, + updated_at DATETIME NOT NULL, + UNIQUE KEY uniq_sales_chart_rank (chart_scope, chart_window, rank_no), + KEY idx_sales_chart_item (chart_scope, chart_window, item_key) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + } + + public function rebuildSalesChartCache(): bool + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return false; + } + $this->ensureSalesChartSchema(); + + $latestHours = max(1, min(168, (int)Settings::get('store_sales_chart_latest_hours', '24'))); + $now = new \DateTimeImmutable('now'); + $ranges = [ + 'latest' => [ + 'from' => $now->modify('-' . $latestHours . ' hours')->format('Y-m-d H:i:s'), + 'to' => $now->format('Y-m-d H:i:s'), + ], + 'weekly' => [ + 'from' => $now->modify('-7 days')->format('Y-m-d H:i:s'), + 'to' => $now->format('Y-m-d H:i:s'), + ], + 'all_time' => [ + 'from' => null, + 'to' => $now->format('Y-m-d H:i:s'), + ], + ]; + + $maxRows = max(10, min(100, (int)Settings::get('store_sales_chart_limit', '10') * 2)); + + try { + $db->beginTransaction(); + $delete = $db->prepare("DELETE FROM ac_store_sales_chart_cache WHERE chart_scope = :scope AND chart_window = :window"); + $insert = $db->prepare(" + INSERT INTO ac_store_sales_chart_cache + (chart_scope, chart_window, rank_no, item_key, item_label, units, revenue, snapshot_from, snapshot_to, updated_at) + VALUES (:chart_scope, :chart_window, :rank_no, :item_key, :item_label, :units, :revenue, :snapshot_from, :snapshot_to, :updated_at) + "); + + foreach (['tracks', 'releases'] as $scope) { + foreach ($ranges as $window => $range) { + $delete->execute([':scope' => $scope, ':window' => $window]); + $rows = $scope === 'tracks' + ? $this->salesChartTrackRows($db, $range['from'], $maxRows) + : $this->salesChartReleaseRows($db, $range['from'], $maxRows); + + $rank = 1; + foreach ($rows as $row) { + $itemKey = trim((string)($row['item_key'] ?? '')); + $itemLabel = trim((string)($row['item_label'] ?? '')); + if ($itemLabel === '') { + continue; + } + if ($itemKey === '') { + $itemKey = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $itemLabel) ?? ''); + } + $insert->execute([ + ':chart_scope' => $scope, + ':chart_window' => $window, + ':rank_no' => $rank, + ':item_key' => substr($itemKey, 0, 190), + ':item_label' => substr($itemLabel, 0, 255), + ':units' => max(0, (int)($row['units'] ?? 0)), + ':revenue' => round((float)($row['revenue'] ?? 0), 2), + ':snapshot_from' => $range['from'], + ':snapshot_to' => $range['to'], + ':updated_at' => $now->format('Y-m-d H:i:s'), + ]); + $rank++; + } + } + } + + $db->commit(); + return true; + } catch (Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + return false; + } + } + + private function salesChartTrackRows(PDO $db, ?string $from, int $limit): array + { + $sql = " + SELECT + CONCAT('track:', CAST(oi.item_id AS CHAR)) AS item_key, + COALESCE(NULLIF(MAX(rt.title), ''), MAX(oi.title_snapshot)) AS item_label, + SUM(oi.qty) AS units, + SUM(oi.line_total) AS revenue + FROM ac_store_order_items oi + JOIN ac_store_orders o ON o.id = oi.order_id + LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id + WHERE o.status = 'paid' + AND oi.item_type = 'track' + "; + if ($from !== null) { + $sql .= " AND o.created_at >= :from "; + } + $sql .= " + GROUP BY oi.item_id + ORDER BY units DESC, revenue DESC, item_label ASC + LIMIT :lim + "; + $stmt = $db->prepare($sql); + if ($from !== null) { + $stmt->bindValue(':from', $from, PDO::PARAM_STR); + } + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function salesChartReleaseRows(PDO $db, ?string $from, int $limit): array + { + $sql = " + SELECT + CONCAT('release:', CAST(COALESCE(ri.release_id, r.id, oi.item_id) AS CHAR)) AS item_key, + COALESCE(NULLIF(MAX(r.title), ''), NULLIF(MAX(rr.title), ''), MAX(oi.title_snapshot)) AS item_label, + SUM(oi.qty) AS units, + SUM(oi.line_total) AS revenue + FROM ac_store_order_items oi + JOIN ac_store_orders o ON o.id = oi.order_id + LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id + LEFT JOIN ac_releases rr ON oi.item_type = 'release' AND oi.item_id = rr.id + LEFT JOIN ac_releases r ON rt.release_id = r.id + LEFT JOIN ( + SELECT id, id AS release_id FROM ac_releases + ) ri ON oi.item_type = 'release' AND oi.item_id = ri.id + WHERE o.status = 'paid' + "; + if ($from !== null) { + $sql .= " AND o.created_at >= :from "; + } + $sql .= " + GROUP BY COALESCE(ri.release_id, r.id, oi.item_id) + ORDER BY units DESC, revenue DESC, item_label ASC + LIMIT :lim + "; + $stmt = $db->prepare($sql); + if ($from !== null) { + $stmt->bindValue(':from', $from, PDO::PARAM_STR); + } + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function salesChartRows(string $scope, string $window, int $limit): array + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return []; + } + try { + $stmt = $db->prepare(" + SELECT rank_no, item_label, units, revenue, snapshot_from, snapshot_to, updated_at + FROM ac_store_sales_chart_cache + WHERE chart_scope = :scope + AND chart_window = :window + ORDER BY rank_no ASC + LIMIT :lim + "); + $stmt->bindValue(':scope', $scope, PDO::PARAM_STR); + $stmt->bindValue(':window', $window, PDO::PARAM_STR); + $stmt->bindValue(':lim', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + return []; + } + } + + private function salesChartLastRebuildAt(): ?string + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return null; + } + try { + $stmt = $db->query("SELECT MAX(updated_at) AS updated_at FROM ac_store_sales_chart_cache"); + $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null; + $value = trim((string)($row['updated_at'] ?? '')); + return $value !== '' ? $value : null; + } catch (Throwable $e) { + return null; + } + } + + private function salesChartCronUrl(): string + { + $base = $this->baseUrl(); + $key = trim((string)Settings::get('store_sales_chart_cron_key', '')); + if ($base === '' || $key === '') { + return ''; + } + return $base . '/store/sales-chart/rebuild?key=' . rawurlencode($key); + } + + private function salesChartCronCommand(): string + { + $url = $this->salesChartCronUrl(); + $minutes = max(5, min(1440, (int)Settings::get('store_sales_chart_refresh_minutes', '180'))); + $step = max(1, (int)floor($minutes / 60)); + $prefix = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *'; + if ($url === '') { + return ''; + } + return $prefix . " /usr/bin/curl -fsS '" . $url . "' >/dev/null 2>&1"; + } + + private function ensureBundleSchema(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_bundles ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(190) NOT NULL, + slug VARCHAR(190) NOT NULL UNIQUE, + bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + currency CHAR(3) NOT NULL DEFAULT 'GBP', + purchase_label VARCHAR(120) NULL, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_bundle_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + bundle_id INT UNSIGNED NOT NULL, + release_id INT UNSIGNED NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_bundle_release (bundle_id, release_id), + KEY idx_bundle_id (bundle_id), + KEY idx_release_id (release_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + } + + private function adminBundleRows(): array + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return []; + } + try { + $stmt = $db->query(" + SELECT + b.id, b.name, b.slug, b.bundle_price, b.currency, b.purchase_label, b.is_enabled, b.created_at, + COUNT(bi.id) AS release_count + FROM ac_store_bundles b + LEFT JOIN ac_store_bundle_items bi ON bi.bundle_id = b.id + GROUP BY b.id + ORDER BY b.created_at DESC + LIMIT 300 + "); + return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; + } catch (Throwable $e) { + return []; + } + } + + private function bundleReleaseOptions(): array + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return []; + } + try { + $stmt = $db->query(" + SELECT id, title, slug, release_date + FROM ac_releases + ORDER BY release_date DESC, id DESC + LIMIT 1000 + "); + $rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; + $options = []; + foreach ($rows as $row) { + $title = trim((string)($row['title'] ?? '')); + if ($title === '') { + continue; + } + $date = trim((string)($row['release_date'] ?? '')); + $options[] = [ + 'id' => (int)($row['id'] ?? 0), + 'label' => $date !== '' ? ($title . ' (' . $date . ')') : $title, + ]; + } + return $options; + } catch (Throwable $e) { + return []; + } + } + + private function loadBundleForCart(PDO $db, int $bundleId): ?array + { + if ($bundleId <= 0) { + return null; + } + try { + $stmt = $db->prepare(" + SELECT + b.id, + b.name, + b.bundle_price, + b.currency, + b.is_enabled, + COUNT(DISTINCT bi.release_id) AS release_count, + COUNT(DISTINCT t.id) AS track_count + FROM ac_store_bundles b + LEFT JOIN ac_store_bundle_items bi ON bi.bundle_id = b.id + LEFT JOIN ac_release_tracks t ON t.release_id = bi.release_id + LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1 + WHERE b.id = :id + GROUP BY b.id, b.name, b.bundle_price, b.currency, b.is_enabled + LIMIT 1 + "); + $stmt->execute([':id' => $bundleId]); + $bundle = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$bundle || (int)($bundle['is_enabled'] ?? 0) !== 1 || (float)($bundle['bundle_price'] ?? 0) <= 0) { + return null; + } + + $coverStmt = $db->prepare(" + SELECT r.cover_url + FROM ac_store_bundle_items bi + JOIN ac_releases r ON r.id = bi.release_id + WHERE bi.bundle_id = :bundle_id + AND r.is_published = 1 + AND (r.release_date IS NULL OR r.release_date <= :today) + AND r.cover_url IS NOT NULL + AND r.cover_url <> '' + ORDER BY bi.sort_order ASC, bi.id ASC + LIMIT 1 + "); + $coverStmt->execute([ + ':bundle_id' => $bundleId, + ':today' => date('Y-m-d'), + ]); + $coverUrl = (string)($coverStmt->fetchColumn() ?: ''); + $bundle['cover_url'] = $coverUrl; + $bundle['release_count'] = (int)($bundle['release_count'] ?? 0); + $bundle['track_count'] = (int)($bundle['track_count'] ?? 0); + return $bundle; + } catch (Throwable $e) { + return null; + } + } + + private function ensureDiscountSchema(): void + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return; + } + try { + $db->exec(" + CREATE TABLE IF NOT EXISTS ac_store_discount_codes ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + discount_type ENUM('percent','fixed') NOT NULL DEFAULT 'percent', + discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00, + max_uses INT UNSIGNED NOT NULL DEFAULT 0, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + expires_at DATETIME NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + "); + } catch (Throwable $e) { + } + } + + private function adminDiscountRows(): array + { + $db = Database::get(); + if (!($db instanceof PDO)) { + return []; + } + try { + $stmt = $db->query(" + SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at + FROM ac_store_discount_codes + ORDER BY created_at DESC + LIMIT 300 + "); + return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : []; + } catch (Throwable $e) { + return []; + } + } + + private function loadActiveDiscount(PDO $db, string $code): ?array + { + try { + $stmt = $db->prepare(" + SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active + FROM ac_store_discount_codes + WHERE code = :code + LIMIT 1 + "); + $stmt->execute([':code' => strtoupper(trim($code))]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$row) { + return null; + } + if ((int)($row['is_active'] ?? 0) !== 1) { + return null; + } + $maxUses = (int)($row['max_uses'] ?? 0); + $used = (int)($row['used_count'] ?? 0); + if ($maxUses > 0 && $used >= $maxUses) { + return null; + } + $expires = trim((string)($row['expires_at'] ?? '')); + if ($expires !== '') { + try { + if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expires)) { + return null; + } + } catch (Throwable $e) { + return null; + } + } + return $row; + } catch (Throwable $e) { + return null; + } + } + + private function slugify(string $value): string + { + $value = strtolower(trim($value)); + $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? ''; + $value = trim($value, '-'); + return $value !== '' ? $value : 'bundle-' . substr(sha1((string)microtime(true)), 0, 8); + } + + private function buildCartTotals(array $items, string $discountCode = ''): array + { + $totals = [ + 'count' => 0, + 'subtotal' => 0.0, + 'discountable_subtotal' => 0.0, + 'discount_amount' => 0.0, + 'amount' => 0.0, + 'currency' => Settings::get('store_currency', 'GBP'), + 'discount_code' => '', + ]; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $itemType = (string)($item['item_type'] ?? ''); + $totals['count'] += $qty; + $totals['subtotal'] += ($price * $qty); + if ($itemType !== 'bundle') { + $totals['discountable_subtotal'] += ($price * $qty); + } + if (!empty($item['currency'])) { + $totals['currency'] = (string)$item['currency']; + } + } + + $discountCode = strtoupper(trim($discountCode)); + if ($discountCode !== '' && $totals['discountable_subtotal'] > 0) { + $db = Database::get(); + if ($db instanceof PDO) { + $this->ensureDiscountSchema(); + $discount = $this->loadActiveDiscount($db, $discountCode); + if ($discount) { + $discountType = (string)($discount['discount_type'] ?? 'percent'); + $discountValue = (float)($discount['discount_value'] ?? 0); + if ($discountType === 'fixed') { + $totals['discount_amount'] = min($totals['discountable_subtotal'], max(0, round($discountValue, 2))); + } else { + $percent = min(100, max(0, $discountValue)); + $totals['discount_amount'] = min($totals['discountable_subtotal'], round($totals['discountable_subtotal'] * ($percent / 100), 2)); + } + $totals['discount_code'] = (string)($discount['code'] ?? ''); + } + } + } + + $totals['amount'] = max(0, round($totals['subtotal'] - $totals['discount_amount'], 2)); + return $totals; + } + + private function sendOrderEmail(string $to, string $orderNo, string $currency, float $total, array $items, string $status, string $downloadLinksHtml): void + { + if (!filter_var($to, FILTER_VALIDATE_EMAIL)) { + return; + } + $subjectTpl = (string)Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'); + $htmlTpl = (string)Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()); + $itemsHtml = $this->renderItemsHtml($items, $currency); + $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore')); + $logoUrl = trim((string)Settings::get('store_email_logo_url', '')); + $logoHtml = $logoUrl !== '' + ? '' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '' + : ''; + if ($downloadLinksHtml === '') { + $downloadLinksHtml = $status === 'paid' + ? '

    Your download links will be available in your account/downloads area.

    ' + : '

    Download links are sent once payment is confirmed.

    '; + } + $map = [ + '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'), + '{{order_no}}' => htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'), + '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'), + '{{currency}}' => htmlspecialchars($currency, ENT_QUOTES, 'UTF-8'), + '{{total}}' => number_format($total, 2), + '{{status}}' => htmlspecialchars($status, ENT_QUOTES, 'UTF-8'), + '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'), + '{{logo_html}}' => $logoHtml, + '{{items_html}}' => $itemsHtml, + '{{download_links_html}}' => $downloadLinksHtml, + ]; + $subject = strtr($subjectTpl, $map); + $html = strtr($htmlTpl, $map); + $mailSettings = [ + 'smtp_host' => Settings::get('smtp_host', ''), + 'smtp_port' => Settings::get('smtp_port', '587'), + 'smtp_user' => Settings::get('smtp_user', ''), + 'smtp_pass' => Settings::get('smtp_pass', ''), + 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'), + 'smtp_from_email' => Settings::get('smtp_from_email', ''), + 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'), + ]; + Mailer::send($to, $subject, $html, $mailSettings); + } + + private function sanitizeOrderPrefix(string $prefix): string + { + $prefix = strtoupper(trim($prefix)); + $prefix = preg_replace('/[^A-Z0-9-]+/', '-', $prefix) ?? 'AC-ORD'; + $prefix = trim($prefix, '-'); + if ($prefix === '') { + return 'AC-ORD'; + } + return substr($prefix, 0, 20); + } + + private function provisionDownloadTokens(PDO $db, int $orderId, string $email, string $status): string + { + if ($status !== 'paid') { + return '

    Download links are sent once payment is confirmed.

    '; + } + $downloadLimit = max(1, (int)Settings::get('store_download_limit', '5')); + $expiryDays = max(1, (int)Settings::get('store_download_expiry_days', '30')); + $expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $expiryDays . ' days')->format('Y-m-d H:i:s'); + + try { + $itemStmt = $db->prepare("SELECT id, item_type, item_id, title_snapshot FROM ac_store_order_items WHERE order_id = :order_id ORDER BY id ASC"); + $itemStmt->execute([':order_id' => $orderId]); + $orderItems = $itemStmt->fetchAll(PDO::FETCH_ASSOC); + if (!$orderItems) { + return '

    No downloadable items in this order.

    '; + } + + $tokenStmt = $db->prepare(" + INSERT INTO ac_store_download_tokens + (order_id, order_item_id, file_id, email, token, download_limit, downloads_used, expires_at, created_at) + VALUES (:order_id, :order_item_id, :file_id, :email, :token, :download_limit, 0, :expires_at, NOW()) + "); + + $links = []; + foreach ($orderItems as $item) { + $type = (string)($item['item_type'] ?? ''); + $itemId = (int)($item['item_id'] ?? 0); + $orderItemId = (int)($item['id'] ?? 0); + if ($itemId <= 0 || $orderItemId <= 0) { + continue; + } + $files = []; + if ($type === 'release') { + $trackIdsStmt = $db->prepare(" + SELECT t.id + FROM ac_release_tracks t + JOIN ac_store_track_products sp ON sp.release_track_id = t.id + WHERE t.release_id = :release_id AND sp.is_enabled = 1 + ORDER BY t.track_no ASC, t.id ASC + "); + $trackIdsStmt->execute([':release_id' => $itemId]); + $trackIds = array_map(static fn($r) => (int)($r['id'] ?? 0), $trackIdsStmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + $trackIds = array_values(array_filter($trackIds, static fn($id) => $id > 0)); + if ($trackIds) { + $placeholders = implode(',', array_fill(0, count($trackIds), '?')); + $fileStmt = $db->prepare(" + SELECT id, file_name + FROM ac_store_files + WHERE scope_type = 'track' AND scope_id IN ($placeholders) AND is_active = 1 + ORDER BY id DESC + "); + $fileStmt->execute($trackIds); + $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + } elseif ($type === 'bundle') { + $releaseStmt = $db->prepare(" + SELECT release_id + FROM ac_store_bundle_items + WHERE bundle_id = :bundle_id + ORDER BY sort_order ASC, id ASC + "); + $releaseStmt->execute([':bundle_id' => $itemId]); + $releaseIds = array_map(static fn($r) => (int)($r['release_id'] ?? 0), $releaseStmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + $releaseIds = array_values(array_filter($releaseIds, static fn($id) => $id > 0)); + if ($releaseIds) { + $placeholders = implode(',', array_fill(0, count($releaseIds), '?')); + $trackStmt = $db->prepare(" + SELECT t.id + FROM ac_release_tracks t + JOIN ac_store_track_products sp ON sp.release_track_id = t.id + WHERE t.release_id IN ($placeholders) + AND sp.is_enabled = 1 + ORDER BY t.track_no ASC, t.id ASC + "); + $trackStmt->execute($releaseIds); + $trackIds = array_map(static fn($r) => (int)($r['id'] ?? 0), $trackStmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + $trackIds = array_values(array_filter($trackIds, static fn($id) => $id > 0)); + if ($trackIds) { + $trackPh = implode(',', array_fill(0, count($trackIds), '?')); + $fileStmt = $db->prepare(" + SELECT id, file_name + FROM ac_store_files + WHERE scope_type = 'track' AND scope_id IN ($trackPh) AND is_active = 1 + ORDER BY id DESC + "); + $fileStmt->execute($trackIds); + $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + } + } else { + $fileStmt = $db->prepare(" + SELECT id, file_name + FROM ac_store_files + WHERE scope_type = 'track' AND scope_id = :scope_id AND is_active = 1 + ORDER BY id DESC + "); + $fileStmt->execute([':scope_id' => $itemId]); + $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + foreach ($files as $file) { + $fileId = (int)($file['id'] ?? 0); + if ($fileId <= 0) { + continue; + } + $token = bin2hex(random_bytes(24)); + $tokenStmt->execute([ + ':order_id' => $orderId, + ':order_item_id' => $orderItemId, + ':file_id' => $fileId, + ':email' => $email, + ':token' => $token, + ':download_limit' => $downloadLimit, + ':expires_at' => $expiresAt, + ]); + $label = $this->buildDownloadLabel($db, $fileId, (string)($item['title_snapshot'] ?? $file['file_name'] ?? 'Download')); + $links[] = [ + 'url' => $this->baseUrl() . '/store/download?token=' . rawurlencode($token), + 'label' => $label, + ]; + } + } + if (!$links) { + return '

    No downloadable files attached yet.

    '; + } + $rows = []; + foreach ($links as $link) { + $rows[] = '
  • ' . htmlspecialchars($link['label'], ENT_QUOTES, 'UTF-8') . '
  • '; + } + return '

    Your Downloads

      ' . implode('', $rows) . '
    '; + } catch (Throwable $e) { + return '

    Download links could not be generated yet.

    '; + } + } + + private function renderItemsHtml(array $items, string $defaultCurrency): string + { + $rows = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $title = htmlspecialchars((string)($item['title'] ?? 'Item'), ENT_QUOTES, 'UTF-8'); + $itemType = (string)($item['item_type'] ?? 'track'); + $releaseCount = (int)($item['release_count'] ?? 0); + $trackCount = (int)($item['track_count'] ?? 0); + $qty = max(1, (int)($item['qty'] ?? 1)); + $price = (float)($item['price'] ?? 0); + $currency = htmlspecialchars((string)($item['currency'] ?? $defaultCurrency), ENT_QUOTES, 'UTF-8'); + $line = number_format($price * $qty, 2); + $metaHtml = ''; + if ($itemType === 'bundle') { + $parts = []; + if ($releaseCount > 0) { + $parts[] = $releaseCount . ' release' . ($releaseCount === 1 ? '' : 's'); + } + if ($trackCount > 0) { + $parts[] = $trackCount . ' track' . ($trackCount === 1 ? '' : 's'); + } + if ($parts) { + $metaHtml = '
    Includes ' . htmlspecialchars(implode(' - ', $parts), ENT_QUOTES, 'UTF-8') . '
    '; + } + } + $rows[] = '' + . '' . $title . $metaHtml . '' + . '' . $currency . ' ' . $line . '' + . ''; + } + if (!$rows) { + return '

    No items.

    '; + } + return '' . implode('', $rows) . '
    '; + } + private function defaultOrderEmailHtml(): string { return '{{logo_html}}' @@ -2868,522 +3914,758 @@ class StoreController . '

    Total: {{currency}} {{total}}

    '; } + private function setAccountFlash(string $type, string $message): void + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $_SESSION['ac_store_flash_' . $type] = $message; + } + + private function consumeAccountFlash(string $type): string + { + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + $key = 'ac_store_flash_' . $type; + $value = (string)($_SESSION[$key] ?? ''); + unset($_SESSION[$key]); + return $value; + } + private function safeReturnUrl(string $url): string { if ($url === '' || $url[0] !== '/') { return '/releases'; - } - if (str_starts_with($url, '//')) { - return '/releases'; - } - return $url; - } - - private function baseUrl(): string - { - $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443'); - $scheme = $https ? 'https' : 'http'; - $host = (string)($_SERVER['HTTP_HOST'] ?? ''); - if ($host === '') { - return ''; - } - return $scheme . '://' . $host; - } - - private function isItemReleased(PDO $db, string $itemType, int $itemId): bool - { - if ($itemId <= 0) { - return false; - } - try { - if ($itemType === 'release') { - $stmt = $db->prepare(" - SELECT 1 - FROM ac_releases - WHERE id = :id - AND is_published = 1 - AND (release_date IS NULL OR release_date <= :today) - LIMIT 1 - "); - $stmt->execute([ - ':id' => $itemId, - ':today' => date('Y-m-d'), - ]); - return (bool)$stmt->fetch(PDO::FETCH_ASSOC); - } - - $stmt = $db->prepare(" - SELECT 1 - FROM ac_release_tracks t - JOIN ac_releases r ON r.id = t.release_id - WHERE t.id = :id - AND r.is_published = 1 - AND (r.release_date IS NULL OR r.release_date <= :today) - LIMIT 1 - "); - $stmt->execute([ - ':id' => $itemId, - ':today' => date('Y-m-d'), - ]); - return (bool)$stmt->fetch(PDO::FETCH_ASSOC); - } catch (Throwable $e) { - return false; - } - } - - private function logMailDebug(string $ref, array $payload): void - { - try { - $dir = __DIR__ . '/../../storage/logs'; - if (!is_dir($dir)) { - @mkdir($dir, 0755, true); - } - $file = $dir . '/store_mail.log'; - $line = '[' . date('c') . '] ' . $ref . ' ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; - @file_put_contents($file, $line, FILE_APPEND); - } catch (Throwable $e) { - } - } - - private function clientIp(): string - { - $candidates = [ - (string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''), - (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''), - (string)($_SERVER['REMOTE_ADDR'] ?? ''), - ]; - foreach ($candidates as $candidate) { - if ($candidate === '') { - continue; - } - $first = trim(explode(',', $candidate)[0] ?? ''); - if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) { - return $first; - } - } - return ''; - } - - private function loadCustomerIpHistory(PDO $db): array - { - $history = []; - try { - $stmt = $db->query(" - SELECT o.email, o.customer_ip AS ip_address, MAX(o.created_at) AS last_seen - FROM ac_store_orders o - WHERE o.customer_ip IS NOT NULL AND o.customer_ip <> '' - GROUP BY o.email, o.customer_ip - "); - $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; - foreach ($rows as $row) { - $email = strtolower(trim((string)($row['email'] ?? ''))); - $ip = trim((string)($row['ip_address'] ?? '')); - $lastSeen = (string)($row['last_seen'] ?? ''); - if ($email === '' || $ip === '') { - continue; - } - $history[$email][$ip] = $lastSeen; - } - } catch (Throwable $e) { - } - - try { - $stmt = $db->query(" - SELECT t.email, e.ip_address, MAX(e.downloaded_at) AS last_seen - FROM ac_store_download_events e - JOIN ac_store_download_tokens t ON t.id = e.token_id - WHERE e.ip_address IS NOT NULL AND e.ip_address <> '' - GROUP BY t.email, e.ip_address - "); - $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; - foreach ($rows as $row) { - $email = strtolower(trim((string)($row['email'] ?? ''))); - $ip = trim((string)($row['ip_address'] ?? '')); - $lastSeen = (string)($row['last_seen'] ?? ''); - if ($email === '' || $ip === '') { - continue; - } - $existing = $history[$email][$ip] ?? ''; - if ($existing === '' || ($lastSeen !== '' && strcmp($lastSeen, $existing) > 0)) { - $history[$email][$ip] = $lastSeen; - } - } - } catch (Throwable $e) { - } - - $result = []; - foreach ($history as $email => $ips) { - arsort($ips); - $entries = []; - foreach ($ips as $ip => $lastSeen) { - $entries[] = [ - 'ip' => $ip, - 'last_seen' => $lastSeen, - ]; - if (count($entries) >= 5) { - break; - } - } - $result[$email] = $entries; - } - return $result; - } - - private function upsertCustomerFromOrder(PDO $db, string $email, string $ip, string $userAgent, float $orderTotal): int - { - $email = strtolower(trim($email)); - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - return 0; - } - try { - $sel = $db->prepare("SELECT id, orders_count, total_spent FROM ac_store_customers WHERE email = :email LIMIT 1"); - $sel->execute([':email' => $email]); - $row = $sel->fetch(PDO::FETCH_ASSOC); - if ($row) { - $customerId = (int)($row['id'] ?? 0); - $ordersCount = (int)($row['orders_count'] ?? 0); - $totalSpent = (float)($row['total_spent'] ?? 0); - $upd = $db->prepare(" - UPDATE ac_store_customers - SET last_order_ip = :ip, - last_order_user_agent = :ua, - last_seen_at = NOW(), - orders_count = :orders_count, - total_spent = :total_spent, - updated_at = NOW() - WHERE id = :id - "); - $upd->execute([ - ':ip' => $ip !== '' ? $ip : null, - ':ua' => $userAgent !== '' ? $userAgent : null, - ':orders_count' => $ordersCount + 1, - ':total_spent' => $totalSpent + $orderTotal, - ':id' => $customerId, - ]); - return $customerId; - } - - $ins = $db->prepare(" - INSERT INTO ac_store_customers - (name, email, password_hash, is_active, last_order_ip, last_order_user_agent, last_seen_at, orders_count, total_spent, created_at, updated_at) - VALUES (NULL, :email, NULL, 1, :ip, :ua, NOW(), 1, :total_spent, NOW(), NOW()) - "); - $ins->execute([ - ':email' => $email, - ':ip' => $ip !== '' ? $ip : null, - ':ua' => $userAgent !== '' ? $userAgent : null, - ':total_spent' => $orderTotal, - ]); - return (int)$db->lastInsertId(); - } catch (Throwable $e) { - return 0; - } - } - - private function bumpDiscountUsage(PDO $db, string $code): void - { - $code = strtoupper(trim($code)); - if ($code === '') { - return; - } - try { - $stmt = $db->prepare(" - UPDATE ac_store_discount_codes - SET used_count = used_count + 1, updated_at = NOW() - WHERE code = :code - "); - $stmt->execute([':code' => $code]); - } catch (Throwable $e) { - } - } - - private function isEnabledSetting($value): bool - { - if (is_bool($value)) { - return $value; - } - if (is_int($value) || is_float($value)) { - return (int)$value === 1; - } - $normalized = strtolower(trim((string)$value)); - return in_array($normalized, ['1', 'true', 'yes', 'on'], true); - } - - private function paypalTokenProbe(string $clientId, string $secret, bool $sandbox): array - { - $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; - $url = $base . '/v1/oauth2/token'; - $headers = [ - 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), - 'Content-Type: application/x-www-form-urlencoded', - 'Accept: application/json', - ]; - $body = 'grant_type=client_credentials'; - - if (function_exists('curl_init')) { - $ch = curl_init($url); - if ($ch === false) { - return ['ok' => false, 'error' => 'Unable to initialize cURL']; - } - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_TIMEOUT, 15); - $response = (string)curl_exec($ch); - $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlErr = curl_error($ch); - curl_close($ch); - if ($curlErr !== '') { - return ['ok' => false, 'error' => 'PayPal test failed: ' . $curlErr]; - } - $decoded = json_decode($response, true); - if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { - return ['ok' => true]; - } - $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; - return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; - } - - $context = stream_context_create([ - 'http' => [ - 'method' => 'POST', - 'header' => implode("\r\n", $headers), - 'content' => $body, - 'timeout' => 15, - 'ignore_errors' => true, - ], - ]); - $response = @file_get_contents($url, false, $context); - if ($response === false) { - return ['ok' => false, 'error' => 'PayPal test failed: network error']; - } - $statusLine = ''; - if (!empty($http_response_header[0])) { - $statusLine = (string)$http_response_header[0]; - } - preg_match('/\s(\d{3})\s/', $statusLine, $m); - $httpCode = isset($m[1]) ? (int)$m[1] : 0; - $decoded = json_decode($response, true); - if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { - return ['ok' => true]; - } - $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; - return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; - } - - private function paypalCreateOrder( - string $clientId, - string $secret, - bool $sandbox, - string $currency, - float $total, - string $orderNo, - string $returnUrl, - string $cancelUrl - ): array { - $token = $this->paypalAccessToken($clientId, $secret, $sandbox); - if (!($token['ok'] ?? false)) { - return $token; - } - - $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; - $payload = [ - 'intent' => 'CAPTURE', - 'purchase_units' => [[ - 'reference_id' => $orderNo, - 'description' => 'AudioCore order ' . $orderNo, - 'custom_id' => $orderNo, - 'amount' => [ - 'currency_code' => $currency, - 'value' => number_format($total, 2, '.', ''), - ], - ]], - 'application_context' => [ - 'return_url' => $returnUrl, - 'cancel_url' => $cancelUrl, - 'shipping_preference' => 'NO_SHIPPING', - 'user_action' => 'PAY_NOW', - ], - ]; - $res = $this->paypalJsonRequest( - $base . '/v2/checkout/orders', - 'POST', - $payload, - (string)($token['access_token'] ?? '') - ); - if (!($res['ok'] ?? false)) { - return $res; - } - $body = is_array($res['body'] ?? null) ? $res['body'] : []; - $orderId = (string)($body['id'] ?? ''); - $approvalUrl = ''; - foreach ((array)($body['links'] ?? []) as $link) { - if ((string)($link['rel'] ?? '') === 'approve') { - $approvalUrl = (string)($link['href'] ?? ''); - break; - } - } - if ($orderId === '' || $approvalUrl === '') { - return ['ok' => false, 'error' => 'PayPal create order response incomplete']; - } - return [ - 'ok' => true, - 'order_id' => $orderId, - 'approval_url' => $approvalUrl, - ]; - } - - private function paypalCaptureOrder(string $clientId, string $secret, bool $sandbox, string $paypalOrderId): array - { - $token = $this->paypalAccessToken($clientId, $secret, $sandbox); - if (!($token['ok'] ?? false)) { - return $token; - } - $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; - $res = $this->paypalJsonRequest( - $base . '/v2/checkout/orders/' . rawurlencode($paypalOrderId) . '/capture', - 'POST', - new \stdClass(), - (string)($token['access_token'] ?? '') - ); - if (!($res['ok'] ?? false)) { - return $res; - } - $body = is_array($res['body'] ?? null) ? $res['body'] : []; - $status = (string)($body['status'] ?? ''); - if ($status !== 'COMPLETED') { - return ['ok' => false, 'error' => 'PayPal capture status: ' . ($status !== '' ? $status : 'unknown')]; - } - $captureId = ''; - $purchaseUnits = (array)($body['purchase_units'] ?? []); - if (!empty($purchaseUnits[0]['payments']['captures'][0]['id'])) { - $captureId = (string)$purchaseUnits[0]['payments']['captures'][0]['id']; - } - return ['ok' => true, 'capture_id' => $captureId]; - } - - private function paypalRefundCapture( - string $clientId, - string $secret, - bool $sandbox, - string $captureId, - string $currency, - float $total - ): array { - $token = $this->paypalAccessToken($clientId, $secret, $sandbox); - if (!($token['ok'] ?? false)) { - return $token; - } - $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; - $payload = [ - 'amount' => [ - 'currency_code' => $currency, - 'value' => number_format($total, 2, '.', ''), - ], - ]; - $res = $this->paypalJsonRequest( - $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund', - 'POST', - $payload, - (string)($token['access_token'] ?? '') - ); - if (!($res['ok'] ?? false)) { - return $res; - } - $body = is_array($res['body'] ?? null) ? $res['body'] : []; - $status = strtoupper((string)($body['status'] ?? '')); - if (!in_array($status, ['COMPLETED', 'PENDING'], true)) { - return ['ok' => false, 'error' => 'PayPal refund status: ' . ($status !== '' ? $status : 'unknown')]; - } - return ['ok' => true]; - } - - private function paypalAccessToken(string $clientId, string $secret, bool $sandbox): array - { - $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; - $url = $base . '/v1/oauth2/token'; - $headers = [ - 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), - 'Content-Type: application/x-www-form-urlencoded', - 'Accept: application/json', - ]; - $body = 'grant_type=client_credentials'; - if (function_exists('curl_init')) { - $ch = curl_init($url); - if ($ch === false) { - return ['ok' => false, 'error' => 'Unable to initialize cURL']; - } - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $body); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_TIMEOUT, 20); - $response = (string)curl_exec($ch); - $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlErr = curl_error($ch); - curl_close($ch); - if ($curlErr !== '') { - return ['ok' => false, 'error' => 'PayPal auth failed: ' . $curlErr]; - } - $decoded = json_decode($response, true); - if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { - return ['ok' => true, 'access_token' => (string)$decoded['access_token']]; - } - $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; - return ['ok' => false, 'error' => 'PayPal auth rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; - } - return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; - } - - private function paypalJsonRequest(string $url, string $method, $payload, string $accessToken): array - { - if (!function_exists('curl_init')) { - return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; - } - $ch = curl_init($url); - if ($ch === false) { - return ['ok' => false, 'error' => 'Unable to initialize cURL']; - } - $json = json_encode($payload, JSON_UNESCAPED_SLASHES); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); - curl_setopt($ch, CURLOPT_POSTFIELDS, $json); - curl_setopt($ch, CURLOPT_HTTPHEADER, [ - 'Authorization: Bearer ' . $accessToken, - 'Content-Type: application/json', - 'Accept: application/json', - ]); - curl_setopt($ch, CURLOPT_TIMEOUT, 25); - $response = (string)curl_exec($ch); - $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); - $curlErr = curl_error($ch); - curl_close($ch); - if ($curlErr !== '') { - return ['ok' => false, 'error' => 'PayPal request failed: ' . $curlErr]; - } - $decoded = json_decode($response, true); - if ($httpCode >= 200 && $httpCode < 300) { - return ['ok' => true, 'body' => is_array($decoded) ? $decoded : []]; - } - $err = is_array($decoded) ? (string)($decoded['message'] ?? $decoded['name'] ?? '') : ''; - return ['ok' => false, 'error' => 'PayPal API error (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; - } - - private function orderItemsForEmail(PDO $db, int $orderId): array - { - try { - $stmt = $db->prepare(" - SELECT title_snapshot AS title, unit_price_snapshot AS price, qty, currency_snapshot AS currency - FROM ac_store_order_items - WHERE order_id = :order_id - ORDER BY id ASC - "); - $stmt->execute([':order_id' => $orderId]); - $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); - return is_array($rows) ? $rows : []; - } catch (Throwable $e) { - return []; - } - } -} + } + if (str_starts_with($url, '//')) { + return '/releases'; + } + return $url; + } + + private function baseUrl(): string + { + $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443'); + $scheme = $https ? 'https' : 'http'; + $host = (string)($_SERVER['HTTP_HOST'] ?? ''); + if ($host === '') { + return ''; + } + return $scheme . '://' . $host; + } + + private function buildDownloadLabel(PDO $db, int $fileId, string $fallback): string + { + if ($fileId <= 0) { + return $fallback !== '' ? $fallback : 'Download'; + } + + try { + $stmt = $db->prepare(" + SELECT + f.file_name, + t.title AS track_title, + t.mix_name, + COALESCE(NULLIF(r.artist_name, ''), a.name, '') AS artist_name + FROM ac_store_files f + LEFT JOIN ac_release_tracks t + ON f.scope_type = 'track' AND f.scope_id = t.id + LEFT JOIN ac_releases r + ON t.release_id = r.id + LEFT JOIN ac_artists a + ON r.artist_id = a.id + WHERE f.id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $fileId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if ($row) { + $trackTitle = trim((string)($row['track_title'] ?? '')); + $mixName = trim((string)($row['mix_name'] ?? '')); + $artistName = trim((string)($row['artist_name'] ?? '')); + if ($trackTitle !== '' && $mixName !== '') { + $trackTitle .= ' (' . $mixName . ')'; + } + if ($trackTitle !== '' && $artistName !== '') { + return $artistName . ' - ' . $trackTitle; + } + if ($trackTitle !== '') { + return $trackTitle; + } + } + } catch (Throwable $e) { + } + + return $fallback !== '' ? $fallback : 'Download'; + } + + private function isItemReleased(PDO $db, string $itemType, int $itemId): bool + { + if ($itemId <= 0) { + return false; + } + try { + if ($itemType === 'bundle') { + $stmt = $db->prepare(" + SELECT COUNT(*) AS total_rows, + SUM(CASE WHEN r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= :today) THEN 1 ELSE 0 END) AS live_rows + FROM ac_store_bundle_items bi + JOIN ac_releases r ON r.id = bi.release_id + WHERE bi.bundle_id = :id + "); + $stmt->execute([ + ':id' => $itemId, + ':today' => date('Y-m-d'), + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + $totalRows = (int)($row['total_rows'] ?? 0); + $liveRows = (int)($row['live_rows'] ?? 0); + return $totalRows > 0 && $totalRows === $liveRows; + } + + if ($itemType === 'release') { + $stmt = $db->prepare(" + SELECT 1 + FROM ac_releases + WHERE id = :id + AND is_published = 1 + AND (release_date IS NULL OR release_date <= :today) + LIMIT 1 + "); + $stmt->execute([ + ':id' => $itemId, + ':today' => date('Y-m-d'), + ]); + return (bool)$stmt->fetch(PDO::FETCH_ASSOC); + } + + $stmt = $db->prepare(" + SELECT 1 + FROM ac_release_tracks t + JOIN ac_releases r ON r.id = t.release_id + WHERE t.id = :id + AND r.is_published = 1 + AND (r.release_date IS NULL OR r.release_date <= :today) + LIMIT 1 + "); + $stmt->execute([ + ':id' => $itemId, + ':today' => date('Y-m-d'), + ]); + return (bool)$stmt->fetch(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + return false; + } + } + + private function hasDownloadableFiles(PDO $db, string $itemType, int $itemId): bool + { + if ($itemId <= 0) { + return false; + } + try { + if ($itemType === 'bundle') { + $stmt = $db->prepare(" + SELECT 1 + FROM ac_store_bundle_items bi + JOIN ac_release_tracks t ON t.release_id = bi.release_id + JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1 + JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = t.id AND f.is_active = 1 + WHERE bi.bundle_id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $itemId]); + return (bool)$stmt->fetch(PDO::FETCH_ASSOC); + } + + if ($itemType === 'release') { + $stmt = $db->prepare(" + SELECT 1 + FROM ac_release_tracks t + JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1 + JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = t.id AND f.is_active = 1 + WHERE t.release_id = :id + LIMIT 1 + "); + $stmt->execute([':id' => $itemId]); + return (bool)$stmt->fetch(PDO::FETCH_ASSOC); + } + + $stmt = $db->prepare(" + SELECT 1 + FROM ac_store_track_products sp + JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = sp.release_track_id AND f.is_active = 1 + WHERE sp.release_track_id = :id + AND sp.is_enabled = 1 + LIMIT 1 + "); + $stmt->execute([':id' => $itemId]); + return (bool)$stmt->fetch(PDO::FETCH_ASSOC); + } catch (Throwable $e) { + return false; + } + } + + private function logMailDebug(string $ref, array $payload): void + { + try { + $dir = __DIR__ . '/../../storage/logs'; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + $file = $dir . '/store_mail.log'; + $line = '[' . date('c') . '] ' . $ref . ' ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; + @file_put_contents($file, $line, FILE_APPEND); + } catch (Throwable $e) { + } + } + + private function clientIp(): string + { + $candidates = [ + (string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''), + (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''), + (string)($_SERVER['REMOTE_ADDR'] ?? ''), + ]; + foreach ($candidates as $candidate) { + if ($candidate === '') { + continue; + } + $first = trim(explode(',', $candidate)[0] ?? ''); + if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) { + return $first; + } + } + return ''; + } + + private function loadCustomerIpHistory(PDO $db): array + { + $history = []; + try { + $stmt = $db->query(" + SELECT o.email, o.customer_ip AS ip_address, MAX(o.created_at) AS last_seen + FROM ac_store_orders o + WHERE o.customer_ip IS NOT NULL AND o.customer_ip <> '' + GROUP BY o.email, o.customer_ip + "); + $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + foreach ($rows as $row) { + $email = strtolower(trim((string)($row['email'] ?? ''))); + $ip = trim((string)($row['ip_address'] ?? '')); + $lastSeen = (string)($row['last_seen'] ?? ''); + if ($email === '' || $ip === '') { + continue; + } + $history[$email][$ip] = $lastSeen; + } + } catch (Throwable $e) { + } + + try { + $stmt = $db->query(" + SELECT t.email, e.ip_address, MAX(e.downloaded_at) AS last_seen + FROM ac_store_download_events e + JOIN ac_store_download_tokens t ON t.id = e.token_id + WHERE e.ip_address IS NOT NULL AND e.ip_address <> '' + GROUP BY t.email, e.ip_address + "); + $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; + foreach ($rows as $row) { + $email = strtolower(trim((string)($row['email'] ?? ''))); + $ip = trim((string)($row['ip_address'] ?? '')); + $lastSeen = (string)($row['last_seen'] ?? ''); + if ($email === '' || $ip === '') { + continue; + } + $existing = $history[$email][$ip] ?? ''; + if ($existing === '' || ($lastSeen !== '' && strcmp($lastSeen, $existing) > 0)) { + $history[$email][$ip] = $lastSeen; + } + } + } catch (Throwable $e) { + } + + $result = []; + foreach ($history as $email => $ips) { + arsort($ips); + $entries = []; + foreach ($ips as $ip => $lastSeen) { + $entries[] = [ + 'ip' => $ip, + 'last_seen' => $lastSeen, + ]; + if (count($entries) >= 5) { + break; + } + } + $result[$email] = $entries; + } + return $result; + } + + private function upsertCustomerFromOrder(PDO $db, string $email, string $ip, string $userAgent, float $orderTotal): int + { + $email = strtolower(trim($email)); + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + return 0; + } + try { + $sel = $db->prepare("SELECT id, orders_count, total_spent FROM ac_store_customers WHERE email = :email LIMIT 1"); + $sel->execute([':email' => $email]); + $row = $sel->fetch(PDO::FETCH_ASSOC); + if ($row) { + $customerId = (int)($row['id'] ?? 0); + $ordersCount = (int)($row['orders_count'] ?? 0); + $totalSpent = (float)($row['total_spent'] ?? 0); + $upd = $db->prepare(" + UPDATE ac_store_customers + SET last_order_ip = :ip, + last_order_user_agent = :ua, + last_seen_at = NOW(), + orders_count = :orders_count, + total_spent = :total_spent, + updated_at = NOW() + WHERE id = :id + "); + $upd->execute([ + ':ip' => $ip !== '' ? $ip : null, + ':ua' => $userAgent !== '' ? $userAgent : null, + ':orders_count' => $ordersCount + 1, + ':total_spent' => $totalSpent + $orderTotal, + ':id' => $customerId, + ]); + return $customerId; + } + + $ins = $db->prepare(" + INSERT INTO ac_store_customers + (name, email, password_hash, is_active, last_order_ip, last_order_user_agent, last_seen_at, orders_count, total_spent, created_at, updated_at) + VALUES (NULL, :email, NULL, 1, :ip, :ua, NOW(), 1, :total_spent, NOW(), NOW()) + "); + $ins->execute([ + ':email' => $email, + ':ip' => $ip !== '' ? $ip : null, + ':ua' => $userAgent !== '' ? $userAgent : null, + ':total_spent' => $orderTotal, + ]); + return (int)$db->lastInsertId(); + } catch (Throwable $e) { + return 0; + } + } + + private function bumpDiscountUsage(PDO $db, string $code): void + { + $code = strtoupper(trim($code)); + if ($code === '') { + return; + } + try { + $stmt = $db->prepare(" + UPDATE ac_store_discount_codes + SET used_count = used_count + 1, updated_at = NOW() + WHERE code = :code + "); + $stmt->execute([':code' => $code]); + } catch (Throwable $e) { + } + } + + private function isEnabledSetting($value): bool + { + if (is_bool($value)) { + return $value; + } + if (is_int($value) || is_float($value)) { + return (int)$value === 1; + } + $normalized = strtolower(trim((string)$value)); + return in_array($normalized, ['1', 'true', 'yes', 'on'], true); + } + + private function truthy($value): bool + { + return $this->isEnabledSetting($value); + } + + private function requestPayload(): array + { + if ($_POST) { + return is_array($_POST) ? $_POST : []; + } + $raw = file_get_contents('php://input'); + if (!is_string($raw) || trim($raw) === '') { + return []; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : []; + } + + private function paypalCardCapabilityProbe(string $clientId, string $secret, bool $sandbox): array + { + $token = $this->paypalGenerateClientToken($clientId, $secret, $sandbox); + if ($token['ok'] ?? false) { + return [ + 'ok' => true, + 'status' => 'available', + 'message' => 'PayPal embedded card fields are available for this account.', + ]; + } + + return [ + 'ok' => false, + 'status' => 'unavailable', + 'message' => (string)($token['error'] ?? 'Unable to generate a PayPal client token for card fields.'), + ]; + } + + private function paypalGenerateClientToken(string $clientId, string $secret, bool $sandbox): array + { + $token = $this->paypalAccessToken($clientId, $secret, $sandbox); + if (!($token['ok'] ?? false)) { + return $token; + } + + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $res = $this->paypalJsonRequest( + $base . '/v1/identity/generate-token', + 'POST', + new \stdClass(), + (string)($token['access_token'] ?? '') + ); + if (!($res['ok'] ?? false)) { + return [ + 'ok' => false, + 'error' => (string)($res['error'] ?? 'Unable to generate a PayPal client token.'), + ]; + } + + $body = is_array($res['body'] ?? null) ? $res['body'] : []; + $clientToken = trim((string)($body['client_token'] ?? '')); + if ($clientToken === '') { + return [ + 'ok' => false, + 'error' => 'PayPal did not return a client token for card fields.', + ]; + } + + return [ + 'ok' => true, + 'client_token' => $clientToken, + ]; + } + + private function paypalTokenProbe(string $clientId, string $secret, bool $sandbox): array + { + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $url = $base . '/v1/oauth2/token'; + $headers = [ + 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), + 'Content-Type: application/x-www-form-urlencoded', + 'Accept: application/json', + ]; + $body = 'grant_type=client_credentials'; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch === false) { + return ['ok' => false, 'error' => 'Unable to initialize cURL']; + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 15); + $response = (string)curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + if ($curlErr !== '') { + return ['ok' => false, 'error' => 'PayPal test failed: ' . $curlErr]; + } + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { + return ['ok' => true]; + } + $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headers), + 'content' => $body, + 'timeout' => 15, + 'ignore_errors' => true, + ], + ]); + $response = @file_get_contents($url, false, $context); + if ($response === false) { + return ['ok' => false, 'error' => 'PayPal test failed: network error']; + } + $statusLine = ''; + if (!empty($http_response_header[0])) { + $statusLine = (string)$http_response_header[0]; + } + preg_match('/\s(\d{3})\s/', $statusLine, $m); + $httpCode = isset($m[1]) ? (int)$m[1] : 0; + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { + return ['ok' => true]; + } + $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + + private function paypalCreateOrder( + string $clientId, + string $secret, + bool $sandbox, + string $currency, + float $total, + string $orderNo, + string $returnUrl, + string $cancelUrl + ): array { + $token = $this->paypalAccessToken($clientId, $secret, $sandbox); + if (!($token['ok'] ?? false)) { + return $token; + } + + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $payload = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [[ + 'reference_id' => $orderNo, + 'description' => 'AudioCore order ' . $orderNo, + 'custom_id' => $orderNo, + 'amount' => [ + 'currency_code' => $currency, + 'value' => number_format($total, 2, '.', ''), + ], + ]], + 'application_context' => [ + 'return_url' => $returnUrl, + 'cancel_url' => $cancelUrl, + 'shipping_preference' => 'NO_SHIPPING', + 'user_action' => 'PAY_NOW', + ], + ]; + $res = $this->paypalJsonRequest( + $base . '/v2/checkout/orders', + 'POST', + $payload, + (string)($token['access_token'] ?? '') + ); + if (!($res['ok'] ?? false)) { + return $res; + } + $body = is_array($res['body'] ?? null) ? $res['body'] : []; + $orderId = (string)($body['id'] ?? ''); + $approvalUrl = ''; + foreach ((array)($body['links'] ?? []) as $link) { + if ((string)($link['rel'] ?? '') === 'approve') { + $approvalUrl = (string)($link['href'] ?? ''); + break; + } + } + if ($orderId === '' || $approvalUrl === '') { + return ['ok' => false, 'error' => 'PayPal create order response incomplete']; + } + return [ + 'ok' => true, + 'order_id' => $orderId, + 'approval_url' => $approvalUrl, + ]; + } + + private function paypalCaptureOrder(string $clientId, string $secret, bool $sandbox, string $paypalOrderId): array + { + $token = $this->paypalAccessToken($clientId, $secret, $sandbox); + if (!($token['ok'] ?? false)) { + return $token; + } + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $res = $this->paypalJsonRequest( + $base . '/v2/checkout/orders/' . rawurlencode($paypalOrderId) . '/capture', + 'POST', + new \stdClass(), + (string)($token['access_token'] ?? '') + ); + if (!($res['ok'] ?? false)) { + return $res; + } + $body = is_array($res['body'] ?? null) ? $res['body'] : []; + $status = (string)($body['status'] ?? ''); + if ($status !== 'COMPLETED') { + return ['ok' => false, 'error' => 'PayPal capture status: ' . ($status !== '' ? $status : 'unknown')]; + } + $captureId = ''; + $purchaseUnits = (array)($body['purchase_units'] ?? []); + if (!empty($purchaseUnits[0]['payments']['captures'][0]['id'])) { + $captureId = (string)$purchaseUnits[0]['payments']['captures'][0]['id']; + } + $breakdown = $this->paypalCaptureBreakdown($purchaseUnits); + return [ + 'ok' => true, + 'capture_id' => $captureId, + 'payment_breakdown' => $breakdown, + ]; + } + + private function paypalCaptureBreakdown(array $purchaseUnits): array + { + $capture = (array)($purchaseUnits[0]['payments']['captures'][0] ?? []); + $breakdown = (array)($capture['seller_receivable_breakdown'] ?? []); + return [ + 'gross' => $this->paypalMoneyValue((array)($breakdown['gross_amount'] ?? [])), + 'fee' => $this->paypalMoneyValue((array)($breakdown['paypal_fee'] ?? [])), + 'net' => $this->paypalMoneyValue((array)($breakdown['net_amount'] ?? [])), + 'currency' => $this->paypalMoneyCurrency((array)($breakdown['gross_amount'] ?? []), (array)($capture['amount'] ?? [])), + ]; + } + + private function paypalMoneyValue(array $money): ?float + { + $value = trim((string)($money['value'] ?? '')); + return $value !== '' ? (float)$value : null; + } + + private function paypalMoneyCurrency(array ...$candidates): ?string + { + foreach ($candidates as $money) { + $currency = strtoupper(trim((string)($money['currency_code'] ?? ''))); + if ($currency !== '') { + return $currency; + } + } + return null; + } + + private function paypalRefundCapture( + string $clientId, + string $secret, + bool $sandbox, + string $captureId, + string $currency, + float $total + ): array { + $token = $this->paypalAccessToken($clientId, $secret, $sandbox); + if (!($token['ok'] ?? false)) { + return $token; + } + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $payload = [ + 'amount' => [ + 'currency_code' => $currency, + 'value' => number_format($total, 2, '.', ''), + ], + ]; + $res = $this->paypalJsonRequest( + $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund', + 'POST', + $payload, + (string)($token['access_token'] ?? '') + ); + if (!($res['ok'] ?? false)) { + return $res; + } + $body = is_array($res['body'] ?? null) ? $res['body'] : []; + $status = strtoupper((string)($body['status'] ?? '')); + if (!in_array($status, ['COMPLETED', 'PENDING'], true)) { + return ['ok' => false, 'error' => 'PayPal refund status: ' . ($status !== '' ? $status : 'unknown')]; + } + return ['ok' => true]; + } + + private function paypalAccessToken(string $clientId, string $secret, bool $sandbox): array + { + $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + $url = $base . '/v1/oauth2/token'; + $headers = [ + 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), + 'Content-Type: application/x-www-form-urlencoded', + 'Accept: application/json', + ]; + $body = 'grant_type=client_credentials'; + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch === false) { + return ['ok' => false, 'error' => 'Unable to initialize cURL']; + } + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + curl_setopt($ch, CURLOPT_TIMEOUT, 20); + $response = (string)curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + if ($curlErr !== '') { + return ['ok' => false, 'error' => 'PayPal auth failed: ' . $curlErr]; + } + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { + return ['ok' => true, 'access_token' => (string)$decoded['access_token']]; + } + $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal auth rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; + } + + private function paypalJsonRequest(string $url, string $method, $payload, string $accessToken): array + { + if (!function_exists('curl_init')) { + return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; + } + $ch = curl_init($url); + if ($ch === false) { + return ['ok' => false, 'error' => 'Unable to initialize cURL']; + } + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); + curl_setopt($ch, CURLOPT_POSTFIELDS, $json); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Authorization: Bearer ' . $accessToken, + 'Content-Type: application/json', + 'Accept: application/json', + ]); + curl_setopt($ch, CURLOPT_TIMEOUT, 25); + $response = (string)curl_exec($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlErr = curl_error($ch); + curl_close($ch); + if ($curlErr !== '') { + return ['ok' => false, 'error' => 'PayPal request failed: ' . $curlErr]; + } + $decoded = json_decode($response, true); + if ($httpCode >= 200 && $httpCode < 300) { + return ['ok' => true, 'body' => is_array($decoded) ? $decoded : []]; + } + $err = is_array($decoded) ? (string)($decoded['message'] ?? $decoded['name'] ?? '') : ''; + return ['ok' => false, 'error' => 'PayPal API error (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; + } + + private function orderItemsForEmail(PDO $db, int $orderId): array + { + try { + $stmt = $db->prepare(" + SELECT title_snapshot AS title, unit_price_snapshot AS price, qty, currency_snapshot AS currency + FROM ac_store_order_items + WHERE order_id = :order_id + ORDER BY id ASC + "); + $stmt->execute([':order_id' => $orderId]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + return is_array($rows) ? $rows : []; + } catch (Throwable $e) { + return []; + } + } +} diff --git a/plugins/store/plugin.php b/plugins/store/plugin.php index c297d1c..f656692 100644 --- a/plugins/store/plugin.php +++ b/plugins/store/plugin.php @@ -181,11 +181,15 @@ return function (Router $router): void { $router->post('/cart/discount/apply', [$controller, 'cartApplyDiscount']); $router->post('/cart/discount/remove', [$controller, 'cartClearDiscount']); $router->get('/checkout', [$controller, 'checkoutIndex']); + $router->post('/checkout/card/start', [$controller, 'checkoutCardStart']); + $router->get('/checkout/card', [$controller, 'checkoutCard']); $router->get('/account', [$controller, 'accountIndex']); $router->post('/account/request-login', [$controller, 'accountRequestLogin']); $router->get('/account/login', [$controller, 'accountLogin']); $router->get('/account/logout', [$controller, 'accountLogout']); $router->post('/checkout/place', [$controller, 'checkoutPlace']); + $router->post('/checkout/paypal/create-order', [$controller, 'checkoutPaypalCreateOrder']); + $router->post('/checkout/paypal/capture-order', [$controller, 'checkoutPaypalCaptureJson']); $router->get('/checkout/paypal/return', [$controller, 'checkoutPaypalReturn']); $router->get('/checkout/paypal/cancel', [$controller, 'checkoutPaypalCancel']); $router->post('/checkout/sandbox', [$controller, 'checkoutSandbox']); @@ -199,6 +203,8 @@ return function (Router $router): void { $router->post('/admin/store/settings/rebuild-sales-chart', [$controller, 'adminRebuildSalesChart']); $router->post('/admin/store/discounts/create', [$controller, 'adminDiscountCreate']); $router->post('/admin/store/discounts/delete', [$controller, 'adminDiscountDelete']); + $router->post('/admin/store/bundles/create', [$controller, 'adminBundleCreate']); + $router->post('/admin/store/bundles/delete', [$controller, 'adminBundleDelete']); $router->post('/admin/store/settings/test-email', [$controller, 'adminSendTestEmail']); $router->post('/admin/store/settings/test-paypal', [$controller, 'adminTestPaypal']); $router->get('/admin/store/customers', [$controller, 'adminCustomers']); diff --git a/plugins/store/views/admin/customers.php b/plugins/store/views/admin/customers.php index e0de2f7..294be1e 100644 --- a/plugins/store/views/admin/customers.php +++ b/plugins/store/views/admin/customers.php @@ -1,8 +1,11 @@
    @@ -20,6 +23,9 @@ ob_start(); Settings Orders Customers + + Sales Reports +
    @@ -31,6 +39,9 @@ ob_start(); Settings Orders Customers + + Sales Reports +
    @@ -62,8 +73,14 @@ ob_start();
    Paid:
    -
    Revenue
    -
    +
    Before Fees
    +
    +
    Gross paid sales
    +
    +
    +
    After Fees
    +
    +
    PayPal fees:
    Total Customers
    @@ -86,7 +103,11 @@ ob_start();
    - + +
    +
    + Before fees + Fees
    View Order diff --git a/plugins/store/views/admin/order.php b/plugins/store/views/admin/order.php index 3c87ad2..88d9eee 100644 --- a/plugins/store/views/admin/order.php +++ b/plugins/store/views/admin/order.php @@ -1,9 +1,12 @@
    @@ -20,33 +23,45 @@ ob_start(); Settings Orders Customers + + Sales Reports +
    -
    -
    -
    +
    +
    +
    Order Number
    -
    +
    +
    Customer
    -
    -
    Status
    -
    +
    +
    +
    Status
    +
    +
    +
    +
    After Fees
    +
    +
    -
    -
    Total
    -
    +
    +
    +
    +
    Before Fees
    +
    -
    -
    Customer Email
    -
    +
    +
    PayPal Fees
    +
    -
    +
    Order IP
    -
    +
    -
    +
    Created
    -
    +
    @@ -118,6 +133,108 @@ ob_start();
    +
    @@ -21,6 +24,9 @@ ob_start(); Settings Orders Customers + + Sales Reports + @@ -78,7 +84,11 @@ ob_start();
    - + +
    + Before fees + · Fees +
    diff --git a/plugins/store/views/admin/settings.php b/plugins/store/views/admin/settings.php index 25b3133..dfe4f4b 100644 --- a/plugins/store/views/admin/settings.php +++ b/plugins/store/views/admin/settings.php @@ -1,35 +1,51 @@ - +$reportsEnabled = Plugins::isEnabled('advanced-reporting'); +ob_start(); +?>
    Store
    -
    +

    Store Settings

    Configure defaults, payments, and transactional emails.

    - Back -
    - -
    - General + Back +
    + + + + @@ -91,10 +107,10 @@ ob_start();
    - -
    - -
    + + + +
    Payment Mode
    -
    -
    PayPal
    - - -
    PayPal Client ID
    - -
    PayPal Secret
    - - -
    - - - +
    +
    PayPal
    + + +
    PayPal Client ID
    + +
    PayPal Secret
    + + +
    +
    +
    Merchant Country
    + +
    +
    +
    Card Button Label
    + +
    +
    + +
    +
    +
    Card Checkout Mode
    + +
    +
    +
    + + +
    +
    +
    + + +
    +
    Card Capability
    +
    + +
    +
    + +
    + Last checked: +
    + +
    + +
    + + +
    @@ -230,6 +302,89 @@ ob_start();
    + +
    +
    Create Bundle
    + +
    +
    +
    Bundle Name
    + +
    +
    +
    Slug (optional)
    + +
    +
    +
    Bundle Price
    + +
    +
    +
    Currency
    + +
    +
    +
    +
    +
    Button Label (optional)
    + +
    + +
    +
    +
    Releases in Bundle (Ctrl/Cmd-click for multi-select)
    + +
    +
    + +
    + +
    + +
    +
    Existing Bundles
    + +
    No bundles yet.
    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    BundleSlugReleasesPriceStatusAction
    +
    + + +
    +
    +
    + +
    diff --git a/plugins/store/views/site/cart.php b/plugins/store/views/site/cart.php index 24da0d0..df62379 100644 --- a/plugins/store/views/site/cart.php +++ b/plugins/store/views/site/cart.php @@ -21,6 +21,9 @@ ob_start(); $key = (string)($item['key'] ?? ''); $title = (string)($item['title'] ?? 'Item'); $coverUrl = (string)($item['cover_url'] ?? ''); + $itemType = (string)($item['item_type'] ?? 'track'); + $releaseCount = (int)($item['release_count'] ?? 0); + $trackCount = (int)($item['track_count'] ?? 0); $qty = max(1, (int)($item['qty'] ?? 1)); $price = (float)($item['price'] ?? 0); $currency = (string)($item['currency'] ?? ($totals['currency'] ?? 'GBP')); @@ -35,6 +38,18 @@ ob_start();
    + 0 || $trackCount > 0)): ?> +
    + Includes + 0): ?> + release + + 0 && $trackCount > 0): ?> · + 0): ?> + track + +
    +
    x
    diff --git a/plugins/store/views/site/checkout.php b/plugins/store/views/site/checkout.php index 1b94b19..130a853 100644 --- a/plugins/store/views/site/checkout.php +++ b/plugins/store/views/site/checkout.php @@ -1,208 +1,242 @@ - -
    -
    Store
    -

    Checkout

    - -
    -
    Order complete
    - -
    Order:
    - -
    -
    -
    Your Downloads
    - - - -

    - -

    - -
    - - -
    - -
    - - -
    - Your cart is empty. -
    - - -
    -
    -
    Order Summary
    -
    - - -
    -
    -
    x
    -
    -
    - -
    -
    - Subtotal - -
    - 0): ?> -
    - Discount () - - -
    - -
    - Order total - -
    -
    - -
    -
    Buyer Details
    - - - - -
    -
    Terms
    -

    - Digital download products are non-refundable once purchased and delivered. Please check your order details before placing the order. - Files are limited to downloads and expire after days. -

    - -
    - - - -
    -
    - -
    - - +
    +
    Store
    +

    Checkout

    + + +
    +
    Order complete
    + +
    Order:
    + +
    +
    +
    Your Downloads
    + + + +

    + +

    + +
    + + + +
    + + + +
    Your cart is empty.
    + + +
    +
    +
    Order Summary
    +
    + + +
    +
    + 0 || $trackCount > 0)): ?> +
    + Includes + 0): ?> release + 0 && $trackCount > 0): ?> · + 0): ?> track +
    + +
    x
    +
    +
    + +
    +
    + Subtotal + +
    + 0): ?> +
    + Discount () + - +
    + +
    + Order total + +
    +
    + +
    +
    Buyer Details
    +
    + + + +
    +
    Terms
    +

    + Digital download products are non-refundable once purchased and delivered. Please check your order details before placing the order. + Files are limited to downloads and expire after days. +

    + +
    + +
    +
    +
    PayPal
    +
    Pay via PayPal
    +
    You will be redirected to PayPal to approve the payment.
    + +
    + + +
    +
    Cards
    +
    Pay via Credit / Debit Card
    +
    Open a dedicated secure card-payment page powered by PayPal.
    + +
    + +
    +
    +
    +
    + +
    + + + +
    +
    Cards
    +
    +
    +

    Credit / Debit Card

    +

    Secure card payment powered by PayPal. The order completes immediately after capture succeeds.

    +
    + Back to checkout +
    + +
    +
    +
    Buyer Details
    +
    + + + +
    +
    Terms
    +

    + Digital download products are non-refundable once purchased and delivered. Please check your order details before placing the order. + Files are limited to downloads and expire after days. +

    + +
    + + + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + + +
    +
    + +
    +
    Order Summary
    +
    + + +
    +
    +
    x
    +
    +
    + +
    +
    + Subtotal + +
    + 0): ?> +
    + Discount () + - +
    + +
    + Order total + +
    +
    +
    +
    + + + + '/contact?error=Please+enter+a+valid+email']); } + $limitKey = sha1(strtolower($email) . '|' . $this->clientIp()); + if (RateLimiter::tooMany('support_contact_submit', $limitKey, 5, 600)) { + return new Response('', 302, ['Location' => '/contact?error=Too+many+support+requests.+Please+wait+10+minutes']); + } foreach ($requiredFields as $requiredField) { if (($extraValues[(string)$requiredField] ?? '') === '') { return new Response('', 302, ['Location' => '/contact?error=' . urlencode('Please complete all required fields for this support type')]); diff --git a/public/index.php b/public/index.php index 222605d..86c4b20 100644 --- a/public/index.php +++ b/public/index.php @@ -3,6 +3,10 @@ declare(strict_types=1); require_once __DIR__ . '/../core/bootstrap.php'; +if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); +} + $uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $uriPath = is_string($uriPath) && $uriPath !== '' ? $uriPath : '/'; $isAdminRoute = str_starts_with($uriPath, '/admin'); @@ -16,12 +20,33 @@ if ( && !$isAdminRoute && !in_array($uriPath, $maintenanceWhitelist, true) ) { + $maintenancePasswordHash = (string)Core\Services\Settings::get('site_maintenance_access_password_hash', ''); + $maintenanceBypassKey = $maintenancePasswordHash !== '' ? hash('sha256', $maintenancePasswordHash) : ''; + $hasMaintenanceBypass = $maintenanceBypassKey !== '' + && (string)($_SESSION['ac_maintenance_bypass'] ?? '') === $maintenanceBypassKey; + $passwordError = ''; + + if ($maintenancePasswordHash !== '' && !$hasMaintenanceBypass && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + $submittedPassword = (string)($_POST['ac_maintenance_access_password'] ?? ''); + if ($submittedPassword !== '' && password_verify($submittedPassword, $maintenancePasswordHash)) { + $_SESSION['ac_maintenance_bypass'] = $maintenanceBypassKey; + $redirectTo = (string)($_SERVER['REQUEST_URI'] ?? '/'); + (new Core\Http\Response('', 302, ['Location' => $redirectTo !== '' ? $redirectTo : '/']))->send(); + exit; + } + $passwordError = 'Incorrect access password.'; + } + + if ($hasMaintenanceBypass) { + goto maintenance_bypass_complete; + } + $title = Core\Services\Settings::get('site_maintenance_title', 'Coming Soon'); $message = Core\Services\Settings::get('site_maintenance_message', 'We are currently updating the site. Please check back soon.'); $buttonLabel = Core\Services\Settings::get('site_maintenance_button_label', ''); $buttonUrl = Core\Services\Settings::get('site_maintenance_button_url', ''); $customHtml = Core\Services\Settings::get('site_maintenance_html', ''); - $siteTitle = Core\Services\Settings::get('site_title', 'AudioCore V1.5'); + $siteTitle = Core\Services\Settings::get('site_title', 'AudioCore V1.5.1'); $contentHtml = ''; if ($customHtml !== '') { @@ -36,6 +61,18 @@ if ( . htmlspecialchars($buttonLabel, ENT_QUOTES, 'UTF-8') . ''; } + if ($maintenancePasswordHash !== '') { + $contentHtml .= '
    ' + . '' + . '
    ' + . '' + . '' + . '
    '; + if ($passwordError !== '') { + $contentHtml .= '
    ' . htmlspecialchars($passwordError, ENT_QUOTES, 'UTF-8') . '
    '; + } + $contentHtml .= '
    '; + } $contentHtml .= ''; } @@ -50,12 +87,20 @@ if ( . 'p{font-size:18px;line-height:1.7;margin:16px 0 0;color:rgba(235,241,255,.8);}' . '.ac-maintenance-btn{margin-top:20px;display:inline-block;padding:10px 18px;border-radius:999px;border:1px solid rgba(255,255,255,.2);color:#f7f8ff;text-decoration:none;font-size:12px;text-transform:uppercase;letter-spacing:.18em;}' . '.ac-maintenance-btn:hover{background:rgba(255,255,255,.08);}' + . '.ac-maintenance-form{margin-top:24px;padding-top:20px;border-top:1px solid rgba(255,255,255,.12);}' + . '.ac-maintenance-label{display:block;font-family:IBM Plex Mono,monospace;text-transform:uppercase;font-size:11px;letter-spacing:.24em;color:rgba(255,255,255,.7);margin:0 0 10px;}' + . '.ac-maintenance-form-row{display:flex;gap:12px;flex-wrap:wrap;}' + . '.ac-maintenance-input{flex:1 1 260px;min-width:220px;padding:14px 16px;border-radius:14px;border:1px solid rgba(255,255,255,.16);background:rgba(9,11,16,.75);color:#eef2ff;font:inherit;}' + . '.ac-maintenance-submit{padding:14px 18px;border-radius:14px;border:1px solid rgba(34,242,165,.45);background:rgba(34,242,165,.16);color:#effff8;font-family:IBM Plex Mono,monospace;font-size:12px;text-transform:uppercase;letter-spacing:.18em;cursor:pointer;}' + . '.ac-maintenance-error{margin-top:10px;color:#ffb4b4;font-size:14px;}' . '' . $contentHtml . ''; (new Core\Http\Response($maintenanceHtml, 503, ['Content-Type' => 'text/html; charset=utf-8']))->send(); exit; } +maintenance_bypass_complete: + $router = new Core\Http\Router(); $router->get('/', function (): Core\Http\Response { $db = Core\Services\Database::get(); @@ -76,7 +121,7 @@ $router->get('/', function (): Core\Http\Response { } $view = new Core\Views\View(__DIR__ . '/../views'); return new Core\Http\Response($view->render('site/home.php', [ - 'title' => 'AudioCore V1.5', + 'title' => 'AudioCore V1.5.1', ])); }); $router->registerModules(__DIR__ . '/../modules'); diff --git a/storage/cache/.gitkeep b/storage/cache/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/update.json b/update.json index e47ba79..6516800 100644 --- a/update.json +++ b/update.json @@ -1,10 +1,10 @@ { "channels": { "stable": { - "version": "1.5.0", - "download_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/archive/v1.5.0.zip", - "changelog_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/releases/tag/v1.5.0", - "notes": "AudioCore v1.5.0" + "version": "1.5.1", + "download_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/archive/v1.5.1.zip", + "changelog_url": "https://lab.codemunkeh.io/codemunkeh/AudioCore/releases/tag/v1.5.1", + "notes": "AudioCore v1.5.1" } } -} +} \ No newline at end of file diff --git a/views/admin/.gitkeep b/views/admin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/views/partials/footer.php b/views/partials/footer.php index cc06f8c..9e08471 100644 --- a/views/partials/footer.php +++ b/views/partials/footer.php @@ -26,7 +26,7 @@ if (!is_array($footerLinks)) { - + - diff --git a/views/partials/header.php b/views/partials/header.php index 461c015..3d91b93 100644 --- a/views/partials/header.php +++ b/views/partials/header.php @@ -48,7 +48,7 @@ if ($storeEnabled) { } } -$headerTitle = Settings::get('site_header_title', 'AudioCore V1.5'); +$headerTitle = Settings::get('site_header_title', 'AudioCore V1.5.1'); $headerTagline = Settings::get('site_header_tagline', 'Core CMS for DJs & Producers'); $headerBadgeText = Settings::get('site_header_badge_text', 'Independent catalog'); $headerBrandMode = Settings::get('site_header_brand_mode', 'default'); @@ -74,7 +74,7 @@ if ($effectiveMarkMode === 'text' && trim($headerMarkText) === '') { $effectiveMarkMode = 'icon'; } ?> -
    +
    @@ -114,8 +114,12 @@ if ($effectiveMarkMode === 'text' && trim($headerMarkText) === '') { +
    diff --git a/views/site/home.php b/views/site/home.php index 7f65898..b76c098 100644 --- a/views/site/home.php +++ b/views/site/home.php @@ -1,10 +1,10 @@
    Foundation
    -

    AudioCore V1.5

    +

    AudioCore V1.5.1

    New core scaffold. Modules will live under /modules and admin will manage navigation.

    diff --git a/views/site/layout.php b/views/site/layout.php index 09df284..548ac24 100644 --- a/views/site/layout.php +++ b/views/site/layout.php @@ -1,763 +1,764 @@ -]*>~i', '', $siteCustomCssRaw) ?? $siteCustomCssRaw; -$pageTitleValue = trim((string)($pageTitle ?? $siteTitleSetting)); -$metaTitle = $pageTitleValue; -if ($seoTitleSuffix !== '' && stripos($pageTitleValue, $seoTitleSuffix) === false) { - $metaTitle = $pageTitleValue . ' | ' . $seoTitleSuffix; -} -$siteNotice = null; -if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']) && is_array($_SESSION['ac_site_notice'])) { - $siteNotice = $_SESSION['ac_site_notice']; - unset($_SESSION['ac_site_notice']); -} -?> - - - - - - <?= htmlspecialchars($metaTitle, ENT_QUOTES, 'UTF-8') ?> - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    - - View Cart -
    -
    -
    - -
    - -
    - - - +]*>~i', '', $siteCustomCssRaw) ?? $siteCustomCssRaw; +$pageTitleValue = trim((string)($pageTitle ?? $siteTitleSetting)); +$metaTitle = $pageTitleValue; +if ($seoTitleSuffix !== '' && stripos($pageTitleValue, $seoTitleSuffix) === false) { + $metaTitle = $pageTitleValue . ' | ' . $seoTitleSuffix; +} +$siteNotice = null; +if (session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['ac_site_notice']) && is_array($_SESSION['ac_site_notice'])) { + $siteNotice = $_SESSION['ac_site_notice']; + unset($_SESSION['ac_site_notice']); +} +?> + + + + + + + <?= htmlspecialchars($metaTitle, ENT_QUOTES, 'UTF-8') ?> + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + View Cart +
    +
    +
    + +
    + +
    + + +