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

Example download links appear here after payment.

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

Hello,

' . '

Use this secure link to access your downloads:

' . '

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

' . '

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

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

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

' : '

Download links are sent once payment is confirmed.

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

Download links are sent once payment is confirmed.

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

No downloadable items in this order.

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

No downloadable files attached yet.

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

    Your Downloads

    '; } catch (Throwable $e) { return '

    Download links could not be generated yet.

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

    No items.

    '; } return '' . implode('', $rows) . '
    '; } private function defaultOrderEmailHtml(): string { return '{{logo_html}}' . '

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

    ' . '

    Status: {{status}}

    ' . '

    Email: {{customer_email}}

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

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

    '; } private function setAccountFlash(string $type, string $message): void { if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } $_SESSION['ac_store_flash_' . $type] = $message; } private function consumeAccountFlash(string $type): string { if (session_status() !== PHP_SESSION_ACTIVE) { session_start(); } $key = 'ac_store_flash_' . $type; $value = (string)($_SESSION[$key] ?? ''); unset($_SESSION[$key]); return $value; } private function safeReturnUrl(string $url): string { if ($url === '' || $url[0] !== '/') { return '/releases'; } if (str_starts_with($url, '//')) { return '/releases'; } return $url; } private function baseUrl(): string { $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443'); $scheme = $https ? 'https' : 'http'; $host = (string)($_SERVER['HTTP_HOST'] ?? ''); if ($host === '') { return ''; } return $scheme . '://' . $host; } private function buildDownloadLabel(PDO $db, int $fileId, string $fallback): string { if ($fileId <= 0) { return $fallback !== '' ? $fallback : 'Download'; } try { $stmt = $db->prepare(" SELECT f.file_name, t.title AS track_title, t.mix_name, COALESCE(NULLIF(r.artist_name, ''), a.name, '') AS artist_name FROM ac_store_files f LEFT JOIN ac_release_tracks t ON f.scope_type = 'track' AND f.scope_id = t.id LEFT JOIN ac_releases r ON t.release_id = r.id LEFT JOIN ac_artists a ON r.artist_id = a.id WHERE f.id = :id LIMIT 1 "); $stmt->execute([':id' => $fileId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if ($row) { $trackTitle = trim((string)($row['track_title'] ?? '')); $mixName = trim((string)($row['mix_name'] ?? '')); $artistName = trim((string)($row['artist_name'] ?? '')); if ($trackTitle !== '' && $mixName !== '') { $trackTitle .= ' (' . $mixName . ')'; } if ($trackTitle !== '' && $artistName !== '') { return $artistName . ' - ' . $trackTitle; } if ($trackTitle !== '') { return $trackTitle; } } } catch (Throwable $e) { } return $fallback !== '' ? $fallback : 'Download'; } private function isItemReleased(PDO $db, string $itemType, int $itemId): bool { if ($itemId <= 0) { return false; } try { if ($itemType === 'bundle') { $stmt = $db->prepare(" SELECT COUNT(*) AS total_rows, SUM(CASE WHEN r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= :today) THEN 1 ELSE 0 END) AS live_rows FROM ac_store_bundle_items bi JOIN ac_releases r ON r.id = bi.release_id WHERE bi.bundle_id = :id "); $stmt->execute([ ':id' => $itemId, ':today' => date('Y-m-d'), ]); $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; $totalRows = (int)($row['total_rows'] ?? 0); $liveRows = (int)($row['live_rows'] ?? 0); return $totalRows > 0 && $totalRows === $liveRows; } if ($itemType === 'release') { $stmt = $db->prepare(" SELECT 1 FROM ac_releases WHERE id = :id AND is_published = 1 AND (release_date IS NULL OR release_date <= :today) LIMIT 1 "); $stmt->execute([ ':id' => $itemId, ':today' => date('Y-m-d'), ]); return (bool)$stmt->fetch(PDO::FETCH_ASSOC); } $stmt = $db->prepare(" SELECT 1 FROM ac_release_tracks t JOIN ac_releases r ON r.id = t.release_id WHERE t.id = :id AND r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= :today) LIMIT 1 "); $stmt->execute([ ':id' => $itemId, ':today' => date('Y-m-d'), ]); return (bool)$stmt->fetch(PDO::FETCH_ASSOC); } catch (Throwable $e) { return false; } } private function hasDownloadableFiles(PDO $db, string $itemType, int $itemId): bool { if ($itemId <= 0) { return false; } try { if ($itemType === 'bundle') { $stmt = $db->prepare(" SELECT 1 FROM ac_store_bundle_items bi JOIN ac_release_tracks t ON t.release_id = bi.release_id JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1 JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = t.id AND f.is_active = 1 WHERE bi.bundle_id = :id LIMIT 1 "); $stmt->execute([':id' => $itemId]); return (bool)$stmt->fetch(PDO::FETCH_ASSOC); } if ($itemType === 'release') { $stmt = $db->prepare(" SELECT 1 FROM ac_release_tracks t JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1 JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = t.id AND f.is_active = 1 WHERE t.release_id = :id LIMIT 1 "); $stmt->execute([':id' => $itemId]); return (bool)$stmt->fetch(PDO::FETCH_ASSOC); } $stmt = $db->prepare(" SELECT 1 FROM ac_store_track_products sp JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = sp.release_track_id AND f.is_active = 1 WHERE sp.release_track_id = :id AND sp.is_enabled = 1 LIMIT 1 "); $stmt->execute([':id' => $itemId]); return (bool)$stmt->fetch(PDO::FETCH_ASSOC); } catch (Throwable $e) { return false; } } private function logMailDebug(string $ref, array $payload): void { try { $dir = __DIR__ . '/../../storage/logs'; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } $file = $dir . '/store_mail.log'; $line = '[' . date('c') . '] ' . $ref . ' ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL; @file_put_contents($file, $line, FILE_APPEND); } catch (Throwable $e) { } } private function clientIp(): string { $candidates = [ (string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''), (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''), (string)($_SERVER['REMOTE_ADDR'] ?? ''), ]; foreach ($candidates as $candidate) { if ($candidate === '') { continue; } $first = trim(explode(',', $candidate)[0] ?? ''); if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) { return $first; } } return ''; } private function loadCustomerIpHistory(PDO $db): array { $history = []; try { $stmt = $db->query(" SELECT o.email, o.customer_ip AS ip_address, MAX(o.created_at) AS last_seen FROM ac_store_orders o WHERE o.customer_ip IS NOT NULL AND o.customer_ip <> '' GROUP BY o.email, o.customer_ip "); $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; foreach ($rows as $row) { $email = strtolower(trim((string)($row['email'] ?? ''))); $ip = trim((string)($row['ip_address'] ?? '')); $lastSeen = (string)($row['last_seen'] ?? ''); if ($email === '' || $ip === '') { continue; } $history[$email][$ip] = $lastSeen; } } catch (Throwable $e) { } try { $stmt = $db->query(" SELECT t.email, e.ip_address, MAX(e.downloaded_at) AS last_seen FROM ac_store_download_events e JOIN ac_store_download_tokens t ON t.id = e.token_id WHERE e.ip_address IS NOT NULL AND e.ip_address <> '' GROUP BY t.email, e.ip_address "); $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : []; foreach ($rows as $row) { $email = strtolower(trim((string)($row['email'] ?? ''))); $ip = trim((string)($row['ip_address'] ?? '')); $lastSeen = (string)($row['last_seen'] ?? ''); if ($email === '' || $ip === '') { continue; } $existing = $history[$email][$ip] ?? ''; if ($existing === '' || ($lastSeen !== '' && strcmp($lastSeen, $existing) > 0)) { $history[$email][$ip] = $lastSeen; } } } catch (Throwable $e) { } $result = []; foreach ($history as $email => $ips) { arsort($ips); $entries = []; foreach ($ips as $ip => $lastSeen) { $entries[] = [ 'ip' => $ip, 'last_seen' => $lastSeen, ]; if (count($entries) >= 5) { break; } } $result[$email] = $entries; } return $result; } private function upsertCustomerFromOrder(PDO $db, string $email, string $ip, string $userAgent, float $orderTotal): int { $email = strtolower(trim($email)); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return 0; } try { $sel = $db->prepare("SELECT id, orders_count, total_spent FROM ac_store_customers WHERE email = :email LIMIT 1"); $sel->execute([':email' => $email]); $row = $sel->fetch(PDO::FETCH_ASSOC); if ($row) { $customerId = (int)($row['id'] ?? 0); $ordersCount = (int)($row['orders_count'] ?? 0); $totalSpent = (float)($row['total_spent'] ?? 0); $upd = $db->prepare(" UPDATE ac_store_customers SET last_order_ip = :ip, last_order_user_agent = :ua, last_seen_at = NOW(), orders_count = :orders_count, total_spent = :total_spent, updated_at = NOW() WHERE id = :id "); $upd->execute([ ':ip' => $ip !== '' ? $ip : null, ':ua' => $userAgent !== '' ? $userAgent : null, ':orders_count' => $ordersCount + 1, ':total_spent' => $totalSpent + $orderTotal, ':id' => $customerId, ]); return $customerId; } $ins = $db->prepare(" INSERT INTO ac_store_customers (name, email, password_hash, is_active, last_order_ip, last_order_user_agent, last_seen_at, orders_count, total_spent, created_at, updated_at) VALUES (NULL, :email, NULL, 1, :ip, :ua, NOW(), 1, :total_spent, NOW(), NOW()) "); $ins->execute([ ':email' => $email, ':ip' => $ip !== '' ? $ip : null, ':ua' => $userAgent !== '' ? $userAgent : null, ':total_spent' => $orderTotal, ]); return (int)$db->lastInsertId(); } catch (Throwable $e) { return 0; } } private function bumpDiscountUsage(PDO $db, string $code): void { $code = strtoupper(trim($code)); if ($code === '') { return; } try { $stmt = $db->prepare(" UPDATE ac_store_discount_codes SET used_count = used_count + 1, updated_at = NOW() WHERE code = :code "); $stmt->execute([':code' => $code]); } catch (Throwable $e) { } } private function isEnabledSetting($value): bool { if (is_bool($value)) { return $value; } if (is_int($value) || is_float($value)) { return (int)$value === 1; } $normalized = strtolower(trim((string)$value)); return in_array($normalized, ['1', 'true', 'yes', 'on'], true); } private function truthy($value): bool { return $this->isEnabledSetting($value); } private function requestPayload(): array { if ($_POST) { return is_array($_POST) ? $_POST : []; } $raw = file_get_contents('php://input'); if (!is_string($raw) || trim($raw) === '') { return []; } $decoded = json_decode($raw, true); return is_array($decoded) ? $decoded : []; } private function paypalCardCapabilityProbe(string $clientId, string $secret, bool $sandbox): array { $token = $this->paypalGenerateClientToken($clientId, $secret, $sandbox); if ($token['ok'] ?? false) { return [ 'ok' => true, 'status' => 'available', 'message' => 'PayPal embedded card fields are available for this account.', ]; } return [ 'ok' => false, 'status' => 'unavailable', 'message' => (string)($token['error'] ?? 'Unable to generate a PayPal client token for card fields.'), ]; } private function paypalGenerateClientToken(string $clientId, string $secret, bool $sandbox): array { $token = $this->paypalAccessToken($clientId, $secret, $sandbox); if (!($token['ok'] ?? false)) { return $token; } $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $res = $this->paypalJsonRequest( $base . '/v1/identity/generate-token', 'POST', new \stdClass(), (string)($token['access_token'] ?? '') ); if (!($res['ok'] ?? false)) { return [ 'ok' => false, 'error' => (string)($res['error'] ?? 'Unable to generate a PayPal client token.'), ]; } $body = is_array($res['body'] ?? null) ? $res['body'] : []; $clientToken = trim((string)($body['client_token'] ?? '')); if ($clientToken === '') { return [ 'ok' => false, 'error' => 'PayPal did not return a client token for card fields.', ]; } return [ 'ok' => true, 'client_token' => $clientToken, ]; } private function paypalTokenProbe(string $clientId, string $secret, bool $sandbox): array { $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $url = $base . '/v1/oauth2/token'; $headers = [ 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), 'Content-Type: application/x-www-form-urlencoded', 'Accept: application/json', ]; $body = 'grant_type=client_credentials'; if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch === false) { return ['ok' => false, 'error' => 'Unable to initialize cURL']; } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 15); $response = (string)curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr !== '') { return ['ok' => false, 'error' => 'PayPal test failed: ' . $curlErr]; } $decoded = json_decode($response, true); if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { return ['ok' => true]; } $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; } $context = stream_context_create([ 'http' => [ 'method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body, 'timeout' => 15, 'ignore_errors' => true, ], ]); $response = @file_get_contents($url, false, $context); if ($response === false) { return ['ok' => false, 'error' => 'PayPal test failed: network error']; } $statusLine = ''; if (!empty($http_response_header[0])) { $statusLine = (string)$http_response_header[0]; } preg_match('/\s(\d{3})\s/', $statusLine, $m); $httpCode = isset($m[1]) ? (int)$m[1] : 0; $decoded = json_decode($response, true); if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { return ['ok' => true]; } $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; } private function paypalCreateOrder( string $clientId, string $secret, bool $sandbox, string $currency, float $total, string $orderNo, string $returnUrl, string $cancelUrl ): array { $token = $this->paypalAccessToken($clientId, $secret, $sandbox); if (!($token['ok'] ?? false)) { return $token; } $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $payload = [ 'intent' => 'CAPTURE', 'purchase_units' => [[ 'reference_id' => $orderNo, 'description' => 'AudioCore order ' . $orderNo, 'custom_id' => $orderNo, 'amount' => [ 'currency_code' => $currency, 'value' => number_format($total, 2, '.', ''), ], ]], 'application_context' => [ 'return_url' => $returnUrl, 'cancel_url' => $cancelUrl, 'shipping_preference' => 'NO_SHIPPING', 'user_action' => 'PAY_NOW', ], ]; $res = $this->paypalJsonRequest( $base . '/v2/checkout/orders', 'POST', $payload, (string)($token['access_token'] ?? '') ); if (!($res['ok'] ?? false)) { return $res; } $body = is_array($res['body'] ?? null) ? $res['body'] : []; $orderId = (string)($body['id'] ?? ''); $approvalUrl = ''; foreach ((array)($body['links'] ?? []) as $link) { if ((string)($link['rel'] ?? '') === 'approve') { $approvalUrl = (string)($link['href'] ?? ''); break; } } if ($orderId === '' || $approvalUrl === '') { return ['ok' => false, 'error' => 'PayPal create order response incomplete']; } return [ 'ok' => true, 'order_id' => $orderId, 'approval_url' => $approvalUrl, ]; } private function paypalCaptureOrder(string $clientId, string $secret, bool $sandbox, string $paypalOrderId): array { $token = $this->paypalAccessToken($clientId, $secret, $sandbox); if (!($token['ok'] ?? false)) { return $token; } $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $res = $this->paypalJsonRequest( $base . '/v2/checkout/orders/' . rawurlencode($paypalOrderId) . '/capture', 'POST', new \stdClass(), (string)($token['access_token'] ?? '') ); if (!($res['ok'] ?? false)) { return $res; } $body = is_array($res['body'] ?? null) ? $res['body'] : []; $status = (string)($body['status'] ?? ''); if ($status !== 'COMPLETED') { return ['ok' => false, 'error' => 'PayPal capture status: ' . ($status !== '' ? $status : 'unknown')]; } $captureId = ''; $purchaseUnits = (array)($body['purchase_units'] ?? []); if (!empty($purchaseUnits[0]['payments']['captures'][0]['id'])) { $captureId = (string)$purchaseUnits[0]['payments']['captures'][0]['id']; } $breakdown = $this->paypalCaptureBreakdown($purchaseUnits); return [ 'ok' => true, 'capture_id' => $captureId, 'payment_breakdown' => $breakdown, ]; } private function paypalCaptureBreakdown(array $purchaseUnits): array { $capture = (array)($purchaseUnits[0]['payments']['captures'][0] ?? []); $breakdown = (array)($capture['seller_receivable_breakdown'] ?? []); return [ 'gross' => $this->paypalMoneyValue((array)($breakdown['gross_amount'] ?? [])), 'fee' => $this->paypalMoneyValue((array)($breakdown['paypal_fee'] ?? [])), 'net' => $this->paypalMoneyValue((array)($breakdown['net_amount'] ?? [])), 'currency' => $this->paypalMoneyCurrency((array)($breakdown['gross_amount'] ?? []), (array)($capture['amount'] ?? [])), ]; } private function paypalMoneyValue(array $money): ?float { $value = trim((string)($money['value'] ?? '')); return $value !== '' ? (float)$value : null; } private function paypalMoneyCurrency(array ...$candidates): ?string { foreach ($candidates as $money) { $currency = strtoupper(trim((string)($money['currency_code'] ?? ''))); if ($currency !== '') { return $currency; } } return null; } private function paypalRefundCapture( string $clientId, string $secret, bool $sandbox, string $captureId, string $currency, float $total ): array { $token = $this->paypalAccessToken($clientId, $secret, $sandbox); if (!($token['ok'] ?? false)) { return $token; } $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $payload = [ 'amount' => [ 'currency_code' => $currency, 'value' => number_format($total, 2, '.', ''), ], ]; $res = $this->paypalJsonRequest( $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund', 'POST', $payload, (string)($token['access_token'] ?? '') ); if (!($res['ok'] ?? false)) { return $res; } $body = is_array($res['body'] ?? null) ? $res['body'] : []; $status = strtoupper((string)($body['status'] ?? '')); if (!in_array($status, ['COMPLETED', 'PENDING'], true)) { return ['ok' => false, 'error' => 'PayPal refund status: ' . ($status !== '' ? $status : 'unknown')]; } return ['ok' => true]; } private function paypalAccessToken(string $clientId, string $secret, bool $sandbox): array { $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; $url = $base . '/v1/oauth2/token'; $headers = [ 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret), 'Content-Type: application/x-www-form-urlencoded', 'Accept: application/json', ]; $body = 'grant_type=client_credentials'; if (function_exists('curl_init')) { $ch = curl_init($url); if ($ch === false) { return ['ok' => false, 'error' => 'Unable to initialize cURL']; } curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_TIMEOUT, 20); $response = (string)curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr !== '') { return ['ok' => false, 'error' => 'PayPal auth failed: ' . $curlErr]; } $decoded = json_decode($response, true); if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) { return ['ok' => true, 'access_token' => (string)$decoded['access_token']]; } $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : ''; return ['ok' => false, 'error' => 'PayPal auth rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; } return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; } private function paypalJsonRequest(string $url, string $method, $payload, string $accessToken): array { if (!function_exists('curl_init')) { return ['ok' => false, 'error' => 'cURL is required for PayPal checkout']; } $ch = curl_init($url); if ($ch === false) { return ['ok' => false, 'error' => 'Unable to initialize cURL']; } $json = json_encode($payload, JSON_UNESCAPED_SLASHES); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method)); curl_setopt($ch, CURLOPT_POSTFIELDS, $json); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Authorization: Bearer ' . $accessToken, 'Content-Type: application/json', 'Accept: application/json', ]); curl_setopt($ch, CURLOPT_TIMEOUT, 25); $response = (string)curl_exec($ch); $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlErr = curl_error($ch); curl_close($ch); if ($curlErr !== '') { return ['ok' => false, 'error' => 'PayPal request failed: ' . $curlErr]; } $decoded = json_decode($response, true); if ($httpCode >= 200 && $httpCode < 300) { return ['ok' => true, 'body' => is_array($decoded) ? $decoded : []]; } $err = is_array($decoded) ? (string)($decoded['message'] ?? $decoded['name'] ?? '') : ''; return ['ok' => false, 'error' => 'PayPal API error (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')]; } private function orderItemsForEmail(PDO $db, int $orderId): array { try { $stmt = $db->prepare(" SELECT title_snapshot AS title, unit_price_snapshot AS price, qty, currency_snapshot AS currency FROM ac_store_order_items WHERE order_id = :order_id ORDER BY id ASC "); $stmt->execute([':order_id' => $orderId]); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); return is_array($rows) ? $rows : []; } catch (Throwable $e) { return []; } } }