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, '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) { } try { $stmt = $db->query(" SELECT order_no, email, status, currency, total, 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); $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_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, COALESCE(os.order_count, 0) AS order_count, COALESCE(os.revenue, 0) AS revenue, 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, COUNT(*) AS order_count, SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue, 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, COUNT(*) AS order_count, SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue, 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 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 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']); } public function accountIndex(): Response { if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } $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 = []; } try { $downloadStmt = $db->prepare(" SELECT o.order_no, 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; } $downloads[] = [ 'order_no' => (string)($row['order_no'] ?? ''), 'file_name' => (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'), ])); } 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']); } $db = Database::get(); if (!($db instanceof PDO)) { return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']); } $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']); } // 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'; } $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']); } return new Response('', 302, ['Location' => '/account?message=If+we+found+orders+for+that+email%2C+a+login+link+has+been+sent']); } public function accountLogin(): Response { $token = trim((string)($_GET['token'] ?? '')); if ($token === '') { return new Response('', 302, ['Location' => '/account?error=Invalid+login+token']); } $db = Database::get(); if (!($db instanceof PDO)) { return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']); } $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']); } if (!empty($row['used_at'])) { return new Response('', 302, ['Location' => '/account?error=Login+link+has+already+been+used']); } $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']); } } $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['ac_store_customer_email'] = strtolower(trim((string)($row['email'] ?? ''))); } catch (Throwable $e) { return new Response('', 302, ['Location' => '/account?error=Unable+to+complete+login']); } return new Response('', 302, ['Location' => '/account?message=Signed+in+successfully']); } 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']); } public function cartAdd(): Response { $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 = []; } if ($itemType === 'release') { $db = Database::get(); 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)]); } $_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) { 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); })); $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 !== '') { $db = Database::get(); 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']); } $db = Database::get(); if (!($db instanceof PDO)) { return new Response('', 302, ['Location' => '/checkout']); } $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']); } $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') { 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)]); } public function checkoutPaypalCancel(): 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_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 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 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

    '; } 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) . '
    '; } private function defaultOrderEmailHtml(): string { return '{{logo_html}}' . '

    {{site_name}} - Order {{order_no}}

    ' . '

    Status: {{status}}

    ' . '

    Email: {{customer_email}}

    ' . '{{items_html}}' . '{{download_links_html}}' . '

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

    '; } 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 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 []; } } }