Credits
@@ -192,6 +251,15 @@ ob_start();
.track-buy-btn{height:34px;border:1px solid rgba(255,255,255,.18);border-radius:999px;background:rgba(255,255,255,.08);color:#e9eefc;font-weight:700;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 14px;font-size:12px;white-space:nowrap}
.track-buy-btn:hover{background:rgba(255,255,255,.14)}
.track-play-btn[disabled]{border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:var(--muted);cursor:not-allowed}
+ .bundle-card{display:grid;grid-template-columns:max-content minmax(0,1fr) auto;gap:14px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.1);background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.01))}
+ .bundle-stack{position:relative;height:70px;width:calc(70px + (max(0, var(--cover-count, 1) - 1) * 16px));}
+ .bundle-cover{position:absolute;left:calc(var(--i,0) * 16px);top:0;width:70px;height:70px;border-radius:10px;overflow:hidden;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.04);box-shadow:0 8px 20px rgba(0,0,0,.35)}
+ .bundle-cover img{width:100%;height:100%;object-fit:cover}
+ .bundle-fallback{display:grid;place-items:center;font-size:11px;color:var(--muted)}
+ .bundle-copy{min-width:0}
+ .bundle-title{font-size:17px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .bundle-meta{margin-top:6px;font-size:12px;color:var(--muted);display:flex;flex-wrap:wrap;gap:10px;align-items:center}
+ .bundle-save{color:#9ff7d5;font-weight:700;letter-spacing:.08em;text-transform:uppercase;font-size:11px}
.ac-dock{position:fixed;left:14px;right:14px;bottom:14px;z-index:50}
.ac-dock-inner{display:grid;grid-template-columns:minmax(210px,280px) 96px minmax(160px,1fr) 110px 110px 44px;gap:10px;align-items:center;padding:12px;border-radius:14px;border:1px solid rgba(255,255,255,.14);background:rgba(10,11,15,.95)}
.ac-dock-meta{display:grid;grid-template-columns:44px minmax(0,1fr);align-items:center;gap:10px;min-width:0}
@@ -221,7 +289,10 @@ ob_start();
gap: 10px !important;
}
.track-buy-btn { font-size: 11px; padding: 0 10px; }
- .ac-dock {
+ .bundle-card{grid-template-columns:1fr;gap:10px}
+ .bundle-stack{height:62px}
+ .bundle-cover{width:62px;height:62px}
+ .ac-dock {
left: 8px;
right: 8px;
bottom: 8px;
diff --git a/plugins/store/StoreController.php b/plugins/store/StoreController.php
index 424ea98..2c56254 100644
--- a/plugins/store/StoreController.php
+++ b/plugins/store/StoreController.php
@@ -1,1079 +1,1292 @@
-applyStoreTimezone();
- $this->view = new View(__DIR__ . '/views');
- }
-
- public function adminIndex(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $this->ensureAnalyticsSchema();
-
- $tablesReady = $this->tablesReady();
+use Core\Views\View;
+use PDO;
+use Plugins\Store\Gateways\Gateways;
+use Throwable;
+
+class StoreController
+{
+ private View $view;
+
+ public function __construct()
+ {
+ $this->applyStoreTimezone();
+ $this->view = new View(__DIR__ . '/views');
+ }
+
+ public function adminIndex(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $this->ensureAnalyticsSchema();
+
+ $tablesReady = $this->tablesReady();
$stats = [
'total_orders' => 0,
'paid_orders' => 0,
- 'total_revenue' => 0.0,
+ 'before_fees' => 0.0,
+ 'paypal_fees' => 0.0,
+ 'after_fees' => 0.0,
'total_customers' => 0,
];
- $recentOrders = [];
- $newCustomers = [];
-
- if ($tablesReady) {
- $db = Database::get();
- if ($db instanceof PDO) {
- try {
- $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders");
- $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
- $stats['total_orders'] = (int)($row['c'] ?? 0);
- } catch (Throwable $e) {
- }
-
- try {
- $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders WHERE status = 'paid'");
- $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
- $stats['paid_orders'] = (int)($row['c'] ?? 0);
- } catch (Throwable $e) {
- }
-
- try {
- $stmt = $db->query("SELECT COALESCE(SUM(total), 0) AS revenue FROM ac_store_orders WHERE status = 'paid'");
- $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
- $stats['total_revenue'] = (float)($row['revenue'] ?? 0);
- } catch (Throwable $e) {
- }
-
- try {
- $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_customers");
- $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
- $stats['total_customers'] = (int)($row['c'] ?? 0);
- } catch (Throwable $e) {
- }
-
+ $recentOrders = [];
+ $newCustomers = [];
+
+ if ($tablesReady) {
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ try {
+ $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders");
+ $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
+ $stats['total_orders'] = (int)($row['c'] ?? 0);
+ } catch (Throwable $e) {
+ }
+
+ try {
+ $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders WHERE status = 'paid'");
+ $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
+ $stats['paid_orders'] = (int)($row['c'] ?? 0);
+ } catch (Throwable $e) {
+ }
+
try {
$stmt = $db->query("
- SELECT order_no, email, status, currency, total, created_at
+ SELECT
+ COALESCE(SUM(COALESCE(payment_gross, total)), 0) AS before_fees,
+ COALESCE(SUM(COALESCE(payment_fee, 0)), 0) AS paypal_fees,
+ COALESCE(SUM(COALESCE(payment_net, total)), 0) AS after_fees
+ FROM ac_store_orders
+ WHERE status = 'paid'
+ ");
+ $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
+ $stats['before_fees'] = (float)($row['before_fees'] ?? 0);
+ $stats['paypal_fees'] = (float)($row['paypal_fees'] ?? 0);
+ $stats['after_fees'] = (float)($row['after_fees'] ?? 0);
+ } catch (Throwable $e) {
+ }
+
+ try {
+ $stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_customers");
+ $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
+ $stats['total_customers'] = (int)($row['c'] ?? 0);
+ } catch (Throwable $e) {
+ }
+
+ try {
+ $stmt = $db->query("
+ SELECT id, order_no, email, status, currency, total, payment_gross, payment_fee, payment_net, created_at
FROM ac_store_orders
ORDER BY created_at DESC
LIMIT 5
- ");
- $recentOrders = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
- } catch (Throwable $e) {
- $recentOrders = [];
- }
-
- try {
- $stmt = $db->query("
- SELECT name, email, is_active, created_at
- FROM ac_store_customers
- ORDER BY created_at DESC
- LIMIT 5
- ");
- $newCustomers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
- } catch (Throwable $e) {
- $newCustomers = [];
- }
- }
- }
-
- return new Response($this->view->render('admin/index.php', [
- 'title' => 'Store',
- 'tables_ready' => $tablesReady,
- 'private_root' => Settings::get('store_private_root', $this->privateRoot()),
- 'private_root_ready' => $this->privateRootReady(),
- 'stats' => $stats,
- 'recent_orders' => $recentOrders,
- 'new_customers' => $newCustomers,
- 'currency' => Settings::get('store_currency', 'GBP'),
- ]));
- }
-
- public function adminSettings(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $this->ensureDiscountSchema();
- $this->ensureSalesChartSchema();
- $settings = $this->settingsPayload();
- $gateways = [];
- foreach (Gateways::all() as $gateway) {
- $gateways[] = [
- 'key' => $gateway->key(),
- 'label' => $gateway->label(),
- 'enabled' => $gateway->isEnabled($settings),
- ];
- }
-
- return new Response($this->view->render('admin/settings.php', [
- 'title' => 'Store Settings',
- 'settings' => $settings,
- 'gateways' => $gateways,
- 'discounts' => $this->adminDiscountRows(),
- 'private_root_ready' => $this->privateRootReady(),
- 'tab' => (string)($_GET['tab'] ?? 'general'),
- 'error' => (string)($_GET['error'] ?? ''),
- 'saved' => (string)($_GET['saved'] ?? ''),
- 'chart_rows' => $this->salesChartRows(
- (string)($settings['store_sales_chart_default_scope'] ?? 'tracks'),
- (string)($settings['store_sales_chart_default_window'] ?? 'latest'),
- max(1, min(30, (int)($settings['store_sales_chart_limit'] ?? '10')))
- ),
- 'chart_last_rebuild_at' => (string)$this->salesChartLastRebuildAt(),
- 'chart_cron_url' => $this->salesChartCronUrl(),
- 'chart_cron_cmd' => $this->salesChartCronCommand(),
- ]));
- }
-
- public function adminSaveSettings(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $current = $this->settingsPayload();
-
- $currencyRaw = array_key_exists('store_currency', $_POST) ? (string)$_POST['store_currency'] : (string)($current['store_currency'] ?? 'GBP');
- $currency = strtoupper(trim($currencyRaw));
- if (!preg_match('/^[A-Z]{3}$/', $currency)) {
- $currency = 'GBP';
- }
- $privateRootRaw = array_key_exists('store_private_root', $_POST) ? (string)$_POST['store_private_root'] : (string)($current['store_private_root'] ?? $this->privateRoot());
- $privateRoot = trim($privateRootRaw);
- if ($privateRoot === '') {
- $privateRoot = $this->privateRoot();
- }
- $downloadLimitRaw = array_key_exists('store_download_limit', $_POST) ? (string)$_POST['store_download_limit'] : (string)($current['store_download_limit'] ?? '5');
- $downloadLimit = max(1, (int)$downloadLimitRaw);
- $expiryDaysRaw = array_key_exists('store_download_expiry_days', $_POST) ? (string)$_POST['store_download_expiry_days'] : (string)($current['store_download_expiry_days'] ?? '30');
- $expiryDays = max(1, (int)$expiryDaysRaw);
- $orderPrefixRaw = array_key_exists('store_order_prefix', $_POST) ? (string)$_POST['store_order_prefix'] : (string)($current['store_order_prefix'] ?? 'AC-ORD');
- $orderPrefix = $this->sanitizeOrderPrefix($orderPrefixRaw);
- $timezoneRaw = array_key_exists('store_timezone', $_POST) ? (string)$_POST['store_timezone'] : (string)($current['store_timezone'] ?? 'UTC');
- $timezone = $this->normalizeTimezone($timezoneRaw);
- $testMode = array_key_exists('store_test_mode', $_POST) ? ((string)$_POST['store_test_mode'] === '1' ? '1' : '0') : (string)($current['store_test_mode'] ?? '1');
- $stripeEnabled = array_key_exists('store_stripe_enabled', $_POST) ? ((string)$_POST['store_stripe_enabled'] === '1' ? '1' : '0') : (string)($current['store_stripe_enabled'] ?? '0');
- $stripePublic = trim((string)(array_key_exists('store_stripe_public_key', $_POST) ? $_POST['store_stripe_public_key'] : ($current['store_stripe_public_key'] ?? '')));
- $stripeSecret = trim((string)(array_key_exists('store_stripe_secret_key', $_POST) ? $_POST['store_stripe_secret_key'] : ($current['store_stripe_secret_key'] ?? '')));
- $paypalEnabled = array_key_exists('store_paypal_enabled', $_POST) ? ((string)$_POST['store_paypal_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_enabled'] ?? '0');
- $paypalClientId = trim((string)(array_key_exists('store_paypal_client_id', $_POST) ? $_POST['store_paypal_client_id'] : ($current['store_paypal_client_id'] ?? '')));
- $paypalSecret = trim((string)(array_key_exists('store_paypal_secret', $_POST) ? $_POST['store_paypal_secret'] : ($current['store_paypal_secret'] ?? '')));
- $emailLogoUrl = trim((string)(array_key_exists('store_email_logo_url', $_POST) ? $_POST['store_email_logo_url'] : ($current['store_email_logo_url'] ?? '')));
- $orderEmailSubject = trim((string)(array_key_exists('store_order_email_subject', $_POST) ? $_POST['store_order_email_subject'] : ($current['store_order_email_subject'] ?? 'Your AudioCore order {{order_no}}')));
- $orderEmailHtml = trim((string)(array_key_exists('store_order_email_html', $_POST) ? $_POST['store_order_email_html'] : ($current['store_order_email_html'] ?? '')));
- if ($orderEmailHtml === '') {
- $orderEmailHtml = $this->defaultOrderEmailHtml();
- }
- $salesChartDefaultScope = strtolower(trim((string)(array_key_exists('store_sales_chart_default_scope', $_POST) ? $_POST['store_sales_chart_default_scope'] : ($current['store_sales_chart_default_scope'] ?? 'tracks'))));
- if (!in_array($salesChartDefaultScope, ['tracks', 'releases'], true)) {
- $salesChartDefaultScope = 'tracks';
- }
- $salesChartDefaultWindow = strtolower(trim((string)(array_key_exists('store_sales_chart_default_window', $_POST) ? $_POST['store_sales_chart_default_window'] : ($current['store_sales_chart_default_window'] ?? 'latest'))));
- if (!in_array($salesChartDefaultWindow, ['latest', 'weekly', 'all_time'], true)) {
- $salesChartDefaultWindow = 'latest';
- }
- $salesChartLimit = max(1, min(50, (int)(array_key_exists('store_sales_chart_limit', $_POST) ? $_POST['store_sales_chart_limit'] : ($current['store_sales_chart_limit'] ?? '10'))));
- $latestHours = max(1, min(168, (int)(array_key_exists('store_sales_chart_latest_hours', $_POST) ? $_POST['store_sales_chart_latest_hours'] : ($current['store_sales_chart_latest_hours'] ?? '24'))));
- $refreshMinutes = max(5, min(1440, (int)(array_key_exists('store_sales_chart_refresh_minutes', $_POST) ? $_POST['store_sales_chart_refresh_minutes'] : ($current['store_sales_chart_refresh_minutes'] ?? '180'))));
-
- Settings::set('store_currency', $currency);
- Settings::set('store_private_root', $privateRoot);
- Settings::set('store_download_limit', (string)$downloadLimit);
- Settings::set('store_download_expiry_days', (string)$expiryDays);
- Settings::set('store_order_prefix', $orderPrefix);
- Settings::set('store_timezone', $timezone);
- Settings::set('store_test_mode', $testMode);
- Settings::set('store_stripe_enabled', $stripeEnabled);
- Settings::set('store_stripe_public_key', $stripePublic);
- Settings::set('store_stripe_secret_key', $stripeSecret);
- Settings::set('store_paypal_enabled', $paypalEnabled);
- Settings::set('store_paypal_client_id', $paypalClientId);
- Settings::set('store_paypal_secret', $paypalSecret);
- Settings::set('store_email_logo_url', $emailLogoUrl);
- Settings::set('store_order_email_subject', $orderEmailSubject !== '' ? $orderEmailSubject : 'Your AudioCore order {{order_no}}');
- Settings::set('store_order_email_html', $orderEmailHtml);
- Settings::set('store_sales_chart_default_scope', $salesChartDefaultScope);
- Settings::set('store_sales_chart_default_window', $salesChartDefaultWindow);
- Settings::set('store_sales_chart_limit', (string)$salesChartLimit);
- Settings::set('store_sales_chart_latest_hours', (string)$latestHours);
- Settings::set('store_sales_chart_refresh_minutes', (string)$refreshMinutes);
- if (isset($_POST['store_sales_chart_regen_key'])) {
- try {
- Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24)));
- } catch (Throwable $e) {
- }
- }
- if (trim((string)Settings::get('store_sales_chart_cron_key', '')) === '') {
- try {
- Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24)));
- } catch (Throwable $e) {
- }
- }
- $this->ensureSalesChartSchema();
-
- if (!$this->ensurePrivateRoot($privateRoot)) {
- return new Response('', 302, ['Location' => '/admin/store/settings?error=Unable+to+create+or+write+private+download+folder']);
- }
-
- $tab = trim((string)($_POST['tab'] ?? 'general'));
- $tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'sales_chart'], true) ? $tab : 'general';
- return new Response('', 302, ['Location' => '/admin/store/settings?saved=1&tab=' . rawurlencode($tab)]);
- }
-
- public function adminRebuildSalesChart(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
- $this->ensureSalesChartSchema();
- $ok = $this->rebuildSalesChartCache();
- if (!$ok) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&error=Unable+to+rebuild+sales+chart']);
- }
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&saved=1']);
- }
-
- public function salesChartCron(): Response
- {
- $this->ensureSalesChartSchema();
- $expected = trim((string)Settings::get('store_sales_chart_cron_key', ''));
- $provided = trim((string)($_GET['key'] ?? ''));
- if ($expected === '' || !hash_equals($expected, $provided)) {
- return new Response('Unauthorized', 401);
- }
- $ok = $this->rebuildSalesChartCache();
- if (!$ok) {
- return new Response('Sales chart rebuild failed', 500);
- }
- return new Response('OK');
- }
-
- public function adminDiscountCreate(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
- $this->ensureDiscountSchema();
- $code = strtoupper(trim((string)($_POST['code'] ?? '')));
- $type = trim((string)($_POST['discount_type'] ?? 'percent'));
- $value = (float)($_POST['discount_value'] ?? 0);
- $maxUses = (int)($_POST['max_uses'] ?? 0);
- $expiresAt = trim((string)($_POST['expires_at'] ?? ''));
- $isActive = isset($_POST['is_active']) ? 1 : 0;
-
- if (!preg_match('/^[A-Z0-9_-]{3,32}$/', $code)) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+code+format']);
- }
- if (!in_array($type, ['percent', 'fixed'], true)) {
- $type = 'percent';
- }
- if ($value <= 0) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Discount+value+must+be+greater+than+0']);
- }
- if ($type === 'percent' && $value > 100) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Percent+cannot+exceed+100']);
- }
- if ($maxUses < 0) {
- $maxUses = 0;
- }
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Database+unavailable']);
- }
- try {
- $stmt = $db->prepare("
- INSERT INTO ac_store_discount_codes
- (code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at, updated_at)
- VALUES (:code, :discount_type, :discount_value, :max_uses, 0, :expires_at, :is_active, NOW(), NOW())
- ON DUPLICATE KEY UPDATE
- discount_type = VALUES(discount_type),
- discount_value = VALUES(discount_value),
- max_uses = VALUES(max_uses),
- expires_at = VALUES(expires_at),
- is_active = VALUES(is_active),
- updated_at = NOW()
- ");
- $stmt->execute([
- ':code' => $code,
- ':discount_type' => $type,
- ':discount_value' => $value,
- ':max_uses' => $maxUses,
- ':expires_at' => $expiresAt !== '' ? $expiresAt : null,
- ':is_active' => $isActive,
- ]);
- } catch (Throwable $e) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+save+discount+code']);
- }
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']);
- }
-
- public function adminDiscountDelete(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
- $this->ensureDiscountSchema();
- $id = (int)($_POST['id'] ?? 0);
- if ($id <= 0) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+discount+id']);
- }
- $db = Database::get();
- if ($db instanceof PDO) {
- try {
- $stmt = $db->prepare("DELETE FROM ac_store_discount_codes WHERE id = :id LIMIT 1");
- $stmt->execute([':id' => $id]);
- } catch (Throwable $e) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+delete+discount']);
- }
- }
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']);
- }
-
- public function adminSendTestEmail(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
- $to = trim((string)($_POST['test_email_to'] ?? ''));
- if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=Enter+a+valid+test+email']);
- }
-
- $subjectTpl = trim((string)($_POST['store_order_email_subject'] ?? Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}')));
- $htmlTpl = trim((string)($_POST['store_order_email_html'] ?? Settings::get('store_order_email_html', $this->defaultOrderEmailHtml())));
- if ($htmlTpl === '') {
- $htmlTpl = $this->defaultOrderEmailHtml();
- }
-
- $mockItems = [
- ['title' => 'Demo Track One', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'],
- ['title' => 'Demo Track Two', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'],
- ];
- $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore'));
- $logoUrl = trim((string)Settings::get('store_email_logo_url', ''));
- $logoHtml = $logoUrl !== ''
- ? '
 . ')
'
- : '';
- $map = [
- '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'),
- '{{order_no}}' => 'AC-TEST-' . date('YmdHis'),
- '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'),
- '{{currency}}' => 'GBP',
- '{{total}}' => '2.98',
- '{{status}}' => 'paid',
- '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'),
- '{{logo_html}}' => $logoHtml,
- '{{items_html}}' => $this->renderItemsHtml($mockItems, 'GBP'),
- '{{download_links_html}}' => '
Example download links appear here after payment.
',
- ];
- $subject = strtr($subjectTpl !== '' ? $subjectTpl : 'Your AudioCore order {{order_no}}', $map);
- $html = strtr($htmlTpl, $map);
- $mailSettings = [
- 'smtp_host' => Settings::get('smtp_host', ''),
- 'smtp_port' => Settings::get('smtp_port', '587'),
- 'smtp_user' => Settings::get('smtp_user', ''),
- 'smtp_pass' => Settings::get('smtp_pass', ''),
- 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
- 'smtp_from_email' => Settings::get('smtp_from_email', ''),
- 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
- ];
- $result = Mailer::send($to, $subject, $html, $mailSettings);
- $ref = 'mailtest-' . date('YmdHis') . '-' . random_int(100, 999);
- $this->logMailDebug($ref, [
- 'to' => $to,
- 'subject' => $subject,
- 'result' => $result,
- ]);
- if (!($result['ok'] ?? false)) {
- $msg = rawurlencode('Unable to send test email. Ref ' . $ref . ': ' . (string)($result['error'] ?? 'Unknown'));
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=' . $msg]);
- }
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&saved=1']);
- }
-
- public function adminTestPaypal(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $settings = $this->settingsPayload();
- $clientId = trim((string)($_POST['store_paypal_client_id'] ?? ($settings['store_paypal_client_id'] ?? '')));
- $secret = trim((string)($_POST['store_paypal_secret'] ?? ($settings['store_paypal_secret'] ?? '')));
- if ($clientId === '' || $secret === '') {
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=Enter+PayPal+Client+ID+and+Secret+first']);
- }
-
- $probeMode = strtolower(trim((string)($_POST['paypal_probe_mode'] ?? 'live')));
- $isSandbox = ($probeMode === 'sandbox');
- $result = $this->paypalTokenProbe($clientId, $secret, $isSandbox);
- if (!($result['ok'] ?? false)) {
- $err = rawurlencode((string)($result['error'] ?? 'PayPal validation failed'));
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=' . $err]);
- }
-
- return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&saved=1&paypal_test=' . rawurlencode($isSandbox ? 'sandbox' : 'live')]);
- }
-
- public function adminCustomers(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $this->ensureAnalyticsSchema();
- $q = trim((string)($_GET['q'] ?? ''));
- $like = '%' . $q . '%';
-
- $rows = [];
- $db = Database::get();
- if ($db instanceof PDO) {
- try {
- $sql = "
- SELECT
- c.id,
- c.name,
- c.email,
- c.is_active,
- c.created_at,
+ ");
+ $recentOrders = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
+ } catch (Throwable $e) {
+ $recentOrders = [];
+ }
+
+ try {
+ $stmt = $db->query("
+ SELECT name, email, is_active, created_at
+ FROM ac_store_customers
+ ORDER BY created_at DESC
+ LIMIT 5
+ ");
+ $newCustomers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
+ } catch (Throwable $e) {
+ $newCustomers = [];
+ }
+ }
+ }
+
+ return new Response($this->view->render('admin/index.php', [
+ 'title' => 'Store',
+ 'tables_ready' => $tablesReady,
+ 'private_root' => Settings::get('store_private_root', $this->privateRoot()),
+ 'private_root_ready' => $this->privateRootReady(),
+ 'stats' => $stats,
+ 'recent_orders' => $recentOrders,
+ 'new_customers' => $newCustomers,
+ 'currency' => Settings::get('store_currency', 'GBP'),
+ ]));
+ }
+
+ public function adminSettings(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $this->ensureDiscountSchema();
+ $this->ensureBundleSchema();
+ $this->ensureSalesChartSchema();
+ $settings = $this->settingsPayload();
+ $gateways = [];
+ foreach (Gateways::all() as $gateway) {
+ $gateways[] = [
+ 'key' => $gateway->key(),
+ 'label' => $gateway->label(),
+ 'enabled' => $gateway->isEnabled($settings),
+ ];
+ }
+
+ return new Response($this->view->render('admin/settings.php', [
+ 'title' => 'Store Settings',
+ 'settings' => $settings,
+ 'gateways' => $gateways,
+ 'discounts' => $this->adminDiscountRows(),
+ 'bundles' => $this->adminBundleRows(),
+ 'bundle_release_options' => $this->bundleReleaseOptions(),
+ 'private_root_ready' => $this->privateRootReady(),
+ 'tab' => (string)($_GET['tab'] ?? 'general'),
+ 'error' => (string)($_GET['error'] ?? ''),
+ 'saved' => (string)($_GET['saved'] ?? ''),
+ 'chart_rows' => $this->salesChartRows(
+ (string)($settings['store_sales_chart_default_scope'] ?? 'tracks'),
+ (string)($settings['store_sales_chart_default_window'] ?? 'latest'),
+ max(1, min(30, (int)($settings['store_sales_chart_limit'] ?? '10')))
+ ),
+ 'chart_last_rebuild_at' => (string)$this->salesChartLastRebuildAt(),
+ 'chart_cron_url' => $this->salesChartCronUrl(),
+ 'chart_cron_cmd' => $this->salesChartCronCommand(),
+ ]));
+ }
+
+ public function adminSaveSettings(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $current = $this->settingsPayload();
+
+ $currencyRaw = array_key_exists('store_currency', $_POST) ? (string)$_POST['store_currency'] : (string)($current['store_currency'] ?? 'GBP');
+ $currency = strtoupper(trim($currencyRaw));
+ if (!preg_match('/^[A-Z]{3}$/', $currency)) {
+ $currency = 'GBP';
+ }
+ $privateRootRaw = array_key_exists('store_private_root', $_POST) ? (string)$_POST['store_private_root'] : (string)($current['store_private_root'] ?? $this->privateRoot());
+ $privateRoot = trim($privateRootRaw);
+ if ($privateRoot === '') {
+ $privateRoot = $this->privateRoot();
+ }
+ $downloadLimitRaw = array_key_exists('store_download_limit', $_POST) ? (string)$_POST['store_download_limit'] : (string)($current['store_download_limit'] ?? '5');
+ $downloadLimit = max(1, (int)$downloadLimitRaw);
+ $expiryDaysRaw = array_key_exists('store_download_expiry_days', $_POST) ? (string)$_POST['store_download_expiry_days'] : (string)($current['store_download_expiry_days'] ?? '30');
+ $expiryDays = max(1, (int)$expiryDaysRaw);
+ $orderPrefixRaw = array_key_exists('store_order_prefix', $_POST) ? (string)$_POST['store_order_prefix'] : (string)($current['store_order_prefix'] ?? 'AC-ORD');
+ $orderPrefix = $this->sanitizeOrderPrefix($orderPrefixRaw);
+ $timezoneRaw = array_key_exists('store_timezone', $_POST) ? (string)$_POST['store_timezone'] : (string)($current['store_timezone'] ?? 'UTC');
+ $timezone = $this->normalizeTimezone($timezoneRaw);
+ $testMode = array_key_exists('store_test_mode', $_POST) ? ((string)$_POST['store_test_mode'] === '1' ? '1' : '0') : (string)($current['store_test_mode'] ?? '1');
+ $stripeEnabled = array_key_exists('store_stripe_enabled', $_POST) ? ((string)$_POST['store_stripe_enabled'] === '1' ? '1' : '0') : (string)($current['store_stripe_enabled'] ?? '0');
+ $stripePublic = trim((string)(array_key_exists('store_stripe_public_key', $_POST) ? $_POST['store_stripe_public_key'] : ($current['store_stripe_public_key'] ?? '')));
+ $stripeSecret = trim((string)(array_key_exists('store_stripe_secret_key', $_POST) ? $_POST['store_stripe_secret_key'] : ($current['store_stripe_secret_key'] ?? '')));
+ $paypalEnabled = array_key_exists('store_paypal_enabled', $_POST) ? ((string)$_POST['store_paypal_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_enabled'] ?? '0');
+ $paypalClientId = trim((string)(array_key_exists('store_paypal_client_id', $_POST) ? $_POST['store_paypal_client_id'] : ($current['store_paypal_client_id'] ?? '')));
+ $paypalSecret = trim((string)(array_key_exists('store_paypal_secret', $_POST) ? $_POST['store_paypal_secret'] : ($current['store_paypal_secret'] ?? '')));
+ $paypalCardsEnabled = array_key_exists('store_paypal_cards_enabled', $_POST) ? ((string)$_POST['store_paypal_cards_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_cards_enabled'] ?? '0');
+ $paypalSdkMode = strtolower(trim((string)(array_key_exists('store_paypal_sdk_mode', $_POST) ? $_POST['store_paypal_sdk_mode'] : ($current['store_paypal_sdk_mode'] ?? 'embedded_fields'))));
+ if (!in_array($paypalSdkMode, ['embedded_fields', 'paypal_only_fallback'], true)) {
+ $paypalSdkMode = 'embedded_fields';
+ }
+ $paypalMerchantCountry = strtoupper(trim((string)(array_key_exists('store_paypal_merchant_country', $_POST) ? $_POST['store_paypal_merchant_country'] : ($current['store_paypal_merchant_country'] ?? ''))));
+ if ($paypalMerchantCountry !== '' && !preg_match('/^[A-Z]{2}$/', $paypalMerchantCountry)) {
+ $paypalMerchantCountry = '';
+ }
+ $paypalCardBrandingText = trim((string)(array_key_exists('store_paypal_card_branding_text', $_POST) ? $_POST['store_paypal_card_branding_text'] : ($current['store_paypal_card_branding_text'] ?? 'Pay with card')));
+ if ($paypalCardBrandingText === '') {
+ $paypalCardBrandingText = 'Pay with card';
+ }
+ $emailLogoUrl = trim((string)(array_key_exists('store_email_logo_url', $_POST) ? $_POST['store_email_logo_url'] : ($current['store_email_logo_url'] ?? '')));
+ $orderEmailSubject = trim((string)(array_key_exists('store_order_email_subject', $_POST) ? $_POST['store_order_email_subject'] : ($current['store_order_email_subject'] ?? 'Your AudioCore order {{order_no}}')));
+ $orderEmailHtml = trim((string)(array_key_exists('store_order_email_html', $_POST) ? $_POST['store_order_email_html'] : ($current['store_order_email_html'] ?? '')));
+ if ($orderEmailHtml === '') {
+ $orderEmailHtml = $this->defaultOrderEmailHtml();
+ }
+ $salesChartDefaultScope = strtolower(trim((string)(array_key_exists('store_sales_chart_default_scope', $_POST) ? $_POST['store_sales_chart_default_scope'] : ($current['store_sales_chart_default_scope'] ?? 'tracks'))));
+ if (!in_array($salesChartDefaultScope, ['tracks', 'releases'], true)) {
+ $salesChartDefaultScope = 'tracks';
+ }
+ $salesChartDefaultWindow = strtolower(trim((string)(array_key_exists('store_sales_chart_default_window', $_POST) ? $_POST['store_sales_chart_default_window'] : ($current['store_sales_chart_default_window'] ?? 'latest'))));
+ if (!in_array($salesChartDefaultWindow, ['latest', 'weekly', 'all_time'], true)) {
+ $salesChartDefaultWindow = 'latest';
+ }
+ $salesChartLimit = max(1, min(50, (int)(array_key_exists('store_sales_chart_limit', $_POST) ? $_POST['store_sales_chart_limit'] : ($current['store_sales_chart_limit'] ?? '10'))));
+ $latestHours = max(1, min(168, (int)(array_key_exists('store_sales_chart_latest_hours', $_POST) ? $_POST['store_sales_chart_latest_hours'] : ($current['store_sales_chart_latest_hours'] ?? '24'))));
+ $refreshMinutes = max(5, min(1440, (int)(array_key_exists('store_sales_chart_refresh_minutes', $_POST) ? $_POST['store_sales_chart_refresh_minutes'] : ($current['store_sales_chart_refresh_minutes'] ?? '180'))));
+
+ Settings::set('store_currency', $currency);
+ Settings::set('store_private_root', $privateRoot);
+ Settings::set('store_download_limit', (string)$downloadLimit);
+ Settings::set('store_download_expiry_days', (string)$expiryDays);
+ Settings::set('store_order_prefix', $orderPrefix);
+ Settings::set('store_timezone', $timezone);
+ Settings::set('store_test_mode', $testMode);
+ Settings::set('store_stripe_enabled', $stripeEnabled);
+ Settings::set('store_stripe_public_key', $stripePublic);
+ Settings::set('store_stripe_secret_key', $stripeSecret);
+ Settings::set('store_paypal_enabled', $paypalEnabled);
+ Settings::set('store_paypal_client_id', $paypalClientId);
+ Settings::set('store_paypal_secret', $paypalSecret);
+ Settings::set('store_paypal_cards_enabled', $paypalCardsEnabled);
+ Settings::set('store_paypal_sdk_mode', $paypalSdkMode);
+ Settings::set('store_paypal_merchant_country', $paypalMerchantCountry);
+ Settings::set('store_paypal_card_branding_text', $paypalCardBrandingText);
+ Settings::set('store_email_logo_url', $emailLogoUrl);
+ Settings::set('store_order_email_subject', $orderEmailSubject !== '' ? $orderEmailSubject : 'Your AudioCore order {{order_no}}');
+ Settings::set('store_order_email_html', $orderEmailHtml);
+ Settings::set('store_sales_chart_default_scope', $salesChartDefaultScope);
+ Settings::set('store_sales_chart_default_window', $salesChartDefaultWindow);
+ Settings::set('store_sales_chart_limit', (string)$salesChartLimit);
+ Settings::set('store_sales_chart_latest_hours', (string)$latestHours);
+ Settings::set('store_sales_chart_refresh_minutes', (string)$refreshMinutes);
+ if (isset($_POST['store_sales_chart_regen_key'])) {
+ try {
+ Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24)));
+ } catch (Throwable $e) {
+ }
+ }
+ if (trim((string)Settings::get('store_sales_chart_cron_key', '')) === '') {
+ try {
+ Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24)));
+ } catch (Throwable $e) {
+ }
+ }
+ $this->ensureSalesChartSchema();
+
+ if (!$this->ensurePrivateRoot($privateRoot)) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?error=Unable+to+create+or+write+private+download+folder']);
+ }
+
+ $tab = trim((string)($_POST['tab'] ?? 'general'));
+ $tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'bundles', 'sales_chart'], true) ? $tab : 'general';
+ return new Response('', 302, ['Location' => '/admin/store/settings?saved=1&tab=' . rawurlencode($tab)]);
+ }
+
+ public function adminRebuildSalesChart(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+ $this->ensureSalesChartSchema();
+ $ok = $this->rebuildSalesChartCache();
+ if (!$ok) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&error=Unable+to+rebuild+sales+chart']);
+ }
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&saved=1']);
+ }
+
+ public function salesChartCron(): Response
+ {
+ $this->ensureSalesChartSchema();
+ $expected = trim((string)Settings::get('store_sales_chart_cron_key', ''));
+ $provided = trim((string)($_GET['key'] ?? ''));
+ if ($expected === '' || !hash_equals($expected, $provided)) {
+ return new Response('Unauthorized', 401);
+ }
+ $ok = $this->rebuildSalesChartCache();
+ if (!$ok) {
+ return new Response('Sales chart rebuild failed', 500);
+ }
+ return new Response('OK');
+ }
+
+ public function adminDiscountCreate(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+ $this->ensureDiscountSchema();
+ $code = strtoupper(trim((string)($_POST['code'] ?? '')));
+ $type = trim((string)($_POST['discount_type'] ?? 'percent'));
+ $value = (float)($_POST['discount_value'] ?? 0);
+ $maxUses = (int)($_POST['max_uses'] ?? 0);
+ $expiresAt = trim((string)($_POST['expires_at'] ?? ''));
+ $isActive = isset($_POST['is_active']) ? 1 : 0;
+
+ if (!preg_match('/^[A-Z0-9_-]{3,32}$/', $code)) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+code+format']);
+ }
+ if (!in_array($type, ['percent', 'fixed'], true)) {
+ $type = 'percent';
+ }
+ if ($value <= 0) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Discount+value+must+be+greater+than+0']);
+ }
+ if ($type === 'percent' && $value > 100) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Percent+cannot+exceed+100']);
+ }
+ if ($maxUses < 0) {
+ $maxUses = 0;
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Database+unavailable']);
+ }
+ try {
+ $stmt = $db->prepare("
+ INSERT INTO ac_store_discount_codes
+ (code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at, updated_at)
+ VALUES (:code, :discount_type, :discount_value, :max_uses, 0, :expires_at, :is_active, NOW(), NOW())
+ ON DUPLICATE KEY UPDATE
+ discount_type = VALUES(discount_type),
+ discount_value = VALUES(discount_value),
+ max_uses = VALUES(max_uses),
+ expires_at = VALUES(expires_at),
+ is_active = VALUES(is_active),
+ updated_at = NOW()
+ ");
+ $stmt->execute([
+ ':code' => $code,
+ ':discount_type' => $type,
+ ':discount_value' => $value,
+ ':max_uses' => $maxUses,
+ ':expires_at' => $expiresAt !== '' ? $expiresAt : null,
+ ':is_active' => $isActive,
+ ]);
+ } catch (Throwable $e) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+save+discount+code']);
+ }
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']);
+ }
+
+ public function adminDiscountDelete(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+ $this->ensureDiscountSchema();
+ $id = (int)($_POST['id'] ?? 0);
+ if ($id <= 0) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+discount+id']);
+ }
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ try {
+ $stmt = $db->prepare("DELETE FROM ac_store_discount_codes WHERE id = :id LIMIT 1");
+ $stmt->execute([':id' => $id]);
+ } catch (Throwable $e) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+delete+discount']);
+ }
+ }
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']);
+ }
+
+ public function adminBundleCreate(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+ $this->ensureBundleSchema();
+
+ $name = trim((string)($_POST['name'] ?? ''));
+ $slug = trim((string)($_POST['slug'] ?? ''));
+ $price = (float)($_POST['bundle_price'] ?? 0);
+ $currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP')));
+ $purchaseLabel = trim((string)($_POST['purchase_label'] ?? ''));
+ $isEnabled = isset($_POST['is_enabled']) ? 1 : 0;
+ $releaseIds = $_POST['release_ids'] ?? [];
+
+ if ($name === '') {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Bundle+name+is+required']);
+ }
+ if ($price <= 0) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Bundle+price+must+be+greater+than+0']);
+ }
+ if (!preg_match('/^[A-Z]{3}$/', $currency)) {
+ $currency = 'GBP';
+ }
+ if ($slug === '') {
+ $slug = $this->slugify($name);
+ } else {
+ $slug = $this->slugify($slug);
+ }
+ if (!is_array($releaseIds) || !$releaseIds) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Choose+at+least+one+release']);
+ }
+ $releaseIds = array_values(array_unique(array_filter(array_map(static function ($id): int {
+ return (int)$id;
+ }, $releaseIds), static function ($id): bool {
+ return $id > 0;
+ })));
+ if (!$releaseIds) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Choose+at+least+one+release']);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Database+unavailable']);
+ }
+
+ $bundleId = (int)($_POST['id'] ?? 0);
+ try {
+ $db->beginTransaction();
+ if ($bundleId > 0) {
+ $stmt = $db->prepare("
+ UPDATE ac_store_bundles
+ SET name = :name, slug = :slug, bundle_price = :bundle_price, currency = :currency,
+ purchase_label = :purchase_label, is_enabled = :is_enabled, updated_at = NOW()
+ WHERE id = :id
+ ");
+ $stmt->execute([
+ ':name' => $name,
+ ':slug' => $slug,
+ ':bundle_price' => $price,
+ ':currency' => $currency,
+ ':purchase_label' => $purchaseLabel !== '' ? $purchaseLabel : null,
+ ':is_enabled' => $isEnabled,
+ ':id' => $bundleId,
+ ]);
+ } else {
+ $stmt = $db->prepare("
+ INSERT INTO ac_store_bundles (name, slug, bundle_price, currency, purchase_label, is_enabled, created_at, updated_at)
+ VALUES (:name, :slug, :bundle_price, :currency, :purchase_label, :is_enabled, NOW(), NOW())
+ ON DUPLICATE KEY UPDATE
+ name = VALUES(name),
+ bundle_price = VALUES(bundle_price),
+ currency = VALUES(currency),
+ purchase_label = VALUES(purchase_label),
+ is_enabled = VALUES(is_enabled),
+ updated_at = NOW()
+ ");
+ $stmt->execute([
+ ':name' => $name,
+ ':slug' => $slug,
+ ':bundle_price' => $price,
+ ':currency' => $currency,
+ ':purchase_label' => $purchaseLabel !== '' ? $purchaseLabel : null,
+ ':is_enabled' => $isEnabled,
+ ]);
+ $bundleId = (int)$db->lastInsertId();
+ if ($bundleId <= 0) {
+ $lookup = $db->prepare("SELECT id FROM ac_store_bundles WHERE slug = :slug LIMIT 1");
+ $lookup->execute([':slug' => $slug]);
+ $bundleId = (int)($lookup->fetchColumn() ?: 0);
+ }
+ }
+
+ if ($bundleId <= 0) {
+ throw new \RuntimeException('Bundle id missing');
+ }
+
+ $del = $db->prepare("DELETE FROM ac_store_bundle_items WHERE bundle_id = :bundle_id");
+ $del->execute([':bundle_id' => $bundleId]);
+
+ $ins = $db->prepare("
+ INSERT INTO ac_store_bundle_items (bundle_id, release_id, sort_order, created_at)
+ VALUES (:bundle_id, :release_id, :sort_order, NOW())
+ ");
+ $sort = 1;
+ foreach ($releaseIds as $releaseId) {
+ $ins->execute([
+ ':bundle_id' => $bundleId,
+ ':release_id' => $releaseId,
+ ':sort_order' => $sort++,
+ ]);
+ }
+
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) {
+ $db->rollBack();
+ }
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Unable+to+save+bundle']);
+ }
+
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&saved=1']);
+ }
+
+ public function adminBundleDelete(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+ $this->ensureBundleSchema();
+
+ $id = (int)($_POST['id'] ?? 0);
+ if ($id <= 0) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Invalid+bundle+id']);
+ }
+
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ try {
+ $db->beginTransaction();
+ $stmt = $db->prepare("DELETE FROM ac_store_bundle_items WHERE bundle_id = :id");
+ $stmt->execute([':id' => $id]);
+ $stmt = $db->prepare("DELETE FROM ac_store_bundles WHERE id = :id LIMIT 1");
+ $stmt->execute([':id' => $id]);
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) {
+ $db->rollBack();
+ }
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&error=Unable+to+delete+bundle']);
+ }
+ }
+
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=bundles&saved=1']);
+ }
+
+ public function adminSendTestEmail(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+ $to = trim((string)($_POST['test_email_to'] ?? ''));
+ if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=Enter+a+valid+test+email']);
+ }
+
+ $subjectTpl = trim((string)($_POST['store_order_email_subject'] ?? Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}')));
+ $htmlTpl = trim((string)($_POST['store_order_email_html'] ?? Settings::get('store_order_email_html', $this->defaultOrderEmailHtml())));
+ if ($htmlTpl === '') {
+ $htmlTpl = $this->defaultOrderEmailHtml();
+ }
+
+ $mockItems = [
+ ['title' => 'Demo Track One', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'],
+ ['title' => 'Demo Track Two', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'],
+ ];
+ $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore'));
+ $logoUrl = trim((string)Settings::get('store_email_logo_url', ''));
+ $logoHtml = $logoUrl !== ''
+ ? '
 . ')
'
+ : '';
+ $map = [
+ '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'),
+ '{{order_no}}' => 'AC-TEST-' . date('YmdHis'),
+ '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'),
+ '{{currency}}' => 'GBP',
+ '{{total}}' => '2.98',
+ '{{status}}' => 'paid',
+ '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'),
+ '{{logo_html}}' => $logoHtml,
+ '{{items_html}}' => $this->renderItemsHtml($mockItems, 'GBP'),
+ '{{download_links_html}}' => '
Example download links appear here after payment.
',
+ ];
+ $subject = strtr($subjectTpl !== '' ? $subjectTpl : 'Your AudioCore order {{order_no}}', $map);
+ $html = strtr($htmlTpl, $map);
+ $mailSettings = [
+ 'smtp_host' => Settings::get('smtp_host', ''),
+ 'smtp_port' => Settings::get('smtp_port', '587'),
+ 'smtp_user' => Settings::get('smtp_user', ''),
+ 'smtp_pass' => Settings::get('smtp_pass', ''),
+ 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
+ 'smtp_from_email' => Settings::get('smtp_from_email', ''),
+ 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
+ ];
+ $result = Mailer::send($to, $subject, $html, $mailSettings);
+ $ref = 'mailtest-' . date('YmdHis') . '-' . random_int(100, 999);
+ $this->logMailDebug($ref, [
+ 'to' => $to,
+ 'subject' => $subject,
+ 'result' => $result,
+ ]);
+ if (!($result['ok'] ?? false)) {
+ $msg = rawurlencode('Unable to send test email. Ref ' . $ref . ': ' . (string)($result['error'] ?? 'Unknown'));
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=' . $msg]);
+ }
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&saved=1']);
+ }
+
+ public function adminTestPaypal(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $settings = $this->settingsPayload();
+ $clientId = trim((string)($_POST['store_paypal_client_id'] ?? ($settings['store_paypal_client_id'] ?? '')));
+ $secret = trim((string)($_POST['store_paypal_secret'] ?? ($settings['store_paypal_secret'] ?? '')));
+ if ($clientId === '' || $secret === '') {
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=Enter+PayPal+Client+ID+and+Secret+first']);
+ }
+
+ $probeMode = strtolower(trim((string)($_POST['paypal_probe_mode'] ?? 'live')));
+ $isSandbox = ($probeMode === 'sandbox');
+ $result = $this->paypalTokenProbe($clientId, $secret, $isSandbox);
+ if (!($result['ok'] ?? false)) {
+ $err = rawurlencode((string)($result['error'] ?? 'PayPal validation failed'));
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=' . $err]);
+ }
+
+ $cardProbe = $this->paypalCardCapabilityProbe($clientId, $secret, $isSandbox);
+ Settings::set('store_paypal_cards_capability_status', (string)($cardProbe['status'] ?? 'unknown'));
+ Settings::set('store_paypal_cards_capability_message', (string)($cardProbe['message'] ?? ''));
+ Settings::set('store_paypal_cards_capability_checked_at', gmdate('c'));
+ Settings::set('store_paypal_cards_capability_mode', $isSandbox ? 'sandbox' : 'live');
+
+ return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&saved=1&paypal_test=' . rawurlencode($isSandbox ? 'sandbox' : 'live')]);
+ }
+
+ public function adminCustomers(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $this->ensureAnalyticsSchema();
+ $q = trim((string)($_GET['q'] ?? ''));
+ $like = '%' . $q . '%';
+
+ $rows = [];
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ try {
+ $sql = "
+ SELECT
+ c.id,
+ c.name,
+ c.email,
+ c.is_active,
+ c.created_at,
COALESCE(os.order_count, 0) AS order_count,
- COALESCE(os.revenue, 0) AS revenue,
+ COALESCE(os.before_fees, 0) AS before_fees,
+ COALESCE(os.paypal_fees, 0) AS paypal_fees,
+ COALESCE(os.after_fees, 0) AS after_fees,
os.last_order_no,
- os.last_order_id,
- os.last_ip,
- os.last_order_at
- FROM ac_store_customers c
- LEFT JOIN (
- SELECT
- o.email,
+ os.last_order_id,
+ os.last_ip,
+ os.last_order_at
+ FROM ac_store_customers c
+ LEFT JOIN (
+ SELECT
+ o.email,
COUNT(*) AS order_count,
- SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue,
+ SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_gross, o.total) ELSE 0 END) AS before_fees,
+ SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END) AS paypal_fees,
+ SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_net, o.total) ELSE 0 END) AS after_fees,
MAX(o.created_at) AS last_order_at,
- SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no,
- SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id,
- SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip
- FROM ac_store_orders o
- GROUP BY o.email
- ) os ON os.email = c.email
- ";
- if ($q !== '') {
- $sql .= " WHERE c.email LIKE :q OR c.name LIKE :q OR os.last_order_no LIKE :q ";
- }
- $sql .= "
- ORDER BY COALESCE(os.last_order_at, c.created_at) DESC
- LIMIT 500
- ";
- $stmt = $db->prepare($sql);
- if ($q !== '') {
- $stmt->execute([':q' => $like]);
- } else {
- $stmt->execute();
- }
- $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- } catch (Throwable $e) {
- $rows = [];
- }
-
- try {
- $guestSql = "
- SELECT
- NULL AS id,
- '' AS name,
- o.email AS email,
- 1 AS is_active,
- MIN(o.created_at) AS created_at,
+ SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no,
+ SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id,
+ SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip
+ FROM ac_store_orders o
+ GROUP BY o.email
+ ) os ON os.email = c.email
+ ";
+ if ($q !== '') {
+ $sql .= " WHERE c.email LIKE :q OR c.name LIKE :q OR os.last_order_no LIKE :q ";
+ }
+ $sql .= "
+ ORDER BY COALESCE(os.last_order_at, c.created_at) DESC
+ LIMIT 500
+ ";
+ $stmt = $db->prepare($sql);
+ if ($q !== '') {
+ $stmt->execute([':q' => $like]);
+ } else {
+ $stmt->execute();
+ }
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ } catch (Throwable $e) {
+ $rows = [];
+ }
+
+ try {
+ $guestSql = "
+ SELECT
+ NULL AS id,
+ '' AS name,
+ o.email AS email,
+ 1 AS is_active,
+ MIN(o.created_at) AS created_at,
COUNT(*) AS order_count,
- SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue,
+ SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_gross, o.total) ELSE 0 END) AS before_fees,
+ SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_fee, 0) ELSE 0 END) AS paypal_fees,
+ SUM(CASE WHEN o.status = 'paid' THEN COALESCE(o.payment_net, o.total) ELSE 0 END) AS after_fees,
SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no,
- SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id,
- SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip,
- MAX(o.created_at) AS last_order_at
- FROM ac_store_orders o
- LEFT JOIN ac_store_customers c ON c.email = o.email
- WHERE c.id IS NULL
- ";
- if ($q !== '') {
- $guestSql .= " AND (o.email LIKE :q OR o.order_no LIKE :q) ";
- }
- $guestSql .= "
- GROUP BY o.email
- ORDER BY MAX(o.created_at) DESC
- LIMIT 500
- ";
- $guestStmt = $db->prepare($guestSql);
- if ($q !== '') {
- $guestStmt->execute([':q' => $like]);
- } else {
- $guestStmt->execute();
- }
- $guests = $guestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- if ($guests) {
- $rows = array_merge($rows, $guests);
- }
- } catch (Throwable $e) {
- }
-
- if ($rows) {
- $ipHistoryMap = $this->loadCustomerIpHistory($db);
- foreach ($rows as &$row) {
- $emailKey = strtolower(trim((string)($row['email'] ?? '')));
- $row['ips'] = $ipHistoryMap[$emailKey] ?? [];
- }
- unset($row);
- }
- }
-
- return new Response($this->view->render('admin/customers.php', [
- 'title' => 'Store Customers',
- 'customers' => $rows,
- 'currency' => Settings::get('store_currency', 'GBP'),
- 'q' => $q,
- ]));
- }
-
- public function adminOrders(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $this->ensureAnalyticsSchema();
- $q = trim((string)($_GET['q'] ?? ''));
- $like = '%' . $q . '%';
-
- $rows = [];
- $db = Database::get();
- if ($db instanceof PDO) {
- try {
- $sql = "
- SELECT id, order_no, email, status, currency, total, created_at, customer_ip
+ SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id,
+ SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip,
+ MAX(o.created_at) AS last_order_at
+ FROM ac_store_orders o
+ LEFT JOIN ac_store_customers c ON c.email = o.email
+ WHERE c.id IS NULL
+ ";
+ if ($q !== '') {
+ $guestSql .= " AND (o.email LIKE :q OR o.order_no LIKE :q) ";
+ }
+ $guestSql .= "
+ GROUP BY o.email
+ ORDER BY MAX(o.created_at) DESC
+ LIMIT 500
+ ";
+ $guestStmt = $db->prepare($guestSql);
+ if ($q !== '') {
+ $guestStmt->execute([':q' => $like]);
+ } else {
+ $guestStmt->execute();
+ }
+ $guests = $guestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ if ($guests) {
+ $rows = array_merge($rows, $guests);
+ }
+ } catch (Throwable $e) {
+ }
+
+ if ($rows) {
+ $ipHistoryMap = $this->loadCustomerIpHistory($db);
+ foreach ($rows as &$row) {
+ $emailKey = strtolower(trim((string)($row['email'] ?? '')));
+ $row['ips'] = $ipHistoryMap[$emailKey] ?? [];
+ }
+ unset($row);
+ }
+ }
+
+ return new Response($this->view->render('admin/customers.php', [
+ 'title' => 'Store Customers',
+ 'customers' => $rows,
+ 'currency' => Settings::get('store_currency', 'GBP'),
+ 'q' => $q,
+ ]));
+ }
+
+ public function adminOrders(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $this->ensureAnalyticsSchema();
+ $q = trim((string)($_GET['q'] ?? ''));
+ $like = '%' . $q . '%';
+
+ $rows = [];
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ try {
+ $sql = "
+ SELECT id, order_no, email, status, currency, total, payment_gross, payment_fee, payment_net, created_at, customer_ip
FROM ac_store_orders
- ";
- if ($q !== '') {
- $sql .= " WHERE order_no LIKE :q OR email LIKE :q OR customer_ip LIKE :q OR status LIKE :q ";
- }
- $sql .= "
- ORDER BY created_at DESC
- LIMIT 500
- ";
- $stmt = $db->prepare($sql);
- if ($q !== '') {
- $stmt->execute([':q' => $like]);
- } else {
- $stmt->execute();
- }
- $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- } catch (Throwable $e) {
- $rows = [];
- }
- }
-
- return new Response($this->view->render('admin/orders.php', [
- 'title' => 'Store Orders',
- 'orders' => $rows,
- 'q' => $q,
- 'saved' => (string)($_GET['saved'] ?? ''),
- 'error' => (string)($_GET['error'] ?? ''),
- ]));
- }
-
- public function adminOrderCreate(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $email = strtolower(trim((string)($_POST['email'] ?? '')));
- $currency = strtoupper(trim((string)($_POST['currency'] ?? Settings::get('store_currency', 'GBP'))));
- $total = (float)($_POST['total'] ?? 0);
- $status = trim((string)($_POST['status'] ?? 'pending'));
- $orderNo = trim((string)($_POST['order_no'] ?? ''));
-
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Enter+a+valid+email']);
- }
- if (!preg_match('/^[A-Z]{3}$/', $currency)) {
- $currency = 'GBP';
- }
- if (!in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) {
- $status = 'pending';
- }
- if ($total < 0) {
- $total = 0.0;
- }
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
- }
-
- if ($orderNo === '') {
- $prefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD'));
- $orderNo = $prefix . '-' . date('YmdHis') . '-' . random_int(100, 999);
- }
-
- try {
- $customerId = $this->upsertCustomerFromOrder($db, $email, $this->clientIp(), substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), $total);
- $stmt = $db->prepare("
- INSERT INTO ac_store_orders
- (order_no, customer_id, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at)
- VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW())
- ");
- $stmt->execute([
- ':order_no' => $orderNo,
- ':customer_id' => $customerId > 0 ? $customerId : null,
- ':email' => $email,
- ':status' => $status,
- ':currency' => $currency,
- ':subtotal' => $total,
- ':total' => $total,
- ':provider' => 'manual',
- ':payment_ref' => null,
- ':customer_ip' => $this->clientIp(),
- ':customer_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
- ]);
- } catch (Throwable $e) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+create+order']);
- }
-
- return new Response('', 302, ['Location' => '/admin/store/orders?saved=created']);
- }
-
- public function adminOrderStatus(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $orderId = (int)($_POST['id'] ?? 0);
- $status = trim((string)($_POST['status'] ?? ''));
- if ($orderId <= 0 || !in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+update']);
- }
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
- }
-
- try {
- $stmt = $db->prepare("UPDATE ac_store_orders SET status = :status, updated_at = NOW() WHERE id = :id LIMIT 1");
- $stmt->execute([
- ':status' => $status,
- ':id' => $orderId,
- ]);
- $this->rebuildSalesChartCache();
- } catch (Throwable $e) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+update+order']);
- }
-
- return new Response('', 302, ['Location' => '/admin/store/orders?saved=status']);
- }
-
- public function adminOrderDelete(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $orderId = (int)($_POST['id'] ?? 0);
- if ($orderId <= 0) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']);
- }
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
- }
-
- try {
- $db->beginTransaction();
- $db->prepare("DELETE FROM ac_store_download_events WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
- $db->prepare("DELETE FROM ac_store_download_tokens WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
- $db->prepare("DELETE FROM ac_store_order_items WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
- $db->prepare("DELETE FROM ac_store_orders WHERE id = :id LIMIT 1")->execute([':id' => $orderId]);
- $db->commit();
- $this->rebuildSalesChartCache();
- } catch (Throwable $e) {
- if ($db->inTransaction()) {
- $db->rollBack();
- }
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+delete+order']);
- }
-
- return new Response('', 302, ['Location' => '/admin/store/orders?saved=deleted']);
- }
-
- public function adminOrderRefund(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $orderId = (int)($_POST['id'] ?? 0);
- if ($orderId <= 0) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']);
- }
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
- }
-
- try {
- $stmt = $db->prepare("
- SELECT id, status, payment_provider, payment_ref, currency, total
+ ";
+ if ($q !== '') {
+ $sql .= " WHERE order_no LIKE :q OR email LIKE :q OR customer_ip LIKE :q OR status LIKE :q ";
+ }
+ $sql .= "
+ ORDER BY created_at DESC
+ LIMIT 500
+ ";
+ $stmt = $db->prepare($sql);
+ if ($q !== '') {
+ $stmt->execute([':q' => $like]);
+ } else {
+ $stmt->execute();
+ }
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ } catch (Throwable $e) {
+ $rows = [];
+ }
+ }
+
+ return new Response($this->view->render('admin/orders.php', [
+ 'title' => 'Store Orders',
+ 'orders' => $rows,
+ 'q' => $q,
+ 'saved' => (string)($_GET['saved'] ?? ''),
+ 'error' => (string)($_GET['error'] ?? ''),
+ ]));
+ }
+
+ public function adminOrderCreate(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $email = strtolower(trim((string)($_POST['email'] ?? '')));
+ $currency = strtoupper(trim((string)($_POST['currency'] ?? Settings::get('store_currency', 'GBP'))));
+ $total = (float)($_POST['total'] ?? 0);
+ $status = trim((string)($_POST['status'] ?? 'pending'));
+ $orderNo = trim((string)($_POST['order_no'] ?? ''));
+
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Enter+a+valid+email']);
+ }
+ if (!preg_match('/^[A-Z]{3}$/', $currency)) {
+ $currency = 'GBP';
+ }
+ if (!in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) {
+ $status = 'pending';
+ }
+ if ($total < 0) {
+ $total = 0.0;
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
+ }
+
+ if ($orderNo === '') {
+ $prefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD'));
+ $orderNo = $prefix . '-' . date('YmdHis') . '-' . random_int(100, 999);
+ }
+
+ try {
+ $customerId = $this->upsertCustomerFromOrder($db, $email, $this->clientIp(), substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), $total);
+ $stmt = $db->prepare("
+ INSERT INTO ac_store_orders
+ (order_no, customer_id, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at)
+ VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW())
+ ");
+ $stmt->execute([
+ ':order_no' => $orderNo,
+ ':customer_id' => $customerId > 0 ? $customerId : null,
+ ':email' => $email,
+ ':status' => $status,
+ ':currency' => $currency,
+ ':subtotal' => $total,
+ ':total' => $total,
+ ':provider' => 'manual',
+ ':payment_ref' => null,
+ ':customer_ip' => $this->clientIp(),
+ ':customer_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
+ ]);
+ } catch (Throwable $e) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+create+order']);
+ }
+
+ return new Response('', 302, ['Location' => '/admin/store/orders?saved=created']);
+ }
+
+ public function adminOrderStatus(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $orderId = (int)($_POST['id'] ?? 0);
+ $status = trim((string)($_POST['status'] ?? ''));
+ if ($orderId <= 0 || !in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+update']);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
+ }
+
+ try {
+ $currentStmt = $db->prepare("SELECT status FROM ac_store_orders WHERE id = :id LIMIT 1");
+ $currentStmt->execute([':id' => $orderId]);
+ $currentStatus = (string)($currentStmt->fetchColumn() ?: '');
+ $stmt = $db->prepare("UPDATE ac_store_orders SET status = :status, updated_at = NOW() WHERE id = :id LIMIT 1");
+ $stmt->execute([
+ ':status' => $status,
+ ':id' => $orderId,
+ ]);
+ $this->rebuildSalesChartCache();
+ if ($status === 'paid' && $currentStatus !== 'paid') {
+ ApiLayer::dispatchSaleWebhooksForOrder($orderId);
+ }
+ } catch (Throwable $e) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+update+order']);
+ }
+
+ return new Response('', 302, ['Location' => '/admin/store/orders?saved=status']);
+ }
+
+ public function adminOrderDelete(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $orderId = (int)($_POST['id'] ?? 0);
+ if ($orderId <= 0) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
+ }
+
+ try {
+ $db->beginTransaction();
+ $db->prepare("DELETE FROM ac_store_download_events WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
+ $db->prepare("DELETE FROM ac_store_download_tokens WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
+ $db->prepare("DELETE FROM ac_store_order_item_allocations WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
+ $db->prepare("DELETE FROM ac_store_order_items WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
+ $db->prepare("DELETE FROM ac_store_orders WHERE id = :id LIMIT 1")->execute([':id' => $orderId]);
+ $db->commit();
+ $this->rebuildSalesChartCache();
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) {
+ $db->rollBack();
+ }
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+delete+order']);
+ }
+
+ return new Response('', 302, ['Location' => '/admin/store/orders?saved=deleted']);
+ }
+
+ public function adminOrderRefund(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $orderId = (int)($_POST['id'] ?? 0);
+ if ($orderId <= 0) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
+ }
+
+ try {
+ $stmt = $db->prepare("
+ SELECT id, status, payment_provider, payment_ref, currency, total
+ FROM ac_store_orders
+ WHERE id = :id
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $orderId]);
+ $order = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$order) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Order+not+found']);
+ }
+
+ $status = (string)($order['status'] ?? '');
+ if ($status === 'refunded') {
+ return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']);
+ }
+ if ($status !== 'paid') {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Only+paid+orders+can+be+refunded']);
+ }
+
+ $provider = strtolower(trim((string)($order['payment_provider'] ?? '')));
+ $paymentRef = trim((string)($order['payment_ref'] ?? ''));
+
+ if ($provider === 'paypal') {
+ if ($paymentRef === '') {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Missing+PayPal+capture+reference']);
+ }
+ $clientId = trim((string)Settings::get('store_paypal_client_id', ''));
+ $secret = trim((string)Settings::get('store_paypal_secret', ''));
+ if ($clientId === '' || $secret === '') {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=PayPal+credentials+missing']);
+ }
+ $refund = $this->paypalRefundCapture(
+ $clientId,
+ $secret,
+ $this->isEnabledSetting(Settings::get('store_test_mode', '1')),
+ $paymentRef,
+ (string)($order['currency'] ?? 'GBP'),
+ (float)($order['total'] ?? 0)
+ );
+ if (!($refund['ok'] ?? false)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=' . rawurlencode((string)($refund['error'] ?? 'Refund failed'))]);
+ }
+ }
+
+ $upd = $db->prepare("UPDATE ac_store_orders SET status = 'refunded', updated_at = NOW() WHERE id = :id LIMIT 1");
+ $upd->execute([':id' => $orderId]);
+ $revoke = $db->prepare("
+ UPDATE ac_store_download_tokens
+ SET downloads_used = download_limit,
+ expires_at = NOW()
+ WHERE order_id = :order_id
+ ");
+ $revoke->execute([':order_id' => $orderId]);
+ $this->rebuildSalesChartCache();
+ } catch (Throwable $e) {
+ return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+refund+order']);
+ }
+
+ return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']);
+ }
+
+ public function adminOrderView(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $this->ensureAnalyticsSchema();
+
+ $orderId = (int)($_GET['id'] ?? 0);
+ if ($orderId <= 0) {
+ return new Response('', 302, ['Location' => '/admin/store/orders']);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/admin/store/orders']);
+ }
+
+ $order = null;
+ $items = [];
+ $downloadsByItem = [];
+ $downloadEvents = [];
+ try {
+ $orderStmt = $db->prepare("
+ SELECT id, order_no, email, status, currency, subtotal, total, payment_provider, payment_ref, payment_gross, payment_fee, payment_net, payment_currency, customer_ip, created_at, updated_at
FROM ac_store_orders
- WHERE id = :id
- LIMIT 1
- ");
- $stmt->execute([':id' => $orderId]);
- $order = $stmt->fetch(PDO::FETCH_ASSOC);
- if (!$order) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Order+not+found']);
- }
-
- $status = (string)($order['status'] ?? '');
- if ($status === 'refunded') {
- return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']);
- }
- if ($status !== 'paid') {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Only+paid+orders+can+be+refunded']);
- }
-
- $provider = strtolower(trim((string)($order['payment_provider'] ?? '')));
- $paymentRef = trim((string)($order['payment_ref'] ?? ''));
-
- if ($provider === 'paypal') {
- if ($paymentRef === '') {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Missing+PayPal+capture+reference']);
- }
- $clientId = trim((string)Settings::get('store_paypal_client_id', ''));
- $secret = trim((string)Settings::get('store_paypal_secret', ''));
- if ($clientId === '' || $secret === '') {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=PayPal+credentials+missing']);
- }
- $refund = $this->paypalRefundCapture(
- $clientId,
- $secret,
- $this->isEnabledSetting(Settings::get('store_test_mode', '1')),
- $paymentRef,
- (string)($order['currency'] ?? 'GBP'),
- (float)($order['total'] ?? 0)
- );
- if (!($refund['ok'] ?? false)) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=' . rawurlencode((string)($refund['error'] ?? 'Refund failed'))]);
- }
- }
-
- $upd = $db->prepare("UPDATE ac_store_orders SET status = 'refunded', updated_at = NOW() WHERE id = :id LIMIT 1");
- $upd->execute([':id' => $orderId]);
- $revoke = $db->prepare("
- UPDATE ac_store_download_tokens
- SET downloads_used = download_limit,
- expires_at = NOW()
- WHERE order_id = :order_id
- ");
- $revoke->execute([':order_id' => $orderId]);
- $this->rebuildSalesChartCache();
- } catch (Throwable $e) {
- return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+refund+order']);
- }
-
- return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']);
- }
-
- public function adminOrderView(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $this->ensureAnalyticsSchema();
-
- $orderId = (int)($_GET['id'] ?? 0);
- if ($orderId <= 0) {
- return new Response('', 302, ['Location' => '/admin/store/orders']);
- }
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/admin/store/orders']);
- }
-
- $order = null;
- $items = [];
- $downloadsByItem = [];
- $downloadEvents = [];
- try {
- $orderStmt = $db->prepare("
- SELECT id, order_no, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, created_at, updated_at
- FROM ac_store_orders
- WHERE id = :id
- LIMIT 1
- ");
- $orderStmt->execute([':id' => $orderId]);
- $order = $orderStmt->fetch(PDO::FETCH_ASSOC) ?: null;
- if (!$order) {
- return new Response('', 302, ['Location' => '/admin/store/orders']);
- }
-
- $itemStmt = $db->prepare("
- SELECT
- oi.id,
- oi.item_type,
- oi.item_id,
- oi.title_snapshot,
- oi.unit_price_snapshot,
- oi.currency_snapshot,
- oi.qty,
- oi.line_total,
- oi.created_at,
- t.id AS token_id,
- t.download_limit,
- t.downloads_used,
- t.expires_at,
- f.file_name,
- f.file_url
- FROM ac_store_order_items oi
- LEFT JOIN ac_store_download_tokens t ON t.order_item_id = oi.id
- LEFT JOIN ac_store_files f ON f.id = t.file_id
- WHERE oi.order_id = :order_id
- ORDER BY oi.id ASC
- ");
- $itemStmt->execute([':order_id' => $orderId]);
- $items = $itemStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
-
- $eventStmt = $db->prepare("
- SELECT
- e.id,
- e.order_item_id,
- e.file_id,
- e.ip_address,
- e.user_agent,
- e.downloaded_at,
- f.file_name
- FROM ac_store_download_events e
- LEFT JOIN ac_store_files f ON f.id = e.file_id
- WHERE e.order_id = :order_id
- ORDER BY e.downloaded_at DESC
- LIMIT 500
- ");
- $eventStmt->execute([':order_id' => $orderId]);
- $downloadEvents = $eventStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- } catch (Throwable $e) {
- }
-
- foreach ($downloadEvents as $event) {
- $key = (int)($event['order_item_id'] ?? 0);
- if ($key <= 0) {
- continue;
- }
- if (!isset($downloadsByItem[$key])) {
- $downloadsByItem[$key] = [
- 'count' => 0,
- 'ips' => [],
- ];
- }
- $downloadsByItem[$key]['count']++;
- $ip = trim((string)($event['ip_address'] ?? ''));
- if ($ip !== '' && !in_array($ip, $downloadsByItem[$key]['ips'], true)) {
- $downloadsByItem[$key]['ips'][] = $ip;
- }
- }
-
- return new Response($this->view->render('admin/order.php', [
- 'title' => 'Order Detail',
- 'order' => $order,
- 'items' => $items,
- 'downloads_by_item' => $downloadsByItem,
- 'download_events' => $downloadEvents,
- ]));
- }
-
- public function adminInstall(): Response
- {
- if ($guard = $this->guard()) {
- return $guard;
- }
-
- $db = Database::get();
- if ($db instanceof PDO) {
- try {
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_release_products (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- release_id INT UNSIGNED NOT NULL UNIQUE,
- is_enabled TINYINT(1) NOT NULL DEFAULT 0,
- bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
- currency CHAR(3) NOT NULL DEFAULT 'GBP',
- purchase_label VARCHAR(120) NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_track_products (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- release_track_id INT UNSIGNED NOT NULL UNIQUE,
- is_enabled TINYINT(1) NOT NULL DEFAULT 0,
- track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
- currency CHAR(3) NOT NULL DEFAULT 'GBP',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_files (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- scope_type ENUM('release','track') NOT NULL,
- scope_id INT UNSIGNED NOT NULL,
- file_url VARCHAR(1024) NOT NULL,
- file_name VARCHAR(255) NOT NULL,
- file_size BIGINT UNSIGNED NULL,
- mime_type VARCHAR(128) NULL,
- is_active TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_customers (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- name VARCHAR(140) NULL,
- email VARCHAR(190) NOT NULL UNIQUE,
- password_hash VARCHAR(255) NULL,
- is_active TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_orders (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- order_no VARCHAR(32) NOT NULL UNIQUE,
- customer_id INT UNSIGNED NULL,
- email VARCHAR(190) NOT NULL,
- status ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending',
- currency CHAR(3) NOT NULL DEFAULT 'GBP',
- subtotal DECIMAL(10,2) NOT NULL DEFAULT 0.00,
- total DECIMAL(10,2) NOT NULL DEFAULT 0.00,
- discount_code VARCHAR(64) NULL,
- discount_amount DECIMAL(10,2) NULL,
- payment_provider VARCHAR(40) NULL,
- payment_ref VARCHAR(120) NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_order_items (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- order_id INT UNSIGNED NOT NULL,
- item_type ENUM('release','track') NOT NULL,
- item_id INT UNSIGNED NOT NULL,
- title_snapshot VARCHAR(255) NOT NULL,
- unit_price_snapshot DECIMAL(10,2) NOT NULL DEFAULT 0.00,
- currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP',
- qty INT UNSIGNED NOT NULL DEFAULT 1,
- line_total DECIMAL(10,2) NOT NULL DEFAULT 0.00,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_download_tokens (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- order_id INT UNSIGNED NOT NULL,
- order_item_id INT UNSIGNED NOT NULL,
- file_id INT UNSIGNED NOT NULL,
- email VARCHAR(190) NOT NULL,
- token VARCHAR(96) NOT NULL UNIQUE,
- download_limit INT UNSIGNED NOT NULL DEFAULT 5,
- downloads_used INT UNSIGNED NOT NULL DEFAULT 0,
- expires_at DATETIME NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_download_events (
- id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- token_id INT UNSIGNED NOT NULL,
- order_id INT UNSIGNED NOT NULL,
- order_item_id INT UNSIGNED NOT NULL,
- file_id INT UNSIGNED NOT NULL,
- email VARCHAR(190) NULL,
- ip_address VARCHAR(64) NULL,
- user_agent VARCHAR(255) NULL,
- downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- KEY idx_store_download_events_order (order_id),
- KEY idx_store_download_events_item (order_item_id),
- KEY idx_store_download_events_ip (ip_address)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
- id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- email VARCHAR(190) NOT NULL,
- token_hash CHAR(64) NOT NULL UNIQUE,
- expires_at DATETIME NOT NULL,
- used_at DATETIME NULL,
- request_ip VARCHAR(64) NULL,
- request_user_agent VARCHAR(255) NULL,
- used_ip VARCHAR(64) NULL,
- used_user_agent VARCHAR(255) NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- KEY idx_store_login_tokens_email (email),
- KEY idx_store_login_tokens_expires (expires_at)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code");
- } catch (Throwable $e) {
- }
- } catch (Throwable $e) {
- }
- }
-
- $this->ensurePrivateRoot(Settings::get('store_private_root', $this->privateRoot()));
- $this->ensureSalesChartSchema();
- return new Response('', 302, ['Location' => '/admin/store']);
- }
-
+ WHERE id = :id
+ LIMIT 1
+ ");
+ $orderStmt->execute([':id' => $orderId]);
+ $order = $orderStmt->fetch(PDO::FETCH_ASSOC) ?: null;
+ if (!$order) {
+ return new Response('', 302, ['Location' => '/admin/store/orders']);
+ }
+
+ $itemStmt = $db->prepare("
+ SELECT
+ oi.id,
+ oi.item_type,
+ oi.item_id,
+ oi.title_snapshot,
+ oi.unit_price_snapshot,
+ oi.currency_snapshot,
+ oi.qty,
+ oi.line_total,
+ oi.created_at,
+ t.id AS token_id,
+ t.download_limit,
+ t.downloads_used,
+ t.expires_at,
+ f.file_name,
+ f.file_url
+ FROM ac_store_order_items oi
+ LEFT JOIN ac_store_download_tokens t ON t.order_item_id = oi.id
+ LEFT JOIN ac_store_files f ON f.id = t.file_id
+ WHERE oi.order_id = :order_id
+ ORDER BY oi.id ASC
+ ");
+ $itemStmt->execute([':order_id' => $orderId]);
+ $items = $itemStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+
+ $eventStmt = $db->prepare("
+ SELECT
+ e.id,
+ e.order_item_id,
+ e.file_id,
+ e.ip_address,
+ e.user_agent,
+ e.downloaded_at,
+ f.file_name
+ FROM ac_store_download_events e
+ LEFT JOIN ac_store_files f ON f.id = e.file_id
+ WHERE e.order_id = :order_id
+ ORDER BY e.downloaded_at DESC
+ LIMIT 500
+ ");
+ $eventStmt->execute([':order_id' => $orderId]);
+ $downloadEvents = $eventStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ } catch (Throwable $e) {
+ }
+
+ foreach ($downloadEvents as $event) {
+ $key = (int)($event['order_item_id'] ?? 0);
+ if ($key <= 0) {
+ continue;
+ }
+ if (!isset($downloadsByItem[$key])) {
+ $downloadsByItem[$key] = [
+ 'count' => 0,
+ 'ips' => [],
+ ];
+ }
+ $downloadsByItem[$key]['count']++;
+ $ip = trim((string)($event['ip_address'] ?? ''));
+ if ($ip !== '' && !in_array($ip, $downloadsByItem[$key]['ips'], true)) {
+ $downloadsByItem[$key]['ips'][] = $ip;
+ }
+ }
+
+ return new Response($this->view->render('admin/order.php', [
+ 'title' => 'Order Detail',
+ 'order' => $order,
+ 'items' => $items,
+ 'downloads_by_item' => $downloadsByItem,
+ 'download_events' => $downloadEvents,
+ ]));
+ }
+
+ public function adminInstall(): Response
+ {
+ if ($guard = $this->guard()) {
+ return $guard;
+ }
+
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ try {
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_release_products (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ release_id INT UNSIGNED NOT NULL UNIQUE,
+ is_enabled TINYINT(1) NOT NULL DEFAULT 0,
+ bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ currency CHAR(3) NOT NULL DEFAULT 'GBP',
+ purchase_label VARCHAR(120) NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_track_products (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ release_track_id INT UNSIGNED NOT NULL UNIQUE,
+ is_enabled TINYINT(1) NOT NULL DEFAULT 0,
+ track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ currency CHAR(3) NOT NULL DEFAULT 'GBP',
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_files (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ scope_type ENUM('release','track') NOT NULL,
+ scope_id INT UNSIGNED NOT NULL,
+ file_url VARCHAR(1024) NOT NULL,
+ file_name VARCHAR(255) NOT NULL,
+ file_size BIGINT UNSIGNED NULL,
+ mime_type VARCHAR(128) NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_customers (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(140) NULL,
+ email VARCHAR(190) NOT NULL UNIQUE,
+ password_hash VARCHAR(255) NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_orders (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ order_no VARCHAR(32) NOT NULL UNIQUE,
+ customer_id INT UNSIGNED NULL,
+ email VARCHAR(190) NOT NULL,
+ status ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending',
+ currency CHAR(3) NOT NULL DEFAULT 'GBP',
+ subtotal DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ total DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ discount_code VARCHAR(64) NULL,
+ discount_amount DECIMAL(10,2) NULL,
+ payment_provider VARCHAR(40) NULL,
+ payment_ref VARCHAR(120) NULL,
+ payment_gross DECIMAL(10,2) NULL,
+ payment_fee DECIMAL(10,2) NULL,
+ payment_net DECIMAL(10,2) NULL,
+ payment_currency CHAR(3) NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_order_items (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ order_id INT UNSIGNED NOT NULL,
+ item_type ENUM('release','track','bundle') NOT NULL,
+ item_id INT UNSIGNED NOT NULL,
+ title_snapshot VARCHAR(255) NOT NULL,
+ unit_price_snapshot DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP',
+ qty INT UNSIGNED NOT NULL DEFAULT 1,
+ line_total DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_download_tokens (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ order_id INT UNSIGNED NOT NULL,
+ order_item_id INT UNSIGNED NOT NULL,
+ file_id INT UNSIGNED NOT NULL,
+ email VARCHAR(190) NOT NULL,
+ token VARCHAR(96) NOT NULL UNIQUE,
+ download_limit INT UNSIGNED NOT NULL DEFAULT 5,
+ downloads_used INT UNSIGNED NOT NULL DEFAULT 0,
+ expires_at DATETIME NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_download_events (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ token_id INT UNSIGNED NOT NULL,
+ order_id INT UNSIGNED NOT NULL,
+ order_item_id INT UNSIGNED NOT NULL,
+ file_id INT UNSIGNED NOT NULL,
+ email VARCHAR(190) NULL,
+ ip_address VARCHAR(64) NULL,
+ user_agent VARCHAR(255) NULL,
+ downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_store_download_events_order (order_id),
+ KEY idx_store_download_events_item (order_item_id),
+ KEY idx_store_download_events_ip (ip_address)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ email VARCHAR(190) NOT NULL,
+ token_hash CHAR(64) NOT NULL UNIQUE,
+ expires_at DATETIME NOT NULL,
+ used_at DATETIME NULL,
+ request_ip VARCHAR(64) NULL,
+ request_user_agent VARCHAR(255) NULL,
+ used_ip VARCHAR(64) NULL,
+ used_user_agent VARCHAR(255) NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_store_login_tokens_email (email),
+ KEY idx_store_login_tokens_expires (expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code");
+ } catch (Throwable $e) {
+ }
+ } catch (Throwable $e) {
+ }
+ }
+
+ $this->ensurePrivateRoot(Settings::get('store_private_root', $this->privateRoot()));
+ $this->ensureBundleSchema();
+ $this->ensureSalesChartSchema();
+ return new Response('', 302, ['Location' => '/admin/store']);
+ }
+
public function accountIndex(): Response
{
if (session_status() !== PHP_SESSION_ACTIVE) {
@@ -1082,832 +1295,1110 @@ class StoreController
$this->ensureAnalyticsSchema();
$email = strtolower(trim((string)($_SESSION['ac_store_customer_email'] ?? '')));
- $flash = (string)($_GET['message'] ?? '');
- $error = (string)($_GET['error'] ?? '');
- $orders = [];
- $downloads = [];
-
- if ($email !== '') {
- $db = Database::get();
- if ($db instanceof PDO) {
- try {
- $orderStmt = $db->prepare("
- SELECT id, order_no, status, currency, total, created_at
- FROM ac_store_orders
- WHERE email = :email
- ORDER BY created_at DESC
- LIMIT 100
- ");
- $orderStmt->execute([':email' => $email]);
- $orders = $orderStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- } catch (Throwable $e) {
- $orders = [];
- }
-
+ $flash = $this->consumeAccountFlash('message');
+ $error = $this->consumeAccountFlash('error');
+ $orders = [];
+ $downloads = [];
+
+ if ($email !== '') {
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ try {
+ $orderStmt = $db->prepare("
+ SELECT id, order_no, status, currency, total, created_at
+ FROM ac_store_orders
+ WHERE email = :email
+ ORDER BY created_at DESC
+ LIMIT 100
+ ");
+ $orderStmt->execute([':email' => $email]);
+ $orders = $orderStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ } catch (Throwable $e) {
+ $orders = [];
+ }
+
try {
$downloadStmt = $db->prepare("
SELECT
o.order_no,
+ t.file_id,
f.file_name,
t.download_limit,
t.downloads_used,
t.expires_at,
t.token
- FROM ac_store_download_tokens t
- JOIN ac_store_orders o ON o.id = t.order_id
- JOIN ac_store_files f ON f.id = t.file_id
- WHERE t.email = :email
- ORDER BY t.created_at DESC
- LIMIT 500
- ");
- $downloadStmt->execute([':email' => $email]);
- $rows = $downloadStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- foreach ($rows as $row) {
- $token = trim((string)($row['token'] ?? ''));
- if ($token === '') {
- continue;
- }
+ FROM ac_store_download_tokens t
+ JOIN ac_store_orders o ON o.id = t.order_id
+ JOIN ac_store_files f ON f.id = t.file_id
+ WHERE t.email = :email
+ ORDER BY t.created_at DESC
+ LIMIT 500
+ ");
+ $downloadStmt->execute([':email' => $email]);
+ $rows = $downloadStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ foreach ($rows as $row) {
+ $token = trim((string)($row['token'] ?? ''));
+ if ($token === '') {
+ continue;
+ }
$downloads[] = [
'order_no' => (string)($row['order_no'] ?? ''),
- 'file_name' => (string)($row['file_name'] ?? 'Download'),
+ 'file_name' => $this->buildDownloadLabel(
+ $db,
+ (int)($row['file_id'] ?? 0),
+ (string)($row['file_name'] ?? 'Download')
+ ),
'download_limit' => (int)($row['download_limit'] ?? 0),
'downloads_used' => (int)($row['downloads_used'] ?? 0),
'expires_at' => (string)($row['expires_at'] ?? ''),
'url' => '/store/download?token=' . rawurlencode($token),
];
- }
- } catch (Throwable $e) {
- $downloads = [];
- }
- }
- }
-
- return new Response($this->view->render('site/account.php', [
- 'title' => 'Account',
- 'is_logged_in' => ($email !== ''),
- 'email' => $email,
- 'orders' => $orders,
- 'downloads' => $downloads,
- 'message' => $flash,
- 'error' => $error,
- 'download_limit' => (int)Settings::get('store_download_limit', '5'),
- 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'),
- ]));
- }
-
+ }
+ } catch (Throwable $e) {
+ $downloads = [];
+ }
+ }
+ }
+
+ return new Response($this->view->render('site/account.php', [
+ 'title' => 'Account',
+ 'is_logged_in' => ($email !== ''),
+ 'email' => $email,
+ 'orders' => $orders,
+ 'downloads' => $downloads,
+ 'message' => $flash,
+ 'error' => $error,
+ 'download_limit' => (int)Settings::get('store_download_limit', '5'),
+ 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'),
+ ]));
+ }
+
public function accountRequestLogin(): Response
{
$email = strtolower(trim((string)($_POST['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
- return new Response('', 302, ['Location' => '/account?error=Enter+a+valid+email+address']);
+ $this->setAccountFlash('error', 'Enter a valid email address');
+ return new Response('', 302, ['Location' => '/account']);
+ }
+ $limitKey = sha1($email . '|' . $this->clientIp());
+ if (RateLimiter::tooMany('store_account_login_request', $limitKey, 8, 600)) {
+ $this->setAccountFlash('error', 'Too many login requests. Please wait 10 minutes');
+ return new Response('', 302, ['Location' => '/account']);
}
$db = Database::get();
if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']);
+ $this->setAccountFlash('error', 'Account login service is currently unavailable');
+ return new Response('', 302, ['Location' => '/account']);
}
- $this->ensureAnalyticsSchema();
-
- try {
- // Rate limit token requests per email.
- $limitStmt = $db->prepare("
- SELECT COUNT(*) AS c
- FROM ac_store_login_tokens
- WHERE email = :email
- AND created_at >= (NOW() - INTERVAL 10 MINUTE)
- ");
+ $this->ensureAnalyticsSchema();
+
+ try {
+ // Rate limit token requests per email.
+ $limitStmt = $db->prepare("
+ SELECT COUNT(*) AS c
+ FROM ac_store_login_tokens
+ WHERE email = :email
+ AND created_at >= (NOW() - INTERVAL 10 MINUTE)
+ ");
$limitStmt->execute([':email' => $email]);
$limitRow = $limitStmt->fetch(PDO::FETCH_ASSOC);
if ((int)($limitRow['c'] ?? 0) >= 5) {
- return new Response('', 302, ['Location' => '/account?error=Too+many+login+requests.+Please+wait+10+minutes']);
+ $this->setAccountFlash('error', 'Too many login requests. Please wait 10 minutes');
+ return new Response('', 302, ['Location' => '/account']);
}
-
- // Send generic response even if no orders exist.
- $orderStmt = $db->prepare("SELECT COUNT(*) AS c FROM ac_store_orders WHERE email = :email");
- $orderStmt->execute([':email' => $email]);
- $orderCount = (int)(($orderStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0));
-
- if ($orderCount > 0) {
- $rawToken = bin2hex(random_bytes(24));
- $tokenHash = hash('sha256', $rawToken);
- $expiresAt = (new \DateTimeImmutable('now'))->modify('+15 minutes')->format('Y-m-d H:i:s');
-
- $ins = $db->prepare("
- INSERT INTO ac_store_login_tokens
- (email, token_hash, expires_at, request_ip, request_user_agent, created_at)
- VALUES (:email, :token_hash, :expires_at, :request_ip, :request_user_agent, NOW())
- ");
- $ins->execute([
- ':email' => $email,
- ':token_hash' => $tokenHash,
- ':expires_at' => $expiresAt,
- ':request_ip' => $this->clientIp(),
- ':request_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
- ]);
-
- $siteName = trim((string)Settings::get('site_title', ''));
- if ($siteName === '') {
- $siteName = 'AudioCore V1.5';
+
+ // Send generic response even if no orders exist.
+ $orderStmt = $db->prepare("SELECT COUNT(*) AS c FROM ac_store_orders WHERE email = :email");
+ $orderStmt->execute([':email' => $email]);
+ $orderCount = (int)(($orderStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0));
+
+ if ($orderCount > 0) {
+ $rawToken = bin2hex(random_bytes(24));
+ $tokenHash = hash('sha256', $rawToken);
+ $expiresAt = (new \DateTimeImmutable('now'))->modify('+15 minutes')->format('Y-m-d H:i:s');
+
+ $ins = $db->prepare("
+ INSERT INTO ac_store_login_tokens
+ (email, token_hash, expires_at, request_ip, request_user_agent, created_at)
+ VALUES (:email, :token_hash, :expires_at, :request_ip, :request_user_agent, NOW())
+ ");
+ $ins->execute([
+ ':email' => $email,
+ ':token_hash' => $tokenHash,
+ ':expires_at' => $expiresAt,
+ ':request_ip' => $this->clientIp(),
+ ':request_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
+ ]);
+
+ $siteName = trim((string)Settings::get('site_title', ''));
+ if ($siteName === '') {
+ $siteName = 'AudioCore V1.5.1';
+ }
+ $loginUrl = $this->baseUrl() . '/account/login?token=' . rawurlencode($rawToken);
+ $subject = $siteName . ' account access link';
+ $html = '
Hello,
'
+ . '
Use this secure link to access your downloads:
'
+ . '
' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '
'
+ . '
This link expires in 15 minutes and can only be used once.
';
+ $mailSettings = [
+ 'smtp_host' => Settings::get('smtp_host', ''),
+ 'smtp_port' => Settings::get('smtp_port', '587'),
+ 'smtp_user' => Settings::get('smtp_user', ''),
+ 'smtp_pass' => Settings::get('smtp_pass', ''),
+ 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
+ 'smtp_from_email' => Settings::get('smtp_from_email', ''),
+ 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
+ ];
+ $send = Mailer::send($email, $subject, $html, $mailSettings);
+ if (!($send['ok'] ?? false)) {
+ error_log('AC account login mail failed: ' . (string)($send['error'] ?? 'unknown error'));
+ if (!empty($send['debug'])) {
+ error_log('AC account login mail debug: ' . str_replace(["\r", "\n"], ' | ', (string)$send['debug']));
+ }
+ $this->setAccountFlash('error', 'Unable to send login email right now');
+ return new Response('', 302, ['Location' => '/account']);
}
- $loginUrl = $this->baseUrl() . '/account/login?token=' . rawurlencode($rawToken);
- $subject = $siteName . ' account access link';
- $html = '
Hello,
'
- . '
Use this secure link to access your downloads:
'
- . '
' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '
'
- . '
This link expires in 15 minutes and can only be used once.
';
- $mailSettings = [
- 'smtp_host' => Settings::get('smtp_host', ''),
- 'smtp_port' => Settings::get('smtp_port', '587'),
- 'smtp_user' => Settings::get('smtp_user', ''),
- 'smtp_pass' => Settings::get('smtp_pass', ''),
- 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
- 'smtp_from_email' => Settings::get('smtp_from_email', ''),
- 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
- ];
- Mailer::send($email, $subject, $html, $mailSettings);
}
} catch (Throwable $e) {
- return new Response('', 302, ['Location' => '/account?error=Unable+to+send+login+email+right+now']);
+ $this->setAccountFlash('error', 'Unable to send login email right now');
+ return new Response('', 302, ['Location' => '/account']);
}
- return new Response('', 302, ['Location' => '/account?message=If+we+found+orders+for+that+email%2C+a+login+link+has+been+sent']);
+ $this->setAccountFlash('message', 'If we found orders for that email, a login link has been sent');
+ return new Response('', 302, ['Location' => '/account']);
}
-
+
public function accountLogin(): Response
{
$token = trim((string)($_GET['token'] ?? ''));
if ($token === '') {
- return new Response('', 302, ['Location' => '/account?error=Invalid+login+token']);
+ $this->setAccountFlash('error', 'Invalid login token');
+ return new Response('', 302, ['Location' => '/account']);
}
$db = Database::get();
if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']);
+ $this->setAccountFlash('error', 'Account login service is currently unavailable');
+ return new Response('', 302, ['Location' => '/account']);
}
- $this->ensureAnalyticsSchema();
-
- try {
- $hash = hash('sha256', $token);
- $stmt = $db->prepare("
- SELECT id, email, expires_at, used_at
- FROM ac_store_login_tokens
- WHERE token_hash = :token_hash
- LIMIT 1
- ");
+ $this->ensureAnalyticsSchema();
+
+ try {
+ $hash = hash('sha256', $token);
+ $stmt = $db->prepare("
+ SELECT id, email, expires_at, used_at
+ FROM ac_store_login_tokens
+ WHERE token_hash = :token_hash
+ LIMIT 1
+ ");
$stmt->execute([':token_hash' => $hash]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
- return new Response('', 302, ['Location' => '/account?error=Login+link+is+invalid']);
+ $this->setAccountFlash('error', 'Login link is invalid');
+ return new Response('', 302, ['Location' => '/account']);
}
if (!empty($row['used_at'])) {
- return new Response('', 302, ['Location' => '/account?error=Login+link+has+already+been+used']);
+ $this->setAccountFlash('error', 'Login link has already been used');
+ return new Response('', 302, ['Location' => '/account']);
}
$expiresAt = (string)($row['expires_at'] ?? '');
if ($expiresAt !== '') {
if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) {
- return new Response('', 302, ['Location' => '/account?error=Login+link+has+expired']);
+ $this->setAccountFlash('error', 'Login link has expired');
+ return new Response('', 302, ['Location' => '/account']);
}
}
-
- $upd = $db->prepare("
- UPDATE ac_store_login_tokens
- SET used_at = NOW(), used_ip = :used_ip, used_user_agent = :used_user_agent
- WHERE id = :id
- ");
- $upd->execute([
- ':id' => (int)$row['id'],
- ':used_ip' => $this->clientIp(),
- ':used_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
- ]);
-
+
+ $upd = $db->prepare("
+ UPDATE ac_store_login_tokens
+ SET used_at = NOW(), used_ip = :used_ip, used_user_agent = :used_user_agent
+ WHERE id = :id
+ ");
+ $upd->execute([
+ ':id' => (int)$row['id'],
+ ':used_ip' => $this->clientIp(),
+ ':used_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
+ ]);
+
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
+ session_regenerate_id(true);
$_SESSION['ac_store_customer_email'] = strtolower(trim((string)($row['email'] ?? '')));
} catch (Throwable $e) {
- return new Response('', 302, ['Location' => '/account?error=Unable+to+complete+login']);
+ $this->setAccountFlash('error', 'Unable to complete login');
+ return new Response('', 302, ['Location' => '/account']);
}
- return new Response('', 302, ['Location' => '/account?message=Signed+in+successfully']);
+ $this->setAccountFlash('message', 'Signed in successfully');
+ return new Response('', 302, ['Location' => '/account']);
}
-
+
public function accountLogout(): Response
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
unset($_SESSION['ac_store_customer_email']);
- return new Response('', 302, ['Location' => '/account?message=You+have+been+signed+out']);
+ unset($_SESSION['ac_store_flash_message'], $_SESSION['ac_store_flash_error']);
+ session_regenerate_id(true);
+ $this->setAccountFlash('message', 'You have been signed out');
+ return new Response('', 302, ['Location' => '/account']);
}
-
- public function cartAdd(): Response
+
+ public function cartAdd(): Response
+ {
+ $itemType = trim((string)($_POST['item_type'] ?? 'track'));
+ if (!in_array($itemType, ['track', 'release', 'bundle'], true)) {
+ $itemType = 'track';
+ }
+
+ $itemId = (int)($_POST['item_id'] ?? 0);
+ $title = trim((string)($_POST['title'] ?? 'Item'));
+ $coverUrl = trim((string)($_POST['cover_url'] ?? ''));
+ $currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP')));
+ $price = (float)($_POST['price'] ?? 0);
+ $qty = max(1, (int)($_POST['qty'] ?? 1));
+ $returnUrl = trim((string)($_POST['return_url'] ?? '/releases'));
+ $itemTitle = $title !== '' ? $title : 'Item';
+
+ if ($itemId <= 0 || $price <= 0) {
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+ if (!preg_match('/^[A-Z]{3}$/', $currency)) {
+ $currency = 'GBP';
+ }
+
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $cart = $_SESSION['ac_cart'] ?? [];
+ if (!is_array($cart)) {
+ $cart = [];
+ }
+
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ if (!$this->isItemReleased($db, $itemType, $itemId)) {
+ $_SESSION['ac_site_notice'] = [
+ 'type' => 'info',
+ 'text' => 'This release is scheduled and is not available yet.',
+ ];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+ }
+
+ if ($itemType === 'bundle') {
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+ $bundle = $this->loadBundleForCart($db, $itemId);
+ if (!$bundle) {
+ $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'This bundle is unavailable right now.'];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ $bundleKey = 'bundle:' . $itemId;
+ $cart[$bundleKey] = [
+ 'key' => $bundleKey,
+ 'item_type' => 'bundle',
+ 'item_id' => $itemId,
+ 'title' => (string)$bundle['name'],
+ 'cover_url' => (string)($bundle['cover_url'] ?? $coverUrl),
+ 'price' => (float)$bundle['bundle_price'],
+ 'currency' => (string)$bundle['currency'],
+ 'release_count' => (int)($bundle['release_count'] ?? 0),
+ 'track_count' => (int)($bundle['track_count'] ?? 0),
+ 'qty' => 1,
+ ];
+ $_SESSION['ac_cart'] = $cart;
+ $_SESSION['ac_site_notice'] = [
+ 'type' => 'ok',
+ 'text' => '"' . (string)$bundle['name'] . '" bundle added to your cart.',
+ ];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ if ($itemType === 'release') {
+ if ($db instanceof PDO) {
+ try {
+ $trackStmt = $db->prepare("
+ SELECT t.id,
+ t.title,
+ t.mix_name,
+ COALESCE(sp.track_price, 0.00) AS track_price,
+ COALESCE(sp.currency, :currency) AS currency
+ FROM ac_release_tracks t
+ JOIN ac_store_track_products sp ON sp.release_track_id = t.id
+ WHERE t.release_id = :release_id
+ AND sp.is_enabled = 1
+ AND sp.track_price > 0
+ ORDER BY t.track_no ASC, t.id ASC
+ ");
+ $trackStmt->execute([
+ ':release_id' => $itemId,
+ ':currency' => $currency,
+ ]);
+ $trackRows = $trackStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ if ($trackRows) {
+ $releaseKey = 'release:' . $itemId;
+ $removedTracks = 0;
+ foreach ($trackRows as $row) {
+ $trackId = (int)($row['id'] ?? 0);
+ if ($trackId <= 0) {
+ continue;
+ }
+ $trackKey = 'track:' . $trackId;
+ if (isset($cart[$trackKey])) {
+ unset($cart[$trackKey]);
+ $removedTracks++;
+ }
+ }
+
+ $cart[$releaseKey] = [
+ 'key' => $releaseKey,
+ 'item_type' => 'release',
+ 'item_id' => $itemId,
+ 'title' => $itemTitle,
+ 'cover_url' => $coverUrl,
+ 'price' => $price,
+ 'currency' => $currency,
+ 'qty' => 1,
+ ];
+ $_SESSION['ac_cart'] = $cart;
+ $msg = '"' . $itemTitle . '" added as full release.';
+ if ($removedTracks > 0) {
+ $msg .= ' Removed ' . $removedTracks . ' individual track' . ($removedTracks === 1 ? '' : 's') . ' from cart.';
+ }
+ $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => $msg];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+ } catch (Throwable $e) {
+ }
+ }
+ }
+
+ $key = $itemType . ':' . $itemId;
+ if (isset($cart[$key]) && is_array($cart[$key])) {
+ if ($itemType === 'track') {
+ $_SESSION['ac_site_notice'] = [
+ 'type' => 'info',
+ 'text' => '"' . $itemTitle . '" is already in your cart.',
+ ];
+ } else {
+ $cart[$key]['qty'] = max(1, (int)($cart[$key]['qty'] ?? 1)) + $qty;
+ $cart[$key]['price'] = $price;
+ $cart[$key]['currency'] = $currency;
+ $cart[$key]['title'] = $itemTitle;
+ if ($coverUrl !== '') {
+ $cart[$key]['cover_url'] = $coverUrl;
+ }
+ $_SESSION['ac_site_notice'] = [
+ 'type' => 'ok',
+ 'text' => '"' . $itemTitle . '" quantity updated in your cart.',
+ ];
+ }
+ } else {
+ $cart[$key] = [
+ 'key' => $key,
+ 'item_type' => $itemType,
+ 'item_id' => $itemId,
+ 'title' => $itemTitle,
+ 'cover_url' => $coverUrl,
+ 'price' => $price,
+ 'currency' => $currency,
+ 'qty' => $qty,
+ ];
+ $_SESSION['ac_site_notice'] = [
+ 'type' => 'ok',
+ 'text' => '"' . $itemTitle . '" added to your cart.',
+ ];
+ }
+ $_SESSION['ac_cart'] = $cart;
+
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ public function cartApplyDiscount(): Response
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $code = strtoupper(trim((string)($_POST['discount_code'] ?? '')));
+ $returnUrl = trim((string)($_POST['return_url'] ?? '/cart'));
+ if ($code === '') {
+ $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Enter a discount code first.'];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount service unavailable right now.'];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ $this->ensureDiscountSchema();
+ $discount = $this->loadActiveDiscount($db, $code);
+ if (!$discount) {
+ $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'That discount code is invalid or expired.'];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ $cart = $_SESSION['ac_cart'] ?? [];
+ $hasDiscountableItems = false;
+ if (is_array($cart)) {
+ foreach ($cart as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $itemType = (string)($item['item_type'] ?? '');
+ $price = (float)($item['price'] ?? 0);
+ $qty = max(1, (int)($item['qty'] ?? 1));
+ if ($itemType !== 'bundle' && $price > 0 && $qty > 0) {
+ $hasDiscountableItems = true;
+ break;
+ }
+ }
+ }
+ if (!$hasDiscountableItems) {
+ $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount codes do not apply to bundles.'];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ $_SESSION['ac_discount_code'] = (string)$discount['code'];
+ $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => 'Discount code "' . (string)$discount['code'] . '" applied.'];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ public function cartClearDiscount(): Response
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $returnUrl = trim((string)($_POST['return_url'] ?? '/cart'));
+ unset($_SESSION['ac_discount_code']);
+ $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount code removed.'];
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ public function cartIndex(): Response
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $cart = $_SESSION['ac_cart'] ?? [];
+ if (!is_array($cart)) {
+ $cart = [];
+ }
+
+ $items = array_values(array_filter($cart, static function ($item): bool {
+ return is_array($item);
+ }));
+
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ $filtered = [];
+ $removed = 0;
+ foreach ($items as $item) {
+ $itemType = (string)($item['item_type'] ?? 'track');
+ $itemId = (int)($item['item_id'] ?? 0);
+ if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) {
+ $filtered[] = $item;
+ continue;
+ }
+ $removed++;
+ $key = (string)($item['key'] ?? '');
+ if ($key !== '' && isset($_SESSION['ac_cart'][$key])) {
+ unset($_SESSION['ac_cart'][$key]);
+ }
+ }
+ if ($removed > 0) {
+ $_SESSION['ac_site_notice'] = [
+ 'type' => 'info',
+ 'text' => 'Unreleased items were removed from your cart.',
+ ];
+ }
+ $items = $filtered;
+ }
+
+ if ($db instanceof PDO) {
+ foreach ($items as $idx => $item) {
+ $cover = trim((string)($item['cover_url'] ?? ''));
+ if ($cover !== '') {
+ continue;
+ }
+ $itemType = (string)($item['item_type'] ?? '');
+ $itemId = (int)($item['item_id'] ?? 0);
+ if ($itemId <= 0) {
+ continue;
+ }
+ try {
+ if ($itemType === 'track') {
+ $stmt = $db->prepare("
+ SELECT r.cover_url
+ FROM ac_release_tracks t
+ JOIN ac_releases r ON r.id = t.release_id
+ WHERE t.id = :id
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $itemId]);
+ } elseif ($itemType === 'release') {
+ $stmt = $db->prepare("SELECT cover_url FROM ac_releases WHERE id = :id LIMIT 1");
+ $stmt->execute([':id' => $itemId]);
+ } else {
+ continue;
+ }
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ $coverUrl = trim((string)($row['cover_url'] ?? ''));
+ if ($coverUrl !== '') {
+ $items[$idx]['cover_url'] = $coverUrl;
+ }
+ } catch (Throwable $e) {
+ }
+ }
+ }
+
+ $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? '')));
+ $totals = $this->buildCartTotals($items, $discountCode);
+ if ($totals['discount_code'] === '') {
+ unset($_SESSION['ac_discount_code']);
+ } else {
+ $_SESSION['ac_discount_code'] = $totals['discount_code'];
+ }
+
+ return new Response($this->view->render('site/cart.php', [
+ 'title' => 'Cart',
+ 'items' => $items,
+ 'totals' => $totals,
+ ]));
+ }
+
+ public function cartRemove(): Response
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $key = trim((string)($_POST['key'] ?? ''));
+ $returnUrl = trim((string)($_POST['return_url'] ?? '/cart'));
+
+ if ($key !== '' && isset($_SESSION['ac_cart']) && is_array($_SESSION['ac_cart'])) {
+ unset($_SESSION['ac_cart'][$key]);
+ }
+
+ return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
+ }
+
+ private function jsonResponse(array $payload, int $status = 200): Response
+ {
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
+ if (!is_string($json)) {
+ $json = '{"ok":false,"error":"JSON encoding failed"}';
+ $status = 500;
+ }
+
+ return new Response($json, $status, [
+ 'Content-Type' => 'application/json; charset=utf-8',
+ ]);
+ }
+
+ private function checkoutCartFingerprint(array $items, string $email, string $currency, float $total, string $discountCode): string
+ {
+ $normalized = [];
+ foreach ($items as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $normalized[] = [
+ 'item_type' => (string)($item['item_type'] ?? 'track'),
+ 'item_id' => (int)($item['item_id'] ?? 0),
+ 'qty' => max(1, (int)($item['qty'] ?? 1)),
+ 'price' => round((float)($item['price'] ?? 0), 2),
+ 'currency' => (string)($item['currency'] ?? $currency),
+ 'title' => (string)($item['title'] ?? ''),
+ ];
+ }
+
+ return sha1((string)json_encode([
+ 'email' => strtolower(trim($email)),
+ 'currency' => $currency,
+ 'total' => number_format($total, 2, '.', ''),
+ 'discount' => $discountCode,
+ 'items' => $normalized,
+ ], JSON_UNESCAPED_SLASHES));
+ }
+
+ private function nextOrderNo(): string
+ {
+ $orderPrefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD'));
+ return $orderPrefix . '-' . date('YmdHis') . '-' . random_int(100, 999);
+ }
+
+ private function buildCheckoutContext(string $email = '', bool $acceptedTerms = true): array
+ {
+ if (!$acceptedTerms) {
+ return ['ok' => false, 'error' => 'Please accept the terms to continue'];
+ }
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+
+ $cart = $_SESSION['ac_cart'] ?? [];
+ if (!is_array($cart) || !$cart) {
+ return ['ok' => false, 'error' => 'Your cart is empty'];
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return ['ok' => false, 'error' => 'Database unavailable'];
+ }
+
+ $email = trim($email);
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return ['ok' => false, 'error' => 'Please enter a valid email address'];
+ }
+
+ $items = array_values(array_filter($cart, static function ($item): bool {
+ return is_array($item);
+ }));
+ $validItems = [];
+ $removed = 0;
+
+ foreach ($items as $item) {
+ $itemType = (string)($item['item_type'] ?? 'track');
+ $itemId = (int)($item['item_id'] ?? 0);
+ $key = (string)($item['key'] ?? '');
+
+ if ($itemType === 'bundle' && $itemId > 0) {
+ $liveBundle = $this->loadBundleForCart($db, $itemId);
+ if ($liveBundle) {
+ $item['title'] = (string)($liveBundle['name'] ?? ($item['title'] ?? 'Bundle'));
+ $item['price'] = (float)($liveBundle['bundle_price'] ?? ($item['price'] ?? 0));
+ $item['currency'] = (string)($liveBundle['currency'] ?? ($item['currency'] ?? 'GBP'));
+ $item['cover_url'] = (string)($liveBundle['cover_url'] ?? ($item['cover_url'] ?? ''));
+ $item['release_count'] = (int)($liveBundle['release_count'] ?? 0);
+ $item['track_count'] = (int)($liveBundle['track_count'] ?? 0);
+ }
+ }
+
+ if ($itemId > 0
+ && $this->isItemReleased($db, $itemType, $itemId)
+ && $this->hasDownloadableFiles($db, $itemType, $itemId)
+ ) {
+ $validItems[] = $item;
+ if ($key !== '' && isset($_SESSION['ac_cart'][$key]) && is_array($_SESSION['ac_cart'][$key])) {
+ $_SESSION['ac_cart'][$key] = $item;
+ }
+ continue;
+ }
+
+ $removed++;
+ if ($key !== '' && isset($_SESSION['ac_cart'][$key])) {
+ unset($_SESSION['ac_cart'][$key]);
+ }
+ }
+
+ if (!$validItems) {
+ unset($_SESSION['ac_cart']);
+ return ['ok' => false, 'error' => 'Selected items are not yet released'];
+ }
+
+ if ($removed > 0) {
+ $_SESSION['ac_site_notice'] = [
+ 'type' => 'info',
+ 'text' => 'Some unavailable items were removed from your cart.',
+ ];
+ }
+
+ $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? '')));
+ $totals = $this->buildCartTotals($validItems, $discountCode);
+ if ((string)$totals['discount_code'] === '') {
+ unset($_SESSION['ac_discount_code']);
+ } else {
+ $_SESSION['ac_discount_code'] = (string)$totals['discount_code'];
+ }
+
+ return [
+ 'ok' => true,
+ 'db' => $db,
+ 'email' => $email,
+ 'items' => $validItems,
+ 'currency' => (string)$totals['currency'],
+ 'subtotal' => (float)$totals['subtotal'],
+ 'discount_amount' => (float)$totals['discount_amount'],
+ 'discount_code' => (string)$totals['discount_code'],
+ 'total' => (float)$totals['amount'],
+ 'fingerprint' => $this->checkoutCartFingerprint(
+ $validItems,
+ $email,
+ (string)$totals['currency'],
+ (float)$totals['amount'],
+ (string)$totals['discount_code']
+ ),
+ ];
+ }
+
+ private function reusablePendingOrder(PDO $db, string $fingerprint): ?array
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+
+ $pending = $_SESSION['ac_checkout_pending'] ?? null;
+ if (!is_array($pending)) {
+ return null;
+ }
+ if ((string)($pending['fingerprint'] ?? '') !== $fingerprint) {
+ $this->clearPendingOrder();
+ return null;
+ }
+
+ $orderId = (int)($pending['order_id'] ?? 0);
+ if ($orderId <= 0) {
+ $this->clearPendingOrder();
+ return null;
+ }
+
+ try {
+ $stmt = $db->prepare("SELECT * FROM ac_store_orders WHERE id = :id AND status = 'pending' LIMIT 1");
+ $stmt->execute([':id' => $orderId]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$row) {
+ $this->clearPendingOrder();
+ return null;
+ }
+
+ return $row;
+ } catch (Throwable $e) {
+ $this->clearPendingOrder();
+ return null;
+ }
+ }
+
+ private function rememberPendingOrder(int $orderId, string $orderNo, string $fingerprint, string $paypalOrderId = ''): void
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+
+ $_SESSION['ac_checkout_pending'] = [
+ 'order_id' => $orderId,
+ 'order_no' => $orderNo,
+ 'fingerprint' => $fingerprint,
+ 'paypal_order_id' => $paypalOrderId,
+ 'updated_at' => time(),
+ ];
+ }
+
+ private function clearPendingOrder(): void
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+
+ unset($_SESSION['ac_checkout_pending']);
+ }
+
+ private function pendingOrderId(array $order): int
{
- $itemType = trim((string)($_POST['item_type'] ?? 'track'));
- if (!in_array($itemType, ['track', 'release'], true)) {
- $itemType = 'track';
- }
-
- $itemId = (int)($_POST['item_id'] ?? 0);
- $title = trim((string)($_POST['title'] ?? 'Item'));
- $coverUrl = trim((string)($_POST['cover_url'] ?? ''));
- $currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP')));
- $price = (float)($_POST['price'] ?? 0);
- $qty = max(1, (int)($_POST['qty'] ?? 1));
- $returnUrl = trim((string)($_POST['return_url'] ?? '/releases'));
- $itemTitle = $title !== '' ? $title : 'Item';
-
- if ($itemId <= 0 || $price <= 0) {
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
- if (!preg_match('/^[A-Z]{3}$/', $currency)) {
- $currency = 'GBP';
- }
-
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $cart = $_SESSION['ac_cart'] ?? [];
- if (!is_array($cart)) {
- $cart = [];
- }
-
- $db = Database::get();
- if ($db instanceof PDO) {
- if (!$this->isItemReleased($db, $itemType, $itemId)) {
- $_SESSION['ac_site_notice'] = [
- 'type' => 'info',
- 'text' => 'This release is scheduled and is not available yet.',
- ];
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
- }
-
- if ($itemType === 'release') {
- if ($db instanceof PDO) {
- try {
- $trackStmt = $db->prepare("
- SELECT t.id,
- t.title,
- t.mix_name,
- COALESCE(sp.track_price, 0.00) AS track_price,
- COALESCE(sp.currency, :currency) AS currency
- FROM ac_release_tracks t
- JOIN ac_store_track_products sp ON sp.release_track_id = t.id
- WHERE t.release_id = :release_id
- AND sp.is_enabled = 1
- AND sp.track_price > 0
- ORDER BY t.track_no ASC, t.id ASC
- ");
- $trackStmt->execute([
- ':release_id' => $itemId,
- ':currency' => $currency,
- ]);
- $trackRows = $trackStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- if ($trackRows) {
- $releaseKey = 'release:' . $itemId;
- $removedTracks = 0;
- foreach ($trackRows as $row) {
- $trackId = (int)($row['id'] ?? 0);
- if ($trackId <= 0) {
- continue;
- }
- $trackKey = 'track:' . $trackId;
- if (isset($cart[$trackKey])) {
- unset($cart[$trackKey]);
- $removedTracks++;
- }
- }
-
- $cart[$releaseKey] = [
- 'key' => $releaseKey,
- 'item_type' => 'release',
- 'item_id' => $itemId,
- 'title' => $itemTitle,
- 'cover_url' => $coverUrl,
- 'price' => $price,
- 'currency' => $currency,
- 'qty' => 1,
- ];
- $_SESSION['ac_cart'] = $cart;
- $msg = '"' . $itemTitle . '" added as full release.';
- if ($removedTracks > 0) {
- $msg .= ' Removed ' . $removedTracks . ' individual track' . ($removedTracks === 1 ? '' : 's') . ' from cart.';
- }
- $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => $msg];
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
- } catch (Throwable $e) {
- }
- }
- }
-
- $key = $itemType . ':' . $itemId;
- if (isset($cart[$key]) && is_array($cart[$key])) {
- if ($itemType === 'track') {
- $_SESSION['ac_site_notice'] = [
- 'type' => 'info',
- 'text' => '"' . $itemTitle . '" is already in your cart.',
- ];
- } else {
- $cart[$key]['qty'] = max(1, (int)($cart[$key]['qty'] ?? 1)) + $qty;
- $cart[$key]['price'] = $price;
- $cart[$key]['currency'] = $currency;
- $cart[$key]['title'] = $itemTitle;
- if ($coverUrl !== '') {
- $cart[$key]['cover_url'] = $coverUrl;
- }
- $_SESSION['ac_site_notice'] = [
- 'type' => 'ok',
- 'text' => '"' . $itemTitle . '" quantity updated in your cart.',
- ];
- }
- } else {
- $cart[$key] = [
- 'key' => $key,
- 'item_type' => $itemType,
- 'item_id' => $itemId,
- 'title' => $itemTitle,
- 'cover_url' => $coverUrl,
- 'price' => $price,
- 'currency' => $currency,
- 'qty' => $qty,
- ];
- $_SESSION['ac_site_notice'] = [
- 'type' => 'ok',
- 'text' => '"' . $itemTitle . '" added to your cart.',
- ];
- }
- $_SESSION['ac_cart'] = $cart;
-
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
-
- public function cartApplyDiscount(): Response
+ return (int)($order['order_id'] ?? $order['id'] ?? 0);
+ }
+
+ private function createPendingOrder(PDO $db, array $context, string $paymentProvider = 'paypal'): array
+ {
+ $existing = $this->reusablePendingOrder($db, (string)$context['fingerprint']);
+ if ($existing) {
+ return [
+ 'ok' => true,
+ 'order_id' => (int)($existing['id'] ?? 0),
+ 'order_no' => (string)($existing['order_no'] ?? ''),
+ 'email' => (string)($existing['email'] ?? $context['email']),
+ ];
+ }
+
+ $this->ensureAnalyticsSchema();
+ $customerIp = $this->clientIp();
+ $customerUserAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
+ $customerId = $this->upsertCustomerFromOrder($db, (string)$context['email'], $customerIp, $customerUserAgent, (float)$context['total']);
+ $orderNo = $this->nextOrderNo();
+
+ try {
+ $db->beginTransaction();
+ $insOrder = $db->prepare("
+ INSERT INTO ac_store_orders
+ (order_no, customer_id, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at)
+ VALUES (:order_no, :customer_id, :email, 'pending', :currency, :subtotal, :total, :discount_code, :discount_amount, :provider, NULL, :customer_ip, :customer_user_agent, NOW(), NOW())
+ ");
+ $insOrder->execute([
+ ':order_no' => $orderNo,
+ ':customer_id' => $customerId > 0 ? $customerId : null,
+ ':email' => (string)$context['email'],
+ ':currency' => (string)$context['currency'],
+ ':subtotal' => (float)$context['subtotal'],
+ ':total' => (float)$context['total'],
+ ':discount_code' => (string)$context['discount_code'] !== '' ? (string)$context['discount_code'] : null,
+ ':discount_amount' => (float)$context['discount_amount'] > 0 ? (float)$context['discount_amount'] : null,
+ ':provider' => $paymentProvider,
+ ':customer_ip' => $customerIp,
+ ':customer_user_agent' => $customerUserAgent !== '' ? $customerUserAgent : null,
+ ]);
+ $orderId = (int)$db->lastInsertId();
+
+ $insItem = $db->prepare("
+ INSERT INTO ac_store_order_items
+ (order_id, item_type, item_id, artist_id, title_snapshot, unit_price_snapshot, currency_snapshot, qty, line_total, created_at)
+ VALUES (:order_id, :item_type, :item_id, :artist_id, :title, :price, :currency, :qty, :line_total, NOW())
+ ");
+ foreach ((array)$context['items'] as $item) {
+ $qty = max(1, (int)($item['qty'] ?? 1));
+ $price = (float)($item['price'] ?? 0);
+ $lineTotal = ((string)($item['item_type'] ?? '') === 'bundle') ? $price : ($price * $qty);
+ $artistId = $this->resolveOrderItemArtistId(
+ $db,
+ (string)($item['item_type'] ?? 'track'),
+ (int)($item['item_id'] ?? 0)
+ );
+ $insItem->execute([
+ ':order_id' => $orderId,
+ ':item_type' => (string)($item['item_type'] ?? 'track'),
+ ':item_id' => (int)($item['item_id'] ?? 0),
+ ':artist_id' => $artistId > 0 ? $artistId : null,
+ ':title' => (string)($item['title'] ?? 'Item'),
+ ':price' => $price,
+ ':currency' => (string)($item['currency'] ?? $context['currency']),
+ ':qty' => $qty,
+ ':line_total' => $lineTotal,
+ ]);
+ ApiLayer::syncOrderItemAllocations($db, $orderId, (int)$db->lastInsertId());
+ }
+ $db->commit();
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) {
+ $db->rollBack();
+ }
+
+ return ['ok' => false, 'error' => 'Unable to create order'];
+ }
+
+ $this->rememberPendingOrder($orderId, $orderNo, (string)$context['fingerprint']);
+
+ return [
+ 'ok' => true,
+ 'order_id' => $orderId,
+ 'order_no' => $orderNo,
+ 'email' => (string)$context['email'],
+ ];
+ }
+
+ private function finalizeOrderAsPaid(PDO $db, int $orderId, string $paymentProvider, string $paymentRef = '', array $paymentBreakdown = []): array
+ {
+ $stmt = $db->prepare("SELECT * FROM ac_store_orders WHERE id = :id LIMIT 1");
+ $stmt->execute([':id' => $orderId]);
+ $order = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$order) {
+ return ['ok' => false, 'error' => 'Order not found'];
+ }
+
+ $orderNo = (string)($order['order_no'] ?? '');
+ if ($orderNo === '') {
+ return ['ok' => false, 'error' => 'Order number missing'];
+ }
+
+ if ((string)($order['status'] ?? '') !== 'paid') {
+ try {
+ $upd = $db->prepare("
+ UPDATE ac_store_orders
+ SET status = 'paid',
+ payment_provider = :provider,
+ payment_ref = :payment_ref,
+ payment_gross = :payment_gross,
+ payment_fee = :payment_fee,
+ payment_net = :payment_net,
+ payment_currency = :payment_currency,
+ updated_at = NOW()
+ WHERE id = :id
+ ");
+ $upd->execute([
+ ':provider' => $paymentProvider,
+ ':payment_ref' => $paymentRef !== '' ? $paymentRef : ((string)($order['payment_ref'] ?? '') !== '' ? (string)$order['payment_ref'] : null),
+ ':payment_gross' => array_key_exists('gross', $paymentBreakdown) ? (float)$paymentBreakdown['gross'] : (((string)($order['payment_gross'] ?? '') !== '') ? (float)$order['payment_gross'] : null),
+ ':payment_fee' => array_key_exists('fee', $paymentBreakdown) ? (float)$paymentBreakdown['fee'] : (((string)($order['payment_fee'] ?? '') !== '') ? (float)$order['payment_fee'] : null),
+ ':payment_net' => array_key_exists('net', $paymentBreakdown) ? (float)$paymentBreakdown['net'] : (((string)($order['payment_net'] ?? '') !== '') ? (float)$order['payment_net'] : null),
+ ':payment_currency' => !empty($paymentBreakdown['currency']) ? (string)$paymentBreakdown['currency'] : (((string)($order['payment_currency'] ?? '') !== '') ? (string)$order['payment_currency'] : null),
+ ':id' => $orderId,
+ ]);
+ } catch (Throwable $e) {
+ return ['ok' => false, 'error' => 'Unable to finalize order'];
+ }
+
+ $itemsForEmail = $this->orderItemsForEmail($db, $orderId);
+ $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, (string)($order['email'] ?? ''), 'paid');
+ $discountCode = trim((string)($order['discount_code'] ?? ''));
+ if ($discountCode !== '') {
+ $this->bumpDiscountUsage($db, $discountCode);
+ }
+ $this->rebuildSalesChartCache();
+ ApiLayer::dispatchSaleWebhooksForOrder($orderId);
+ $this->sendOrderEmail(
+ (string)($order['email'] ?? ''),
+ $orderNo,
+ (string)($order['currency'] ?? 'GBP'),
+ (float)($order['total'] ?? 0),
+ $itemsForEmail,
+ 'paid',
+ $downloadLinksHtml
+ );
+ }
+
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $_SESSION['ac_last_order_no'] = $orderNo;
+ $_SESSION['ac_cart'] = [];
+ unset($_SESSION['ac_discount_code']);
+ $this->clearPendingOrder();
+
+ return [
+ 'ok' => true,
+ 'order_no' => $orderNo,
+ 'redirect' => '/checkout?success=1&order_no=' . rawurlencode($orderNo),
+ ];
+ }
+
+ public function checkoutIndex(): Response
+ {
+ $success = (string)($_GET['success'] ?? '');
+ $orderNo = (string)($_GET['order_no'] ?? '');
+ $error = (string)($_GET['error'] ?? '');
+ $downloadLinks = [];
+ $downloadNotice = '';
+
+ $context = $this->buildCheckoutContext('preview@example.com', true);
+ $items = [];
+ $subtotal = 0.0;
+ $discountAmount = 0.0;
+ $discountCode = '';
+ $currency = strtoupper(trim((string)Settings::get('store_currency', 'GBP')));
+ if (!preg_match('/^[A-Z]{3}$/', $currency)) {
+ $currency = 'GBP';
+ }
+ $total = 0.0;
+ if ($context['ok'] ?? false) {
+ $items = (array)($context['items'] ?? []);
+ $subtotal = (float)($context['subtotal'] ?? 0);
+ $discountAmount = (float)($context['discount_amount'] ?? 0);
+ $discountCode = (string)($context['discount_code'] ?? '');
+ $currency = (string)($context['currency'] ?? $currency);
+ $total = (float)($context['total'] ?? 0);
+ }
+
+ $db = Database::get();
+ if ($success !== '' && $orderNo !== '' && $db instanceof PDO) {
+ try {
+ $orderStmt = $db->prepare("SELECT id, status FROM ac_store_orders WHERE order_no = :order_no LIMIT 1");
+ $orderStmt->execute([':order_no' => $orderNo]);
+ $orderRow = $orderStmt->fetch(PDO::FETCH_ASSOC);
+ if ($orderRow) {
+ $orderId = (int)($orderRow['id'] ?? 0);
+ $orderStatus = (string)($orderRow['status'] ?? '');
+ if ($orderId > 0) {
+ $tokenStmt = $db->prepare("
+ SELECT
+ t.token,
+ t.file_id,
+ f.file_name,
+ COALESCE(NULLIF(oi.title_snapshot, ''), f.file_name) AS fallback_label
+ FROM ac_store_download_tokens t
+ JOIN ac_store_files f ON f.id = t.file_id
+ LEFT JOIN ac_store_order_items oi ON oi.id = t.order_item_id
+ WHERE t.order_id = :order_id
+ ORDER BY t.id DESC
+ ");
+ $tokenStmt->execute([':order_id' => $orderId]);
+ foreach ($tokenStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
+ $token = trim((string)($row['token'] ?? ''));
+ if ($token === '') {
+ continue;
+ }
+ $fileId = (int)($row['file_id'] ?? 0);
+ $downloadLinks[] = [
+ 'label' => $this->buildDownloadLabel($db, $fileId, (string)($row['fallback_label'] ?? $row['file_name'] ?? 'Download')),
+ 'url' => '/store/download?token=' . rawurlencode($token),
+ ];
+ }
+ if (!$downloadLinks) {
+ $downloadNotice = $orderStatus === 'paid'
+ ? 'No downloadable files are attached to this order yet.'
+ : 'Download links will appear here once payment is confirmed.';
+ }
+ }
+ }
+ } catch (Throwable $e) {
+ $downloadNotice = 'Unable to load download links right now.';
+ }
+ }
+
+ $settings = $this->settingsPayload();
+ $paypalEnabled = $this->isEnabledSetting($settings['store_paypal_enabled'] ?? '0');
+ $paypalCardsEnabled = $paypalEnabled && $this->isEnabledSetting($settings['store_paypal_cards_enabled'] ?? '0');
+ $paypalCapabilityStatus = (string)($settings['store_paypal_cards_capability_status'] ?? 'unknown');
+ $paypalCardsAvailable = false;
+ $paypalClientToken = '';
+ if ($paypalCardsEnabled && $paypalCapabilityStatus === 'available') {
+ $clientId = trim((string)($settings['store_paypal_client_id'] ?? ''));
+ $secret = trim((string)($settings['store_paypal_secret'] ?? ''));
+ if ($clientId !== '' && $secret !== '') {
+ $token = $this->paypalGenerateClientToken(
+ $clientId,
+ $secret,
+ $this->isEnabledSetting($settings['store_test_mode'] ?? '1')
+ );
+ if ($token['ok'] ?? false) {
+ $paypalClientToken = (string)($token['client_token'] ?? '');
+ $paypalCardsAvailable = $paypalClientToken !== '';
+ }
+ }
+ }
+
+ return new Response($this->view->render('site/checkout.php', [
+ 'title' => 'Checkout',
+ 'items' => $items,
+ 'total' => $total,
+ 'subtotal' => $subtotal,
+ 'discount_amount' => $discountAmount,
+ 'discount_code' => $discountCode,
+ 'currency' => $currency,
+ 'success' => $success,
+ 'order_no' => $orderNo,
+ 'error' => $error,
+ 'download_links' => $downloadLinks,
+ 'download_notice' => $downloadNotice,
+ 'download_limit' => (int)Settings::get('store_download_limit', '5'),
+ 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'),
+ 'paypal_enabled' => $paypalEnabled,
+ 'paypal_cards_enabled' => $paypalCardsEnabled,
+ 'paypal_cards_available' => $paypalCardsAvailable,
+ 'paypal_client_id' => (string)($settings['store_paypal_client_id'] ?? ''),
+ 'paypal_client_token' => $paypalClientToken,
+ 'paypal_sdk_mode' => (string)($settings['store_paypal_sdk_mode'] ?? 'embedded_fields'),
+ 'paypal_merchant_country' => (string)($settings['store_paypal_merchant_country'] ?? ''),
+ 'paypal_card_branding_text' => (string)($settings['store_paypal_card_branding_text'] ?? 'Pay with card'),
+ 'paypal_capability_status' => $paypalCapabilityStatus,
+ 'paypal_capability_message' => (string)($settings['store_paypal_cards_capability_message'] ?? ''),
+ 'paypal_test_mode' => $this->isEnabledSetting($settings['store_test_mode'] ?? '1'),
+ ]));
+ }
+
+ public function checkoutCardStart(): Response
{
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $code = strtoupper(trim((string)($_POST['discount_code'] ?? '')));
- $returnUrl = trim((string)($_POST['return_url'] ?? '/cart'));
- if ($code === '') {
- $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Enter a discount code first.'];
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount service unavailable right now.'];
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
-
- $this->ensureDiscountSchema();
- $discount = $this->loadActiveDiscount($db, $code);
- if (!$discount) {
- $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'That discount code is invalid or expired.'];
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
-
- $_SESSION['ac_discount_code'] = (string)$discount['code'];
- $_SESSION['ac_site_notice'] = ['type' => 'ok', 'text' => 'Discount code "' . (string)$discount['code'] . '" applied.'];
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
-
- public function cartClearDiscount(): Response
- {
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $returnUrl = trim((string)($_POST['return_url'] ?? '/cart'));
- unset($_SESSION['ac_discount_code']);
- $_SESSION['ac_site_notice'] = ['type' => 'info', 'text' => 'Discount code removed.'];
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
-
- public function cartIndex(): Response
- {
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $cart = $_SESSION['ac_cart'] ?? [];
- if (!is_array($cart)) {
- $cart = [];
- }
-
- $items = array_values(array_filter($cart, static function ($item): bool {
- return is_array($item);
- }));
-
- $db = Database::get();
- if ($db instanceof PDO) {
- $filtered = [];
- $removed = 0;
- foreach ($items as $item) {
- $itemType = (string)($item['item_type'] ?? 'track');
- $itemId = (int)($item['item_id'] ?? 0);
- if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) {
- $filtered[] = $item;
- continue;
- }
- $removed++;
- $key = (string)($item['key'] ?? '');
- if ($key !== '' && isset($_SESSION['ac_cart'][$key])) {
- unset($_SESSION['ac_cart'][$key]);
- }
- }
- if ($removed > 0) {
- $_SESSION['ac_site_notice'] = [
- 'type' => 'info',
- 'text' => 'Unreleased items were removed from your cart.',
- ];
- }
- $items = $filtered;
- }
-
- if ($db instanceof PDO) {
- foreach ($items as $idx => $item) {
- $cover = trim((string)($item['cover_url'] ?? ''));
- if ($cover !== '') {
- continue;
- }
- $itemType = (string)($item['item_type'] ?? '');
- $itemId = (int)($item['item_id'] ?? 0);
- if ($itemId <= 0) {
- continue;
- }
- try {
- if ($itemType === 'track') {
- $stmt = $db->prepare("
- SELECT r.cover_url
- FROM ac_release_tracks t
- JOIN ac_releases r ON r.id = t.release_id
- WHERE t.id = :id
- LIMIT 1
- ");
- $stmt->execute([':id' => $itemId]);
- } elseif ($itemType === 'release') {
- $stmt = $db->prepare("SELECT cover_url FROM ac_releases WHERE id = :id LIMIT 1");
- $stmt->execute([':id' => $itemId]);
- } else {
- continue;
- }
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
- $coverUrl = trim((string)($row['cover_url'] ?? ''));
- if ($coverUrl !== '') {
- $items[$idx]['cover_url'] = $coverUrl;
- }
- } catch (Throwable $e) {
- }
- }
- }
-
- $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? '')));
- $totals = $this->buildCartTotals($items, $discountCode);
- if ($totals['discount_code'] === '') {
- unset($_SESSION['ac_discount_code']);
- } else {
- $_SESSION['ac_discount_code'] = $totals['discount_code'];
- }
-
- return new Response($this->view->render('site/cart.php', [
- 'title' => 'Cart',
- 'items' => $items,
- 'totals' => $totals,
- ]));
- }
-
- public function cartRemove(): Response
- {
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $key = trim((string)($_POST['key'] ?? ''));
- $returnUrl = trim((string)($_POST['return_url'] ?? '/cart'));
-
- if ($key !== '' && isset($_SESSION['ac_cart']) && is_array($_SESSION['ac_cart'])) {
- unset($_SESSION['ac_cart'][$key]);
- }
-
- return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
- }
-
- public function checkoutIndex(): Response
- {
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $cart = $_SESSION['ac_cart'] ?? [];
- if (!is_array($cart)) {
- $cart = [];
- }
- $items = array_values(array_filter($cart, static function ($item): bool {
- return is_array($item);
- }));
- $db = Database::get();
- if ($db instanceof PDO) {
- $filtered = [];
- foreach ($items as $item) {
- $itemType = (string)($item['item_type'] ?? 'track');
- $itemId = (int)($item['item_id'] ?? 0);
- if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) {
- $filtered[] = $item;
- continue;
- }
- $key = (string)($item['key'] ?? '');
- if ($key !== '' && isset($_SESSION['ac_cart'][$key])) {
- unset($_SESSION['ac_cart'][$key]);
- }
- }
- $items = $filtered;
- }
- $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? '')));
- $totals = $this->buildCartTotals($items, $discountCode);
- if ($totals['discount_code'] === '') {
- unset($_SESSION['ac_discount_code']);
- } else {
- $_SESSION['ac_discount_code'] = $totals['discount_code'];
- }
- $total = (float)$totals['amount'];
- $currency = (string)$totals['currency'];
-
- $downloadLinks = [];
- $downloadNotice = '';
- $success = (string)($_GET['success'] ?? '');
- $orderNo = (string)($_GET['order_no'] ?? '');
- if ($success !== '' && $orderNo !== '') {
- if ($db instanceof PDO) {
- try {
- $orderStmt = $db->prepare("SELECT id, status, email FROM ac_store_orders WHERE order_no = :order_no LIMIT 1");
- $orderStmt->execute([':order_no' => $orderNo]);
- $orderRow = $orderStmt->fetch(PDO::FETCH_ASSOC);
- if ($orderRow) {
- $orderId = (int)($orderRow['id'] ?? 0);
- $orderStatus = (string)($orderRow['status'] ?? '');
- if ($orderId > 0) {
- $tokenStmt = $db->prepare("
- SELECT t.token, f.file_name
- FROM ac_store_download_tokens t
- JOIN ac_store_files f ON f.id = t.file_id
- WHERE t.order_id = :order_id
- ORDER BY t.id DESC
- ");
- $tokenStmt->execute([':order_id' => $orderId]);
- $rows = $tokenStmt->fetchAll(PDO::FETCH_ASSOC);
- foreach ($rows as $row) {
- $token = trim((string)($row['token'] ?? ''));
- if ($token === '') {
- continue;
- }
- $downloadLinks[] = [
- 'label' => (string)($row['file_name'] ?? 'Download'),
- 'url' => '/store/download?token=' . rawurlencode($token),
- ];
- }
- if (!$downloadLinks) {
- $downloadNotice = $orderStatus === 'paid'
- ? 'No downloadable files are attached to this order yet.'
- : 'Download links will appear here once payment is confirmed.';
- }
- }
- }
- } catch (Throwable $e) {
- $downloadNotice = 'Unable to load download links right now.';
- }
- }
- }
-
- return new Response($this->view->render('site/checkout.php', [
- 'title' => 'Checkout',
- 'items' => $items,
- 'total' => $total,
- 'subtotal' => (float)$totals['subtotal'],
- 'discount_amount' => (float)$totals['discount_amount'],
- 'discount_code' => (string)$totals['discount_code'],
- 'currency' => $currency,
- 'success' => $success,
- 'order_no' => $orderNo,
- 'error' => (string)($_GET['error'] ?? ''),
- 'download_links' => $downloadLinks,
- 'download_notice' => $downloadNotice,
- 'download_limit' => (int)Settings::get('store_download_limit', '5'),
- 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'),
- ]));
- }
-
- public function checkoutSandbox(): Response
- {
- return $this->checkoutPlace();
- }
-
- public function checkoutPlace(): Response
- {
- if (!isset($_POST['accept_terms'])) {
- return new Response('', 302, ['Location' => '/checkout?error=Please+accept+the+terms+to+continue']);
- }
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $cart = $_SESSION['ac_cart'] ?? [];
- if (!is_array($cart) || !$cart) {
- return new Response('', 302, ['Location' => '/checkout']);
- }
-
- $items = array_values(array_filter($cart, static function ($item): bool {
- return is_array($item);
- }));
- if (!$items) {
- return new Response('', 302, ['Location' => '/checkout']);
- }
-
- $discountCode = strtoupper(trim((string)($_SESSION['ac_discount_code'] ?? '')));
- $totals = $this->buildCartTotals($items, $discountCode);
- $currency = (string)$totals['currency'];
- $subtotal = (float)$totals['subtotal'];
- $discountAmount = (float)$totals['discount_amount'];
- $total = (float)$totals['amount'];
- $appliedDiscountCode = (string)$totals['discount_code'];
- $testMode = $this->isEnabledSetting(Settings::get('store_test_mode', '1'));
- $paypalEnabled = $this->isEnabledSetting(Settings::get('store_paypal_enabled', '0'));
- $orderPrefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD'));
- $orderNo = $orderPrefix . '-' . date('YmdHis') . '-' . random_int(100, 999);
$email = trim((string)($_POST['email'] ?? ''));
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
- return new Response('', 302, ['Location' => '/checkout?error=Please+enter+a+valid+email+address']);
+ $acceptedTerms = isset($_POST['accept_terms']);
+ $context = $this->buildCheckoutContext($email, $acceptedTerms);
+ if (!(bool)($context['ok'] ?? false)) {
+ return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)((string)($context['error'] ?? '') !== '' ? $context['error'] : 'CARD_START_CONTEXT_FAIL'))]);
}
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('', 302, ['Location' => '/checkout']);
- }
- $validItems = [];
- $removed = 0;
- foreach ($items as $item) {
- $itemType = (string)($item['item_type'] ?? 'track');
- $itemId = (int)($item['item_id'] ?? 0);
- if ($itemId > 0 && $this->isItemReleased($db, $itemType, $itemId)) {
- $validItems[] = $item;
- continue;
- }
- $removed++;
- $key = (string)($item['key'] ?? '');
- if ($key !== '' && isset($_SESSION['ac_cart'][$key])) {
- unset($_SESSION['ac_cart'][$key]);
- }
- }
- if (!$validItems) {
- return new Response('', 302, ['Location' => '/checkout?error=Selected+items+are+not+yet+released']);
- }
- if ($removed > 0) {
- $_SESSION['ac_site_notice'] = [
- 'type' => 'info',
- 'text' => 'Some unreleased items were removed from your cart.',
- ];
- }
- $items = $validItems;
-
- $this->ensureAnalyticsSchema();
- $customerIp = $this->clientIp();
- $customerUserAgent = substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255);
-
- try {
- $customerId = $this->upsertCustomerFromOrder($db, $email, $customerIp, $customerUserAgent, $total);
- $db->beginTransaction();
- $insOrder = $db->prepare("
- INSERT INTO ac_store_orders
- (order_no, customer_id, email, status, currency, subtotal, total, discount_code, discount_amount, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at)
- VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :discount_code, :discount_amount, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW())
- ");
- $insOrder->execute([
- ':order_no' => $orderNo,
- ':customer_id' => $customerId > 0 ? $customerId : null,
- ':email' => $email,
- ':status' => $paypalEnabled ? 'pending' : ($testMode ? 'paid' : 'pending'),
- ':currency' => $currency,
- ':subtotal' => $subtotal,
- ':total' => $total,
- ':discount_code' => $appliedDiscountCode !== '' ? $appliedDiscountCode : null,
- ':discount_amount' => $discountAmount > 0 ? $discountAmount : null,
- ':provider' => $paypalEnabled ? 'paypal' : ($testMode ? 'test' : 'checkout'),
- ':payment_ref' => $paypalEnabled ? null : ($testMode ? 'test' : null),
- ':customer_ip' => $customerIp,
- ':customer_user_agent' => $customerUserAgent !== '' ? $customerUserAgent : null,
- ]);
- $orderId = (int)$db->lastInsertId();
-
- $insItem = $db->prepare("
- INSERT INTO ac_store_order_items
- (order_id, item_type, item_id, title_snapshot, unit_price_snapshot, currency_snapshot, qty, line_total, created_at)
- VALUES (:order_id, :item_type, :item_id, :title, :price, :currency, :qty, :line_total, NOW())
- ");
- foreach ($items as $item) {
- $qty = max(1, (int)($item['qty'] ?? 1));
- $price = (float)($item['price'] ?? 0);
- $insItem->execute([
- ':order_id' => $orderId,
- ':item_type' => (string)($item['item_type'] ?? 'track'),
- ':item_id' => (int)($item['item_id'] ?? 0),
- ':title' => (string)($item['title'] ?? 'Item'),
- ':price' => $price,
- ':currency' => (string)($item['currency'] ?? $currency),
- ':qty' => $qty,
- ':line_total' => ($price * $qty),
- ]);
- }
- $db->commit();
- if ($total <= 0.0) {
- try {
- $upd = $db->prepare("
- UPDATE ac_store_orders
- SET status = 'paid', payment_provider = 'discount', payment_ref = :payment_ref, updated_at = NOW()
- WHERE id = :id
- ");
- $upd->execute([
- ':payment_ref' => 'discount-zero-total',
- ':id' => $orderId,
- ]);
- } catch (Throwable $e) {
- }
- $_SESSION['ac_last_order_no'] = $orderNo;
- $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, 'paid');
- $this->sendOrderEmail($email, $orderNo, $currency, 0.0, $items, 'paid', $downloadLinksHtml);
- if ($appliedDiscountCode !== '') {
- $this->bumpDiscountUsage($db, $appliedDiscountCode);
- }
- $this->rebuildSalesChartCache();
- $_SESSION['ac_cart'] = [];
- unset($_SESSION['ac_discount_code']);
- return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]);
- }
- if ($paypalEnabled) {
- $clientId = trim((string)Settings::get('store_paypal_client_id', ''));
- $secret = trim((string)Settings::get('store_paypal_secret', ''));
- if ($clientId === '' || $secret === '') {
- return new Response('', 302, ['Location' => '/checkout?error=PayPal+is+enabled+but+credentials+are+missing']);
- }
-
- $returnUrl = $this->baseUrl() . '/checkout/paypal/return';
- $cancelUrl = $this->baseUrl() . '/checkout/paypal/cancel';
- $create = $this->paypalCreateOrder(
- $clientId,
- $secret,
- $testMode,
- $currency,
- $total,
- $orderNo,
- $returnUrl,
- $cancelUrl
- );
- if (!($create['ok'] ?? false)) {
- return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($create['error'] ?? 'Unable to start PayPal checkout'))]);
- }
-
- $paypalOrderId = (string)($create['order_id'] ?? '');
- $approvalUrl = (string)($create['approval_url'] ?? '');
- if ($paypalOrderId === '' || $approvalUrl === '') {
- return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+did+not+return+approval+url']);
- }
-
- try {
- $upd = $db->prepare("
- UPDATE ac_store_orders
- SET payment_provider = 'paypal', payment_ref = :payment_ref, updated_at = NOW()
- WHERE id = :id
- ");
- $upd->execute([
- ':payment_ref' => $paypalOrderId,
- ':id' => $orderId,
- ]);
- } catch (Throwable $e) {
- }
-
- return new Response('', 302, ['Location' => $approvalUrl]);
- }
-
- $_SESSION['ac_last_order_no'] = $orderNo;
- $status = $testMode ? 'paid' : 'pending';
- $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, $status);
- $this->sendOrderEmail($email, $orderNo, $currency, $total, $items, $status, $downloadLinksHtml);
- if ($testMode) {
- if ($appliedDiscountCode !== '') {
- $this->bumpDiscountUsage($db, $appliedDiscountCode);
- }
- $this->rebuildSalesChartCache();
- $_SESSION['ac_cart'] = [];
- unset($_SESSION['ac_discount_code']);
- return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]);
- }
- return new Response('', 302, ['Location' => '/checkout?error=No+live+payment+gateway+is+enabled']);
- } catch (Throwable $e) {
- if ($db->inTransaction()) {
- $db->rollBack();
- }
- return new Response('', 302, ['Location' => '/checkout']);
- }
- }
-
- public function checkoutPaypalReturn(): Response
- {
- $paypalOrderId = trim((string)($_GET['token'] ?? ''));
- if ($paypalOrderId === '') {
- return new Response('', 302, ['Location' => '/checkout?error=Missing+PayPal+order+token']);
+ if ((float)$context['total'] <= 0.0) {
+ return $this->checkoutPlace();
}
$db = Database::get();
@@ -1915,948 +2406,1503 @@ class StoreController
return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']);
}
- $orderStmt = $db->prepare("
- SELECT id, order_no, email, status, currency, total, discount_code
- FROM ac_store_orders
- WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref
- LIMIT 1
- ");
- $orderStmt->execute([':payment_ref' => $paypalOrderId]);
- $order = $orderStmt->fetch(PDO::FETCH_ASSOC);
- if (!$order) {
- return new Response('', 302, ['Location' => '/checkout?error=Order+not+found+for+PayPal+payment']);
+ $order = $this->createPendingOrder($db, $context, 'paypal');
+ if (!($order['ok'] ?? false)) {
+ return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($order['error'] ?? 'CARD_START_ORDER_FAIL'))]);
}
- $orderId = (int)($order['id'] ?? 0);
- $orderNo = (string)($order['order_no'] ?? '');
- $email = (string)($order['email'] ?? '');
- $status = (string)($order['status'] ?? 'pending');
- if ($orderId <= 0 || $orderNo === '') {
- return new Response('', 302, ['Location' => '/checkout?error=Invalid+order+record']);
- }
-
- if ($status === 'paid') {
- if (session_status() !== PHP_SESSION_ACTIVE) {
- session_start();
- }
- $_SESSION['ac_last_order_no'] = $orderNo;
- $_SESSION['ac_cart'] = [];
- unset($_SESSION['ac_discount_code']);
- return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]);
- }
-
- $clientId = trim((string)Settings::get('store_paypal_client_id', ''));
- $secret = trim((string)Settings::get('store_paypal_secret', ''));
- if ($clientId === '' || $secret === '') {
- return new Response('', 302, ['Location' => '/checkout?error=PayPal+credentials+missing']);
- }
-
- $capture = $this->paypalCaptureOrder(
- $clientId,
- $secret,
- $this->isEnabledSetting(Settings::get('store_test_mode', '1')),
- $paypalOrderId
- );
- if (!($capture['ok'] ?? false)) {
- return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($capture['error'] ?? 'PayPal capture failed'))]);
- }
-
- try {
- $captureRef = trim((string)($capture['capture_id'] ?? ''));
- $upd = $db->prepare("
- UPDATE ac_store_orders
- SET status = 'paid', payment_ref = :payment_ref, updated_at = NOW()
- WHERE id = :id
- ");
- $upd->execute([
- ':id' => $orderId,
- ':payment_ref' => $captureRef !== '' ? $captureRef : $paypalOrderId,
- ]);
- } catch (Throwable $e) {
- }
-
- $itemsForEmail = $this->orderItemsForEmail($db, $orderId);
- $downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, 'paid');
- $discountCode = trim((string)($order['discount_code'] ?? ''));
- if ($discountCode !== '') {
- $this->bumpDiscountUsage($db, $discountCode);
- }
- $this->rebuildSalesChartCache();
- $this->sendOrderEmail(
- $email,
- $orderNo,
- (string)($order['currency'] ?? 'GBP'),
- (float)($order['total'] ?? 0),
- $itemsForEmail,
- 'paid',
- $downloadLinksHtml
- );
-
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
- $_SESSION['ac_last_order_no'] = $orderNo;
- $_SESSION['ac_cart'] = [];
- unset($_SESSION['ac_discount_code']);
- return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]);
+ $_SESSION['ac_checkout_card_email'] = $email;
+ $_SESSION['ac_checkout_card_terms'] = 1;
+ return new Response('', 302, ['Location' => '/checkout/card']);
}
- public function checkoutPaypalCancel(): Response
+ public function checkoutCard(): Response
{
- return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+was+cancelled']);
- }
-
- public function download(): Response
- {
- $token = trim((string)($_GET['token'] ?? ''));
- if ($token === '') {
- return new Response('Invalid download token.', 400);
- }
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return new Response('Download service unavailable.', 500);
- }
- $this->ensureAnalyticsSchema();
- try {
- $stmt = $db->prepare("
- SELECT t.id, t.order_id, t.order_item_id, t.file_id, t.email, t.download_limit, t.downloads_used, t.expires_at,
- f.file_url, f.file_name, f.mime_type, o.status AS order_status
- FROM ac_store_download_tokens t
- JOIN ac_store_files f ON f.id = t.file_id
- JOIN ac_store_orders o ON o.id = t.order_id
- WHERE t.token = :token
- LIMIT 1
- ");
- $stmt->execute([':token' => $token]);
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
- if (!$row) {
- return new Response('Download link is invalid.', 404);
- }
- $orderStatus = strtolower(trim((string)($row['order_status'] ?? '')));
- if ($orderStatus !== 'paid') {
- return new Response('Downloads are no longer available for this order.', 410);
- }
-
- $limit = (int)($row['download_limit'] ?? 0);
- $used = (int)($row['downloads_used'] ?? 0);
- if ($limit > 0 && $used >= $limit) {
- return new Response('Download limit reached.', 410);
- }
-
- $expiresAt = (string)($row['expires_at'] ?? '');
- if ($expiresAt !== '') {
- try {
- if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) {
- return new Response('Download link expired.', 410);
- }
- } catch (Throwable $e) {
- }
- }
-
- $relative = ltrim((string)($row['file_url'] ?? ''), '/');
- if ($relative === '' || str_contains($relative, '..')) {
- return new Response('Invalid file path.', 400);
- }
- $root = rtrim((string)Settings::get('store_private_root', $this->privateRoot()), '/');
- $path = $root . '/' . $relative;
- if (!is_file($path) || !is_readable($path)) {
- return new Response('File not found.', 404);
- }
-
- $upd = $db->prepare("UPDATE ac_store_download_tokens SET downloads_used = downloads_used + 1 WHERE id = :id");
- $upd->execute([':id' => (int)$row['id']]);
- try {
- $evt = $db->prepare("
- INSERT INTO ac_store_download_events
- (token_id, order_id, order_item_id, file_id, email, ip_address, user_agent, downloaded_at)
- VALUES (:token_id, :order_id, :order_item_id, :file_id, :email, :ip_address, :user_agent, NOW())
- ");
- $evt->execute([
- ':token_id' => (int)($row['id'] ?? 0),
- ':order_id' => (int)($row['order_id'] ?? 0),
- ':order_item_id' => (int)($row['order_item_id'] ?? 0),
- ':file_id' => (int)($row['file_id'] ?? 0),
- ':email' => (string)($row['email'] ?? ''),
- ':ip_address' => $this->clientIp(),
- ':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
- ]);
- } catch (Throwable $e) {
- }
-
- $fileName = (string)($row['file_name'] ?? basename($path));
- $mime = (string)($row['mime_type'] ?? '');
- if ($mime === '') {
- $mime = 'application/octet-stream';
- }
- header('Content-Type: ' . $mime);
- header('Content-Length: ' . (string)filesize($path));
- header('Content-Disposition: attachment; filename="' . str_replace('"', '', $fileName) . '"');
- readfile($path);
- exit;
- } catch (Throwable $e) {
- return new Response('Download failed.', 500);
- }
- }
-
- private function guard(): ?Response
- {
- if (!Auth::check()) {
- return new Response('', 302, ['Location' => '/admin/login']);
- }
- if (!Auth::hasRole(['admin', 'manager'])) {
- return new Response('', 302, ['Location' => '/admin']);
- }
- return null;
- }
-
- private function ensureAnalyticsSchema(): void
- {
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return;
- }
- $this->ensureSalesChartSchema();
- try {
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_download_events (
- id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- token_id INT UNSIGNED NOT NULL,
- order_id INT UNSIGNED NOT NULL,
- order_item_id INT UNSIGNED NOT NULL,
- file_id INT UNSIGNED NOT NULL,
- email VARCHAR(190) NULL,
- ip_address VARCHAR(64) NULL,
- user_agent VARCHAR(255) NULL,
- downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- KEY idx_store_download_events_order (order_id),
- KEY idx_store_download_events_item (order_item_id),
- KEY idx_store_download_events_ip (ip_address)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
- id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- email VARCHAR(190) NOT NULL,
- token_hash CHAR(64) NOT NULL UNIQUE,
- expires_at DATETIME NOT NULL,
- used_at DATETIME NULL,
- request_ip VARCHAR(64) NULL,
- request_user_agent VARCHAR(255) NULL,
- used_ip VARCHAR(64) NULL,
- used_user_agent VARCHAR(255) NULL,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- KEY idx_store_login_tokens_email (email),
- KEY idx_store_login_tokens_expires (expires_at)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_ip VARCHAR(64) NULL AFTER is_active");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_user_agent VARCHAR(255) NULL AFTER last_order_ip");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_seen_at DATETIME NULL AFTER last_order_user_agent");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_customers ADD COLUMN orders_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_seen_at");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_customers ADD COLUMN total_spent DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER orders_count");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total");
- } catch (Throwable $e) {
- }
- try {
- $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code");
- } catch (Throwable $e) {
- }
- }
-
- private function tablesReady(): bool
- {
- $db = Database::get();
- if (!$db instanceof PDO) {
- return false;
- }
- try {
- $db->query("SELECT 1 FROM ac_store_release_products LIMIT 1");
- $db->query("SELECT 1 FROM ac_store_track_products LIMIT 1");
- $db->query("SELECT 1 FROM ac_store_files LIMIT 1");
- $db->query("SELECT 1 FROM ac_store_orders LIMIT 1");
- return true;
- } catch (Throwable $e) {
- return false;
- }
- }
-
- private function privateRoot(): string
- {
- return '/home/audiocore.site/private_downloads';
- }
-
- private function ensurePrivateRoot(string $path): bool
- {
- $path = rtrim($path, '/');
- if ($path === '') {
- return false;
- }
- if (!is_dir($path) && !mkdir($path, 0755, true)) {
- return false;
- }
- if (!is_writable($path)) {
- return false;
- }
- $tracks = $path . '/tracks';
- if (!is_dir($tracks) && !mkdir($tracks, 0755, true)) {
- return false;
- }
- return is_writable($tracks);
- }
-
- private function privateRootReady(): bool
- {
- $path = Settings::get('store_private_root', $this->privateRoot());
- $path = rtrim($path, '/');
- if ($path === '' || !is_dir($path) || !is_writable($path)) {
- return false;
- }
- $tracks = $path . '/tracks';
- return is_dir($tracks) && is_writable($tracks);
- }
-
- private function settingsPayload(): array
- {
- $cronKey = trim((string)Settings::get('store_sales_chart_cron_key', ''));
- if ($cronKey === '') {
- try {
- $cronKey = bin2hex(random_bytes(24));
- Settings::set('store_sales_chart_cron_key', $cronKey);
- } catch (Throwable $e) {
- $cronKey = '';
- }
- }
- return [
- 'store_currency' => Settings::get('store_currency', 'GBP'),
- 'store_private_root' => Settings::get('store_private_root', $this->privateRoot()),
- 'store_download_limit' => Settings::get('store_download_limit', '5'),
- 'store_download_expiry_days' => Settings::get('store_download_expiry_days', '30'),
- 'store_order_prefix' => Settings::get('store_order_prefix', 'AC-ORD'),
- 'store_timezone' => Settings::get('store_timezone', 'UTC'),
- 'store_test_mode' => Settings::get('store_test_mode', '1'),
- 'store_stripe_enabled' => Settings::get('store_stripe_enabled', '0'),
- 'store_stripe_public_key' => Settings::get('store_stripe_public_key', ''),
- 'store_stripe_secret_key' => Settings::get('store_stripe_secret_key', ''),
- 'store_paypal_enabled' => Settings::get('store_paypal_enabled', '0'),
- 'store_paypal_client_id' => Settings::get('store_paypal_client_id', ''),
- 'store_paypal_secret' => Settings::get('store_paypal_secret', ''),
- 'store_email_logo_url' => Settings::get('store_email_logo_url', ''),
- 'store_order_email_subject' => Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'),
- 'store_order_email_html' => Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()),
- 'store_sales_chart_default_scope' => Settings::get('store_sales_chart_default_scope', 'tracks'),
- 'store_sales_chart_default_window' => Settings::get('store_sales_chart_default_window', 'latest'),
- 'store_sales_chart_limit' => Settings::get('store_sales_chart_limit', '10'),
- 'store_sales_chart_latest_hours' => Settings::get('store_sales_chart_latest_hours', '24'),
- 'store_sales_chart_refresh_minutes' => Settings::get('store_sales_chart_refresh_minutes', '180'),
- 'store_sales_chart_cron_key' => $cronKey,
- ];
- }
-
- private function normalizeTimezone(string $timezone): string
- {
- $timezone = trim($timezone);
- if ($timezone === '') {
- return 'UTC';
- }
- return in_array($timezone, \DateTimeZone::listIdentifiers(), true) ? $timezone : 'UTC';
- }
-
- private function applyStoreTimezone(): void
- {
- $timezone = $this->normalizeTimezone((string)Settings::get('store_timezone', 'UTC'));
- @date_default_timezone_set($timezone);
-
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return;
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
}
- try {
- $tz = new \DateTimeZone($timezone);
- $now = new \DateTimeImmutable('now', $tz);
- $offset = $tz->getOffset($now);
- $sign = $offset < 0 ? '-' : '+';
- $offset = abs($offset);
- $hours = str_pad((string)intdiv($offset, 3600), 2, '0', STR_PAD_LEFT);
- $mins = str_pad((string)intdiv($offset % 3600, 60), 2, '0', STR_PAD_LEFT);
- $dbTz = $sign . $hours . ':' . $mins;
- $db->exec("SET time_zone = '" . $dbTz . "'");
- } catch (Throwable $e) {
+ $prefillEmail = trim((string)($_SESSION['ac_checkout_card_email'] ?? ''));
+ $acceptedTerms = ((int)($_SESSION['ac_checkout_card_terms'] ?? 0) === 1);
+ $context = $this->buildCheckoutContext($prefillEmail !== '' ? $prefillEmail : 'preview@example.com', true);
+ if (!(bool)($context['ok'] ?? false)) {
+ return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)((string)($context['error'] ?? '') !== '' ? $context['error'] : 'CARD_CONTEXT_FAIL'))]);
}
- }
-
- private function ensureSalesChartSchema(): void
- {
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return;
+
+ $settings = $this->settingsPayload();
+ $paypalEnabled = $this->isEnabledSetting($settings['store_paypal_enabled'] ?? '0');
+ $paypalCardsEnabled = $paypalEnabled && $this->isEnabledSetting($settings['store_paypal_cards_enabled'] ?? '0');
+ $paypalCapabilityStatus = (string)($settings['store_paypal_cards_capability_status'] ?? 'unknown');
+ $paypalClientToken = '';
+ $paypalCardsAvailable = false;
+ if ($paypalCardsEnabled && $paypalCapabilityStatus === 'available') {
+ $clientId = trim((string)($settings['store_paypal_client_id'] ?? ''));
+ $secret = trim((string)($settings['store_paypal_secret'] ?? ''));
+ if ($clientId !== '' && $secret !== '') {
+ $token = $this->paypalGenerateClientToken(
+ $clientId,
+ $secret,
+ $this->isEnabledSetting($settings['store_test_mode'] ?? '1')
+ );
+ if ($token['ok'] ?? false) {
+ $paypalClientToken = (string)($token['client_token'] ?? '');
+ $paypalCardsAvailable = $paypalClientToken !== '';
+ }
+ }
+ }
+
+ if (!$paypalCardsAvailable) {
+ return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode('CARD_UNAVAILABLE_' . $paypalCapabilityStatus)]);
}
- try {
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_sales_chart_cache (
- id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- chart_scope ENUM('tracks','releases') NOT NULL,
- chart_window ENUM('latest','weekly','all_time') NOT NULL,
- rank_no INT UNSIGNED NOT NULL,
- item_key VARCHAR(190) NOT NULL,
- item_label VARCHAR(255) NOT NULL,
- units INT UNSIGNED NOT NULL DEFAULT 0,
- revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00,
- snapshot_from DATETIME NULL,
- snapshot_to DATETIME NULL,
- updated_at DATETIME NOT NULL,
- UNIQUE KEY uniq_sales_chart_rank (chart_scope, chart_window, rank_no),
- KEY idx_sales_chart_item (chart_scope, chart_window, item_key)
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- } catch (Throwable $e) {
- }
- }
-
- public function rebuildSalesChartCache(): bool
- {
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return false;
- }
- $this->ensureSalesChartSchema();
-
- $latestHours = max(1, min(168, (int)Settings::get('store_sales_chart_latest_hours', '24')));
- $now = new \DateTimeImmutable('now');
- $ranges = [
- 'latest' => [
- 'from' => $now->modify('-' . $latestHours . ' hours')->format('Y-m-d H:i:s'),
- 'to' => $now->format('Y-m-d H:i:s'),
- ],
- 'weekly' => [
- 'from' => $now->modify('-7 days')->format('Y-m-d H:i:s'),
- 'to' => $now->format('Y-m-d H:i:s'),
- ],
- 'all_time' => [
- 'from' => null,
- 'to' => $now->format('Y-m-d H:i:s'),
- ],
- ];
-
- $maxRows = max(10, min(100, (int)Settings::get('store_sales_chart_limit', '10') * 2));
-
- try {
- $db->beginTransaction();
- $delete = $db->prepare("DELETE FROM ac_store_sales_chart_cache WHERE chart_scope = :scope AND chart_window = :window");
- $insert = $db->prepare("
- INSERT INTO ac_store_sales_chart_cache
- (chart_scope, chart_window, rank_no, item_key, item_label, units, revenue, snapshot_from, snapshot_to, updated_at)
- VALUES (:chart_scope, :chart_window, :rank_no, :item_key, :item_label, :units, :revenue, :snapshot_from, :snapshot_to, :updated_at)
- ");
-
- foreach (['tracks', 'releases'] as $scope) {
- foreach ($ranges as $window => $range) {
- $delete->execute([':scope' => $scope, ':window' => $window]);
- $rows = $scope === 'tracks'
- ? $this->salesChartTrackRows($db, $range['from'], $maxRows)
- : $this->salesChartReleaseRows($db, $range['from'], $maxRows);
-
- $rank = 1;
- foreach ($rows as $row) {
- $itemKey = trim((string)($row['item_key'] ?? ''));
- $itemLabel = trim((string)($row['item_label'] ?? ''));
- if ($itemLabel === '') {
- continue;
- }
- if ($itemKey === '') {
- $itemKey = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $itemLabel) ?? '');
- }
- $insert->execute([
- ':chart_scope' => $scope,
- ':chart_window' => $window,
- ':rank_no' => $rank,
- ':item_key' => substr($itemKey, 0, 190),
- ':item_label' => substr($itemLabel, 0, 255),
- ':units' => max(0, (int)($row['units'] ?? 0)),
- ':revenue' => round((float)($row['revenue'] ?? 0), 2),
- ':snapshot_from' => $range['from'],
- ':snapshot_to' => $range['to'],
- ':updated_at' => $now->format('Y-m-d H:i:s'),
- ]);
- $rank++;
- }
- }
- }
-
- $db->commit();
- return true;
- } catch (Throwable $e) {
- if ($db->inTransaction()) {
- $db->rollBack();
- }
- return false;
- }
- }
-
- private function salesChartTrackRows(PDO $db, ?string $from, int $limit): array
- {
- $sql = "
- SELECT
- CONCAT('track:', CAST(oi.item_id AS CHAR)) AS item_key,
- COALESCE(NULLIF(MAX(rt.title), ''), MAX(oi.title_snapshot)) AS item_label,
- SUM(oi.qty) AS units,
- SUM(oi.line_total) AS revenue
- FROM ac_store_order_items oi
- JOIN ac_store_orders o ON o.id = oi.order_id
- LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id
- WHERE o.status = 'paid'
- AND oi.item_type = 'track'
- ";
- if ($from !== null) {
- $sql .= " AND o.created_at >= :from ";
- }
- $sql .= "
- GROUP BY oi.item_id
- ORDER BY units DESC, revenue DESC, item_label ASC
- LIMIT :lim
- ";
- $stmt = $db->prepare($sql);
- if ($from !== null) {
- $stmt->bindValue(':from', $from, PDO::PARAM_STR);
- }
- $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
- $stmt->execute();
- return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- }
-
- private function salesChartReleaseRows(PDO $db, ?string $from, int $limit): array
- {
- $sql = "
- SELECT
- CONCAT('release:', CAST(COALESCE(ri.release_id, r.id, oi.item_id) AS CHAR)) AS item_key,
- COALESCE(NULLIF(MAX(r.title), ''), NULLIF(MAX(rr.title), ''), MAX(oi.title_snapshot)) AS item_label,
- SUM(oi.qty) AS units,
- SUM(oi.line_total) AS revenue
- FROM ac_store_order_items oi
- JOIN ac_store_orders o ON o.id = oi.order_id
- LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id
- LEFT JOIN ac_releases rr ON oi.item_type = 'release' AND oi.item_id = rr.id
- LEFT JOIN ac_releases r ON rt.release_id = r.id
- LEFT JOIN (
- SELECT id, id AS release_id FROM ac_releases
- ) ri ON oi.item_type = 'release' AND oi.item_id = ri.id
- WHERE o.status = 'paid'
- ";
- if ($from !== null) {
- $sql .= " AND o.created_at >= :from ";
- }
- $sql .= "
- GROUP BY COALESCE(ri.release_id, r.id, oi.item_id)
- ORDER BY units DESC, revenue DESC, item_label ASC
- LIMIT :lim
- ";
- $stmt = $db->prepare($sql);
- if ($from !== null) {
- $stmt->bindValue(':from', $from, PDO::PARAM_STR);
- }
- $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
- $stmt->execute();
- return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- }
-
- private function salesChartRows(string $scope, string $window, int $limit): array
- {
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return [];
- }
- try {
- $stmt = $db->prepare("
- SELECT rank_no, item_label, units, revenue, snapshot_from, snapshot_to, updated_at
- FROM ac_store_sales_chart_cache
- WHERE chart_scope = :scope
- AND chart_window = :window
- ORDER BY rank_no ASC
- LIMIT :lim
- ");
- $stmt->bindValue(':scope', $scope, PDO::PARAM_STR);
- $stmt->bindValue(':window', $window, PDO::PARAM_STR);
- $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
- $stmt->execute();
- return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- } catch (Throwable $e) {
- return [];
- }
- }
-
- private function salesChartLastRebuildAt(): ?string
- {
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return null;
- }
- try {
- $stmt = $db->query("SELECT MAX(updated_at) AS updated_at FROM ac_store_sales_chart_cache");
- $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
- $value = trim((string)($row['updated_at'] ?? ''));
- return $value !== '' ? $value : null;
- } catch (Throwable $e) {
- return null;
- }
- }
-
- private function salesChartCronUrl(): string
- {
- $base = $this->baseUrl();
- $key = trim((string)Settings::get('store_sales_chart_cron_key', ''));
- if ($base === '' || $key === '') {
- return '';
- }
- return $base . '/store/sales-chart/rebuild?key=' . rawurlencode($key);
- }
-
- private function salesChartCronCommand(): string
- {
- $url = $this->salesChartCronUrl();
- $minutes = max(5, min(1440, (int)Settings::get('store_sales_chart_refresh_minutes', '180')));
- $step = max(1, (int)floor($minutes / 60));
- $prefix = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *';
- if ($url === '') {
- return '';
- }
- return $prefix . " /usr/bin/curl -fsS '" . $url . "' >/dev/null 2>&1";
- }
-
- private function ensureDiscountSchema(): void
- {
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return;
- }
- try {
- $db->exec("
- CREATE TABLE IF NOT EXISTS ac_store_discount_codes (
- id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- code VARCHAR(64) NOT NULL UNIQUE,
- discount_type ENUM('percent','fixed') NOT NULL DEFAULT 'percent',
- discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
- max_uses INT UNSIGNED NOT NULL DEFAULT 0,
- used_count INT UNSIGNED NOT NULL DEFAULT 0,
- expires_at DATETIME NULL,
- is_active TINYINT(1) NOT NULL DEFAULT 1,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
- ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
- ");
- } catch (Throwable $e) {
- }
- }
-
- private function adminDiscountRows(): array
- {
- $db = Database::get();
- if (!($db instanceof PDO)) {
- return [];
- }
- try {
- $stmt = $db->query("
- SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at
- FROM ac_store_discount_codes
- ORDER BY created_at DESC
- LIMIT 300
- ");
- return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
- } catch (Throwable $e) {
- return [];
- }
- }
-
- private function loadActiveDiscount(PDO $db, string $code): ?array
- {
- try {
- $stmt = $db->prepare("
- SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active
- FROM ac_store_discount_codes
- WHERE code = :code
- LIMIT 1
- ");
- $stmt->execute([':code' => strtoupper(trim($code))]);
- $row = $stmt->fetch(PDO::FETCH_ASSOC);
- if (!$row) {
- return null;
- }
- if ((int)($row['is_active'] ?? 0) !== 1) {
- return null;
- }
- $maxUses = (int)($row['max_uses'] ?? 0);
- $used = (int)($row['used_count'] ?? 0);
- if ($maxUses > 0 && $used >= $maxUses) {
- return null;
- }
- $expires = trim((string)($row['expires_at'] ?? ''));
- if ($expires !== '') {
- try {
- if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expires)) {
- return null;
- }
- } catch (Throwable $e) {
- return null;
- }
- }
- return $row;
- } catch (Throwable $e) {
- return null;
- }
- }
-
- private function buildCartTotals(array $items, string $discountCode = ''): array
- {
- $totals = [
- 'count' => 0,
- 'subtotal' => 0.0,
- 'discount_amount' => 0.0,
- 'amount' => 0.0,
- 'currency' => Settings::get('store_currency', 'GBP'),
- 'discount_code' => '',
- ];
- foreach ($items as $item) {
- if (!is_array($item)) {
- continue;
- }
- $qty = max(1, (int)($item['qty'] ?? 1));
- $price = (float)($item['price'] ?? 0);
- $totals['count'] += $qty;
- $totals['subtotal'] += ($price * $qty);
- if (!empty($item['currency'])) {
- $totals['currency'] = (string)$item['currency'];
- }
- }
-
- $discountCode = strtoupper(trim($discountCode));
- if ($discountCode !== '' && $totals['subtotal'] > 0) {
- $db = Database::get();
- if ($db instanceof PDO) {
- $this->ensureDiscountSchema();
- $discount = $this->loadActiveDiscount($db, $discountCode);
- if ($discount) {
- $discountType = (string)($discount['discount_type'] ?? 'percent');
- $discountValue = (float)($discount['discount_value'] ?? 0);
- if ($discountType === 'fixed') {
- $totals['discount_amount'] = min($totals['subtotal'], max(0, round($discountValue, 2)));
- } else {
- $percent = min(100, max(0, $discountValue));
- $totals['discount_amount'] = min($totals['subtotal'], round($totals['subtotal'] * ($percent / 100), 2));
- }
- $totals['discount_code'] = (string)($discount['code'] ?? '');
- }
- }
- }
-
- $totals['amount'] = max(0, round($totals['subtotal'] - $totals['discount_amount'], 2));
- return $totals;
- }
-
- private function sendOrderEmail(string $to, string $orderNo, string $currency, float $total, array $items, string $status, string $downloadLinksHtml): void
- {
- if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
- return;
- }
- $subjectTpl = (string)Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}');
- $htmlTpl = (string)Settings::get('store_order_email_html', $this->defaultOrderEmailHtml());
- $itemsHtml = $this->renderItemsHtml($items, $currency);
- $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore'));
- $logoUrl = trim((string)Settings::get('store_email_logo_url', ''));
- $logoHtml = $logoUrl !== ''
- ? '
 . ')
'
- : '';
- if ($downloadLinksHtml === '') {
- $downloadLinksHtml = $status === 'paid'
- ? '
Your download links will be available in your account/downloads area.
'
- : '
Download links are sent once payment is confirmed.
';
- }
- $map = [
- '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'),
- '{{order_no}}' => htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'),
- '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'),
- '{{currency}}' => htmlspecialchars($currency, ENT_QUOTES, 'UTF-8'),
- '{{total}}' => number_format($total, 2),
- '{{status}}' => htmlspecialchars($status, ENT_QUOTES, 'UTF-8'),
- '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'),
- '{{logo_html}}' => $logoHtml,
- '{{items_html}}' => $itemsHtml,
- '{{download_links_html}}' => $downloadLinksHtml,
- ];
- $subject = strtr($subjectTpl, $map);
- $html = strtr($htmlTpl, $map);
- $mailSettings = [
- 'smtp_host' => Settings::get('smtp_host', ''),
- 'smtp_port' => Settings::get('smtp_port', '587'),
- 'smtp_user' => Settings::get('smtp_user', ''),
- 'smtp_pass' => Settings::get('smtp_pass', ''),
- 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
- 'smtp_from_email' => Settings::get('smtp_from_email', ''),
- 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
- ];
- Mailer::send($to, $subject, $html, $mailSettings);
- }
-
- private function sanitizeOrderPrefix(string $prefix): string
- {
- $prefix = strtoupper(trim($prefix));
- $prefix = preg_replace('/[^A-Z0-9-]+/', '-', $prefix) ?? 'AC-ORD';
- $prefix = trim($prefix, '-');
- if ($prefix === '') {
- return 'AC-ORD';
- }
- return substr($prefix, 0, 20);
- }
-
- private function provisionDownloadTokens(PDO $db, int $orderId, string $email, string $status): string
- {
- if ($status !== 'paid') {
- return '
Download links are sent once payment is confirmed.
';
- }
- $downloadLimit = max(1, (int)Settings::get('store_download_limit', '5'));
- $expiryDays = max(1, (int)Settings::get('store_download_expiry_days', '30'));
- $expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $expiryDays . ' days')->format('Y-m-d H:i:s');
-
- try {
- $itemStmt = $db->prepare("SELECT id, item_type, item_id, title_snapshot FROM ac_store_order_items WHERE order_id = :order_id ORDER BY id ASC");
- $itemStmt->execute([':order_id' => $orderId]);
- $orderItems = $itemStmt->fetchAll(PDO::FETCH_ASSOC);
- if (!$orderItems) {
- return '
No downloadable items in this order.
';
- }
-
- $tokenStmt = $db->prepare("
- INSERT INTO ac_store_download_tokens
- (order_id, order_item_id, file_id, email, token, download_limit, downloads_used, expires_at, created_at)
- VALUES (:order_id, :order_item_id, :file_id, :email, :token, :download_limit, 0, :expires_at, NOW())
- ");
-
- $links = [];
- foreach ($orderItems as $item) {
- $type = (string)($item['item_type'] ?? '');
- $itemId = (int)($item['item_id'] ?? 0);
- $orderItemId = (int)($item['id'] ?? 0);
- if ($itemId <= 0 || $orderItemId <= 0) {
- continue;
- }
- $files = [];
- if ($type === 'release') {
- $trackIdsStmt = $db->prepare("
- SELECT t.id
- FROM ac_release_tracks t
- JOIN ac_store_track_products sp ON sp.release_track_id = t.id
- WHERE t.release_id = :release_id AND sp.is_enabled = 1
- ORDER BY t.track_no ASC, t.id ASC
- ");
- $trackIdsStmt->execute([':release_id' => $itemId]);
- $trackIds = array_map(static fn($r) => (int)($r['id'] ?? 0), $trackIdsStmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
- $trackIds = array_values(array_filter($trackIds, static fn($id) => $id > 0));
- if ($trackIds) {
- $placeholders = implode(',', array_fill(0, count($trackIds), '?'));
- $fileStmt = $db->prepare("
- SELECT id, file_name
- FROM ac_store_files
- WHERE scope_type = 'track' AND scope_id IN ($placeholders) AND is_active = 1
- ORDER BY id DESC
- ");
- $fileStmt->execute($trackIds);
- $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- }
- } else {
- $fileStmt = $db->prepare("
- SELECT id, file_name
- FROM ac_store_files
- WHERE scope_type = 'track' AND scope_id = :scope_id AND is_active = 1
- ORDER BY id DESC
- ");
- $fileStmt->execute([':scope_id' => $itemId]);
- $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
- }
- foreach ($files as $file) {
- $fileId = (int)($file['id'] ?? 0);
- if ($fileId <= 0) {
- continue;
- }
- $token = bin2hex(random_bytes(24));
- $tokenStmt->execute([
- ':order_id' => $orderId,
- ':order_item_id' => $orderItemId,
- ':file_id' => $fileId,
- ':email' => $email,
- ':token' => $token,
- ':download_limit' => $downloadLimit,
- ':expires_at' => $expiresAt,
- ]);
- $label = (string)($file['file_name'] ?? $item['title_snapshot'] ?? 'Download');
- $links[] = [
- 'url' => $this->baseUrl() . '/store/download?token=' . rawurlencode($token),
- 'label' => $label,
- ];
- }
- }
- if (!$links) {
- return '
No downloadable files attached yet.
';
- }
- $rows = [];
- foreach ($links as $link) {
- $rows[] = '
' . htmlspecialchars($link['label'], ENT_QUOTES, 'UTF-8') . '';
- }
- return '
Your Downloads
' . implode('', $rows) . '
';
- } catch (Throwable $e) {
- return '
Download links could not be generated yet.
';
- }
- }
-
- private function renderItemsHtml(array $items, string $defaultCurrency): string
- {
- $rows = [];
- foreach ($items as $item) {
- if (!is_array($item)) {
- continue;
- }
- $title = htmlspecialchars((string)($item['title'] ?? 'Item'), ENT_QUOTES, 'UTF-8');
- $qty = max(1, (int)($item['qty'] ?? 1));
- $price = (float)($item['price'] ?? 0);
- $currency = htmlspecialchars((string)($item['currency'] ?? $defaultCurrency), ENT_QUOTES, 'UTF-8');
- $line = number_format($price * $qty, 2);
- $rows[] = '
'
- . '| ' . $title . ' | '
- . '' . $currency . ' ' . $line . ' | '
- . '
';
- }
- if (!$rows) {
- return '
No items.
';
- }
- return '
' . implode('', $rows) . '
';
- }
-
+
+ return new Response($this->view->render('site/checkout_card.php', [
+ 'title' => 'Card Checkout',
+ 'items' => (array)($context['items'] ?? []),
+ 'total' => (float)($context['total'] ?? 0),
+ 'subtotal' => (float)($context['subtotal'] ?? 0),
+ 'discount_amount' => (float)($context['discount_amount'] ?? 0),
+ 'discount_code' => (string)($context['discount_code'] ?? ''),
+ 'currency' => (string)($context['currency'] ?? 'GBP'),
+ 'email' => $prefillEmail,
+ 'accept_terms' => $acceptedTerms,
+ 'download_limit' => (int)Settings::get('store_download_limit', '5'),
+ 'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'),
+ 'paypal_client_id' => (string)($settings['store_paypal_client_id'] ?? ''),
+ 'paypal_client_token' => $paypalClientToken,
+ 'paypal_merchant_country' => (string)($settings['store_paypal_merchant_country'] ?? ''),
+ 'paypal_card_branding_text' => (string)($settings['store_paypal_card_branding_text'] ?? 'Pay with card'),
+ ]));
+ }
+
+ public function checkoutSandbox(): Response
+ {
+ return $this->checkoutPlace();
+ }
+
+ public function checkoutPlace(): Response
+ {
+ if ((string)($_POST['checkout_method'] ?? '') === 'card') {
+ return $this->checkoutCardStart();
+ }
+
+ $email = trim((string)($_POST['email'] ?? ''));
+ $acceptedTerms = isset($_POST['accept_terms']);
+ $context = $this->buildCheckoutContext($email, $acceptedTerms);
+ if (!(bool)($context['ok'] ?? false)) {
+ return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)((string)($context['error'] ?? '') !== '' ? $context['error'] : 'Unable to process checkout.'))]);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']);
+ }
+
+ $testMode = $this->isEnabledSetting(Settings::get('store_test_mode', '1'));
+ $paypalEnabled = $this->isEnabledSetting(Settings::get('store_paypal_enabled', '0'));
+ $clientId = trim((string)Settings::get('store_paypal_client_id', ''));
+ $secret = trim((string)Settings::get('store_paypal_secret', ''));
+
+ if ((float)$context['total'] <= 0.0) {
+ $order = $this->createPendingOrder($db, $context, 'discount');
+ $result = $this->finalizeOrderAsPaid($db, $this->pendingOrderId($order), 'discount', 'discount-zero-total');
+ return new Response('', 302, ['Location' => (string)$result['redirect']]);
+ }
+
+ if ($paypalEnabled) {
+ if ($clientId === '' || $secret === '') {
+ return new Response('', 302, ['Location' => '/checkout?error=PayPal+is+enabled+but+credentials+are+missing']);
+ }
+
+ $order = $this->createPendingOrder($db, $context, 'paypal');
+ $create = $this->paypalCreateOrder(
+ $clientId,
+ $secret,
+ $testMode,
+ (string)$context['currency'],
+ (float)$context['total'],
+ (string)$order['order_no'],
+ $this->baseUrl() . '/checkout/paypal/return',
+ $this->baseUrl() . '/checkout/paypal/cancel'
+ );
+ if (!($create['ok'] ?? false)) {
+ return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($create['error'] ?? 'Unable+to+start+PayPal+checkout'))]);
+ }
+
+ $paypalOrderId = trim((string)($create['order_id'] ?? ''));
+ $approvalUrl = trim((string)($create['approval_url'] ?? ''));
+ if ($paypalOrderId === '' || $approvalUrl === '') {
+ return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+did+not+return+approval+details']);
+ }
+
+ $upd = $db->prepare("
+ UPDATE ac_store_orders
+ SET payment_provider = 'paypal', payment_ref = :payment_ref, updated_at = NOW()
+ WHERE id = :id
+ ");
+ $upd->execute([
+ ':payment_ref' => $paypalOrderId,
+ ':id' => $this->pendingOrderId($order),
+ ]);
+ $this->rememberPendingOrder($this->pendingOrderId($order), (string)$order['order_no'], (string)$context['fingerprint'], $paypalOrderId);
+
+ return new Response('', 302, ['Location' => $approvalUrl]);
+ }
+
+ if ($testMode) {
+ $order = $this->createPendingOrder($db, $context, 'test');
+ $result = $this->finalizeOrderAsPaid($db, $this->pendingOrderId($order), 'test', 'test');
+ return new Response('', 302, ['Location' => (string)$result['redirect']]);
+ }
+
+ return new Response('', 302, ['Location' => '/checkout?error=No+live+payment+gateway+is+enabled']);
+ }
+
+ public function checkoutPaypalCreateOrder(): Response
+ {
+ try {
+ $payload = $this->requestPayload();
+ $this->checkoutDebugLog('paypal_create_entry', ['payload' => $payload]);
+ $email = trim((string)($payload['email'] ?? ''));
+ $acceptedTerms = $this->truthy($payload['accept_terms'] ?? false);
+ $context = $this->buildCheckoutContext($email, $acceptedTerms);
+ if (!(bool)($context['ok'] ?? false)) {
+ $error = (string)($context['error'] ?? 'Unable to validate checkout.');
+ $this->checkoutDebugLog('paypal_create_context_error', ['error' => $error, 'ok' => $context['ok'] ?? null]);
+ return $this->jsonResponse(['ok' => false, 'error' => $error], 422);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return $this->jsonResponse(['ok' => false, 'error' => 'Database unavailable.'], 500);
+ }
+
+ if ((float)$context['total'] <= 0.0) {
+ $order = $this->createPendingOrder($db, $context, 'discount');
+ $result = $this->finalizeOrderAsPaid($db, $this->pendingOrderId($order), 'discount', 'discount-zero-total');
+ return $this->jsonResponse([
+ 'ok' => true,
+ 'completed' => true,
+ 'redirect' => (string)$result['redirect'],
+ 'order_no' => (string)$order['order_no'],
+ ]);
+ }
+
+ $settings = $this->settingsPayload();
+ if (!$this->isEnabledSetting($settings['store_paypal_enabled'] ?? '0')) {
+ return $this->jsonResponse(['ok' => false, 'error' => 'PayPal is not enabled.'], 422);
+ }
+
+ $clientId = trim((string)($settings['store_paypal_client_id'] ?? ''));
+ $secret = trim((string)($settings['store_paypal_secret'] ?? ''));
+ if ($clientId === '' || $secret === '') {
+ return $this->jsonResponse(['ok' => false, 'error' => 'PayPal credentials are missing.'], 422);
+ }
+
+ $existing = $this->reusablePendingOrder($db, (string)$context['fingerprint']);
+ if ($existing && trim((string)($existing['payment_ref'] ?? '')) !== '') {
+ return $this->jsonResponse([
+ 'ok' => true,
+ 'local_order_id' => (int)($existing['id'] ?? 0),
+ 'order_no' => (string)($existing['order_no'] ?? ''),
+ 'paypal_order_id' => (string)($existing['payment_ref'] ?? ''),
+ 'orderID' => (string)($existing['payment_ref'] ?? ''),
+ ]);
+ }
+
+ $order = $this->createPendingOrder($db, $context, 'paypal');
+ $create = $this->paypalCreateOrder(
+ $clientId,
+ $secret,
+ $this->isEnabledSetting($settings['store_test_mode'] ?? '1'),
+ (string)$context['currency'],
+ (float)$context['total'],
+ (string)$order['order_no'],
+ $this->baseUrl() . '/checkout/paypal/return',
+ $this->baseUrl() . '/checkout/paypal/cancel'
+ );
+ if (!($create['ok'] ?? false)) {
+ $this->checkoutDebugLog('paypal_create_fail', ['error' => (string)($create['error'] ?? 'Unable to start PayPal checkout.')]);
+ return $this->jsonResponse(['ok' => false, 'error' => (string)($create['error'] ?? 'Unable to start PayPal checkout.')], 422);
+ }
+
+ $paypalOrderId = trim((string)($create['order_id'] ?? ''));
+ if ($paypalOrderId === '') {
+ return $this->jsonResponse(['ok' => false, 'error' => 'PayPal did not return an order id.'], 422);
+ }
+
+ $upd = $db->prepare("
+ UPDATE ac_store_orders
+ SET payment_provider = 'paypal', payment_ref = :payment_ref, updated_at = NOW()
+ WHERE id = :id
+ ");
+ $upd->execute([
+ ':payment_ref' => $paypalOrderId,
+ ':id' => $this->pendingOrderId($order),
+ ]);
+ $this->rememberPendingOrder($this->pendingOrderId($order), (string)$order['order_no'], (string)$context['fingerprint'], $paypalOrderId);
+
+ $this->checkoutDebugLog('paypal_create_ok', ['paypal_order_id' => $paypalOrderId, 'local_order_id' => $this->pendingOrderId($order)]);
+ return $this->jsonResponse([
+ 'ok' => true,
+ 'local_order_id' => $this->pendingOrderId($order),
+ 'order_no' => (string)$order['order_no'],
+ 'paypal_order_id' => $paypalOrderId,
+ 'orderID' => $paypalOrderId,
+ 'approval_url' => (string)($create['approval_url'] ?? ''),
+ ]);
+ } catch (Throwable $e) {
+ $this->checkoutDebugLog('paypal_create_exception', ['message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()]);
+ return $this->jsonResponse(['ok' => false, 'error' => 'Server exception during PayPal order creation.'], 500);
+ }
+ }
+
+ public function checkoutPaypalCaptureJson(): Response
+ {
+ $payload = $this->requestPayload();
+ $paypalOrderId = trim((string)($payload['paypal_order_id'] ?? ($payload['orderID'] ?? '')));
+ if ($paypalOrderId === '') {
+ return $this->jsonResponse(['ok' => false, 'error' => 'Missing PayPal order id.'], 422);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return $this->jsonResponse(['ok' => false, 'error' => 'Database unavailable.'], 500);
+ }
+
+ $stmt = $db->prepare("
+ SELECT id, order_no, status
+ FROM ac_store_orders
+ WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref
+ LIMIT 1
+ ");
+ $stmt->execute([':payment_ref' => $paypalOrderId]);
+ $order = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$order) {
+ return $this->jsonResponse(['ok' => false, 'error' => 'Order not found for PayPal payment.'], 404);
+ }
+
+ if (strtolower((string)($order['status'] ?? 'pending')) === 'paid') {
+ $this->clearPendingOrder();
+ return $this->jsonResponse([
+ 'ok' => true,
+ 'completed' => true,
+ 'redirect' => '/checkout?success=1&order_no=' . rawurlencode((string)($order['order_no'] ?? '')),
+ 'order_no' => (string)($order['order_no'] ?? ''),
+ ]);
+ }
+
+ $settings = $this->settingsPayload();
+ $clientId = trim((string)($settings['store_paypal_client_id'] ?? ''));
+ $secret = trim((string)($settings['store_paypal_secret'] ?? ''));
+ if ($clientId === '' || $secret === '') {
+ return $this->jsonResponse(['ok' => false, 'error' => 'PayPal credentials are missing.'], 422);
+ }
+
+ $capture = $this->paypalCaptureOrder(
+ $clientId,
+ $secret,
+ $this->isEnabledSetting($settings['store_test_mode'] ?? '1'),
+ $paypalOrderId
+ );
+ if (!($capture['ok'] ?? false)) {
+ return $this->jsonResponse(['ok' => false, 'error' => (string)($capture['error'] ?? 'PayPal capture failed.')], 422);
+ }
+
+ $paymentRef = trim((string)($capture['capture_id'] ?? ''));
+ $result = $this->finalizeOrderAsPaid($db, (int)($order['id'] ?? 0), 'paypal', $paymentRef !== '' ? $paymentRef : $paypalOrderId, (array)($capture['payment_breakdown'] ?? []));
+ return $this->jsonResponse([
+ 'ok' => true,
+ 'completed' => true,
+ 'redirect' => (string)$result['redirect'],
+ 'order_no' => (string)$result['order_no'],
+ ]);
+ }
+
+ public function checkoutPaypalReturn(): Response
+ {
+ $paypalOrderId = trim((string)($_GET['token'] ?? ''));
+ if ($paypalOrderId === '') {
+ return new Response('', 302, ['Location' => '/checkout?error=Missing+PayPal+order+token']);
+ }
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']);
+ }
+
+ $orderStmt = $db->prepare("
+ SELECT id, order_no, email, status, currency, total, discount_code
+ FROM ac_store_orders
+ WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref
+ LIMIT 1
+ ");
+ $orderStmt->execute([':payment_ref' => $paypalOrderId]);
+ $order = $orderStmt->fetch(PDO::FETCH_ASSOC);
+ if (!$order) {
+ return new Response('', 302, ['Location' => '/checkout?error=Order+not+found+for+PayPal+payment']);
+ }
+
+ $orderId = (int)($order['id'] ?? 0);
+ $orderNo = (string)($order['order_no'] ?? '');
+ $email = (string)($order['email'] ?? '');
+ $status = (string)($order['status'] ?? 'pending');
+ if ($orderId <= 0 || $orderNo === '') {
+ return new Response('', 302, ['Location' => '/checkout?error=Invalid+order+record']);
+ }
+
+ if ($status === 'paid') {
+ $this->clearPendingOrder();
+ return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]);
+ }
+
+ $settings = $this->settingsPayload();
+ $clientId = trim((string)($settings['store_paypal_client_id'] ?? ''));
+ $secret = trim((string)($settings['store_paypal_secret'] ?? ''));
+ if ($clientId === '' || $secret === '') {
+ return new Response('', 302, ['Location' => '/checkout?error=PayPal+credentials+missing']);
+ }
+
+ $capture = $this->paypalCaptureOrder(
+ $clientId,
+ $secret,
+ $this->isEnabledSetting($settings['store_test_mode'] ?? '1'),
+ $paypalOrderId
+ );
+ if (!($capture['ok'] ?? false)) {
+ return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($capture['error'] ?? 'PayPal capture failed'))]);
+ }
+
+ $captureRef = trim((string)($capture['capture_id'] ?? ''));
+ $result = $this->finalizeOrderAsPaid($db, $orderId, 'paypal', $captureRef !== '' ? $captureRef : $paypalOrderId, (array)($capture['payment_breakdown'] ?? []));
+ return new Response('', 302, ['Location' => (string)$result['redirect']]);
+ }
+
+ public function checkoutPaypalCancel(): Response
+ {
+ $this->clearPendingOrder();
+ return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+was+cancelled']);
+ }
+
+ public function download(): Response
+ {
+ $token = trim((string)($_GET['token'] ?? ''));
+ if ($token === '') {
+ return new Response('Invalid download token.', 400);
+ }
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return new Response('Download service unavailable.', 500);
+ }
+ $this->ensureAnalyticsSchema();
+ try {
+ $stmt = $db->prepare("
+ SELECT t.id, t.order_id, t.order_item_id, t.file_id, t.email, t.download_limit, t.downloads_used, t.expires_at,
+ f.file_url, f.file_name, f.mime_type, o.status AS order_status
+ FROM ac_store_download_tokens t
+ JOIN ac_store_files f ON f.id = t.file_id
+ JOIN ac_store_orders o ON o.id = t.order_id
+ WHERE t.token = :token
+ LIMIT 1
+ ");
+ $stmt->execute([':token' => $token]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$row) {
+ return new Response('Download link is invalid.', 404);
+ }
+ $orderStatus = strtolower(trim((string)($row['order_status'] ?? '')));
+ if ($orderStatus !== 'paid') {
+ return new Response('Downloads are no longer available for this order.', 410);
+ }
+
+ $limit = (int)($row['download_limit'] ?? 0);
+ $used = (int)($row['downloads_used'] ?? 0);
+ if ($limit > 0 && $used >= $limit) {
+ return new Response('Download limit reached.', 410);
+ }
+
+ $expiresAt = (string)($row['expires_at'] ?? '');
+ if ($expiresAt !== '') {
+ try {
+ if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) {
+ return new Response('Download link expired.', 410);
+ }
+ } catch (Throwable $e) {
+ }
+ }
+
+ $relative = ltrim((string)($row['file_url'] ?? ''), '/');
+ if ($relative === '' || str_contains($relative, '..')) {
+ return new Response('Invalid file path.', 400);
+ }
+ $root = rtrim((string)Settings::get('store_private_root', $this->privateRoot()), '/');
+ $path = $root . '/' . $relative;
+ if (!is_file($path) || !is_readable($path)) {
+ return new Response('File not found.', 404);
+ }
+
+ $upd = $db->prepare("UPDATE ac_store_download_tokens SET downloads_used = downloads_used + 1 WHERE id = :id");
+ $upd->execute([':id' => (int)$row['id']]);
+ try {
+ $evt = $db->prepare("
+ INSERT INTO ac_store_download_events
+ (token_id, order_id, order_item_id, file_id, email, ip_address, user_agent, downloaded_at)
+ VALUES (:token_id, :order_id, :order_item_id, :file_id, :email, :ip_address, :user_agent, NOW())
+ ");
+ $evt->execute([
+ ':token_id' => (int)($row['id'] ?? 0),
+ ':order_id' => (int)($row['order_id'] ?? 0),
+ ':order_item_id' => (int)($row['order_item_id'] ?? 0),
+ ':file_id' => (int)($row['file_id'] ?? 0),
+ ':email' => (string)($row['email'] ?? ''),
+ ':ip_address' => $this->clientIp(),
+ ':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
+ ]);
+ } catch (Throwable $e) {
+ }
+
+ $fileName = (string)($row['file_name'] ?? basename($path));
+ $mime = (string)($row['mime_type'] ?? '');
+ if ($mime === '') {
+ $mime = 'application/octet-stream';
+ }
+ header('Content-Type: ' . $mime);
+ header('Content-Length: ' . (string)filesize($path));
+ header('Content-Disposition: attachment; filename="' . str_replace('"', '', $fileName) . '"');
+ readfile($path);
+ exit;
+ } catch (Throwable $e) {
+ return new Response('Download failed.', 500);
+ }
+ }
+
+ private function guard(): ?Response
+ {
+ if (!Auth::check()) {
+ return new Response('', 302, ['Location' => '/admin/login']);
+ }
+ if (!Auth::hasRole(['admin', 'manager'])) {
+ return new Response('', 302, ['Location' => '/admin']);
+ }
+ return null;
+ }
+
+ private function ensureAnalyticsSchema(): void
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return;
+ }
+ ApiLayer::ensureSchema($db);
+ $this->ensureSalesChartSchema();
+ try {
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_download_events (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ token_id INT UNSIGNED NOT NULL,
+ order_id INT UNSIGNED NOT NULL,
+ order_item_id INT UNSIGNED NOT NULL,
+ file_id INT UNSIGNED NOT NULL,
+ email VARCHAR(190) NULL,
+ ip_address VARCHAR(64) NULL,
+ user_agent VARCHAR(255) NULL,
+ downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_store_download_events_order (order_id),
+ KEY idx_store_download_events_item (order_item_id),
+ KEY idx_store_download_events_ip (ip_address)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ email VARCHAR(190) NOT NULL,
+ token_hash CHAR(64) NOT NULL UNIQUE,
+ expires_at DATETIME NOT NULL,
+ used_at DATETIME NULL,
+ request_ip VARCHAR(64) NULL,
+ request_user_agent VARCHAR(255) NULL,
+ used_ip VARCHAR(64) NULL,
+ used_user_agent VARCHAR(255) NULL,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ KEY idx_store_login_tokens_email (email),
+ KEY idx_store_login_tokens_expires (expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_ip VARCHAR(64) NULL AFTER is_active");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_user_agent VARCHAR(255) NULL AFTER last_order_ip");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_seen_at DATETIME NULL AFTER last_order_user_agent");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_customers ADD COLUMN orders_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_seen_at");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_customers ADD COLUMN total_spent DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER orders_count");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_gross DECIMAL(10,2) NULL AFTER payment_ref");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_fee DECIMAL(10,2) NULL AFTER payment_gross");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_net DECIMAL(10,2) NULL AFTER payment_fee");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN payment_currency CHAR(3) NULL AFTER payment_net");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_currency");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code");
+ } catch (Throwable $e) {
+ }
+ try {
+ $db->exec("ALTER TABLE ac_store_order_items MODIFY item_type ENUM('release','track','bundle') NOT NULL");
+ } catch (Throwable $e) {
+ }
+ }
+
+ private function tablesReady(): bool
+ {
+ $db = Database::get();
+ if (!$db instanceof PDO) {
+ return false;
+ }
+ try {
+ $db->query("SELECT 1 FROM ac_store_release_products LIMIT 1");
+ $db->query("SELECT 1 FROM ac_store_track_products LIMIT 1");
+ $db->query("SELECT 1 FROM ac_store_files LIMIT 1");
+ $db->query("SELECT 1 FROM ac_store_orders LIMIT 1");
+ return true;
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ private function privateRoot(): string
+ {
+ return '/home/audiocore.site/private_downloads';
+ }
+
+ private function resolveOrderItemArtistId(PDO $db, string $itemType, int $itemId): int
+ {
+ if ($itemId <= 0) {
+ return 0;
+ }
+ try {
+ if ($itemType === 'track') {
+ $stmt = $db->prepare("
+ SELECT r.artist_id
+ FROM ac_release_tracks t
+ JOIN ac_releases r ON r.id = t.release_id
+ WHERE t.id = :id
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $itemId]);
+ return (int)($stmt->fetchColumn() ?: 0);
+ }
+ if ($itemType === 'release') {
+ $stmt = $db->prepare("SELECT artist_id FROM ac_releases WHERE id = :id LIMIT 1");
+ $stmt->execute([':id' => $itemId]);
+ return (int)($stmt->fetchColumn() ?: 0);
+ }
+ } catch (Throwable $e) {
+ }
+ return 0;
+ }
+
+ private function ensurePrivateRoot(string $path): bool
+ {
+ $path = rtrim($path, '/');
+ if ($path === '') {
+ return false;
+ }
+ if (!is_dir($path) && !mkdir($path, 0755, true)) {
+ return false;
+ }
+ if (!is_writable($path)) {
+ return false;
+ }
+ $tracks = $path . '/tracks';
+ if (!is_dir($tracks) && !mkdir($tracks, 0755, true)) {
+ return false;
+ }
+ return is_writable($tracks);
+ }
+
+ private function privateRootReady(): bool
+ {
+ $path = Settings::get('store_private_root', $this->privateRoot());
+ $path = rtrim($path, '/');
+ if ($path === '' || !is_dir($path) || !is_writable($path)) {
+ return false;
+ }
+ $tracks = $path . '/tracks';
+ return is_dir($tracks) && is_writable($tracks);
+ }
+
+ private function settingsPayload(): array
+ {
+ $cronKey = trim((string)Settings::get('store_sales_chart_cron_key', ''));
+ if ($cronKey === '') {
+ try {
+ $cronKey = bin2hex(random_bytes(24));
+ Settings::set('store_sales_chart_cron_key', $cronKey);
+ } catch (Throwable $e) {
+ $cronKey = '';
+ }
+ }
+ return [
+ 'store_currency' => Settings::get('store_currency', 'GBP'),
+ 'store_private_root' => Settings::get('store_private_root', $this->privateRoot()),
+ 'store_download_limit' => Settings::get('store_download_limit', '5'),
+ 'store_download_expiry_days' => Settings::get('store_download_expiry_days', '30'),
+ 'store_order_prefix' => Settings::get('store_order_prefix', 'AC-ORD'),
+ 'store_timezone' => Settings::get('store_timezone', 'UTC'),
+ 'store_test_mode' => Settings::get('store_test_mode', '1'),
+ 'store_stripe_enabled' => Settings::get('store_stripe_enabled', '0'),
+ 'store_stripe_public_key' => Settings::get('store_stripe_public_key', ''),
+ 'store_stripe_secret_key' => Settings::get('store_stripe_secret_key', ''),
+ 'store_paypal_enabled' => Settings::get('store_paypal_enabled', '0'),
+ 'store_paypal_client_id' => Settings::get('store_paypal_client_id', ''),
+ 'store_paypal_secret' => Settings::get('store_paypal_secret', ''),
+ 'store_paypal_cards_enabled' => Settings::get('store_paypal_cards_enabled', '0'),
+ 'store_paypal_sdk_mode' => Settings::get('store_paypal_sdk_mode', 'embedded_fields'),
+ 'store_paypal_merchant_country' => Settings::get('store_paypal_merchant_country', ''),
+ 'store_paypal_card_branding_text' => Settings::get('store_paypal_card_branding_text', 'Pay with card'),
+ 'store_paypal_cards_capability_status' => Settings::get('store_paypal_cards_capability_status', 'unknown'),
+ 'store_paypal_cards_capability_message' => Settings::get('store_paypal_cards_capability_message', ''),
+ 'store_paypal_cards_capability_checked_at' => Settings::get('store_paypal_cards_capability_checked_at', ''),
+ 'store_paypal_cards_capability_mode' => Settings::get('store_paypal_cards_capability_mode', ''),
+ 'store_email_logo_url' => Settings::get('store_email_logo_url', ''),
+ 'store_order_email_subject' => Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'),
+ 'store_order_email_html' => Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()),
+ 'store_sales_chart_default_scope' => Settings::get('store_sales_chart_default_scope', 'tracks'),
+ 'store_sales_chart_default_window' => Settings::get('store_sales_chart_default_window', 'latest'),
+ 'store_sales_chart_limit' => Settings::get('store_sales_chart_limit', '10'),
+ 'store_sales_chart_latest_hours' => Settings::get('store_sales_chart_latest_hours', '24'),
+ 'store_sales_chart_refresh_minutes' => Settings::get('store_sales_chart_refresh_minutes', '180'),
+ 'store_sales_chart_cron_key' => $cronKey,
+ ];
+ }
+
+ private function normalizeTimezone(string $timezone): string
+ {
+ $timezone = trim($timezone);
+ if ($timezone === '') {
+ return 'UTC';
+ }
+ return in_array($timezone, \DateTimeZone::listIdentifiers(), true) ? $timezone : 'UTC';
+ }
+
+ private function applyStoreTimezone(): void
+ {
+ $timezone = $this->normalizeTimezone((string)Settings::get('store_timezone', 'UTC'));
+ @date_default_timezone_set($timezone);
+
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return;
+ }
+
+ try {
+ $tz = new \DateTimeZone($timezone);
+ $now = new \DateTimeImmutable('now', $tz);
+ $offset = $tz->getOffset($now);
+ $sign = $offset < 0 ? '-' : '+';
+ $offset = abs($offset);
+ $hours = str_pad((string)intdiv($offset, 3600), 2, '0', STR_PAD_LEFT);
+ $mins = str_pad((string)intdiv($offset % 3600, 60), 2, '0', STR_PAD_LEFT);
+ $dbTz = $sign . $hours . ':' . $mins;
+ $db->exec("SET time_zone = '" . $dbTz . "'");
+ } catch (Throwable $e) {
+ }
+ }
+
+ private function ensureSalesChartSchema(): void
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return;
+ }
+ try {
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_sales_chart_cache (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ chart_scope ENUM('tracks','releases') NOT NULL,
+ chart_window ENUM('latest','weekly','all_time') NOT NULL,
+ rank_no INT UNSIGNED NOT NULL,
+ item_key VARCHAR(190) NOT NULL,
+ item_label VARCHAR(255) NOT NULL,
+ units INT UNSIGNED NOT NULL DEFAULT 0,
+ revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00,
+ snapshot_from DATETIME NULL,
+ snapshot_to DATETIME NULL,
+ updated_at DATETIME NOT NULL,
+ UNIQUE KEY uniq_sales_chart_rank (chart_scope, chart_window, rank_no),
+ KEY idx_sales_chart_item (chart_scope, chart_window, item_key)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ } catch (Throwable $e) {
+ }
+ }
+
+ public function rebuildSalesChartCache(): bool
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return false;
+ }
+ $this->ensureSalesChartSchema();
+
+ $latestHours = max(1, min(168, (int)Settings::get('store_sales_chart_latest_hours', '24')));
+ $now = new \DateTimeImmutable('now');
+ $ranges = [
+ 'latest' => [
+ 'from' => $now->modify('-' . $latestHours . ' hours')->format('Y-m-d H:i:s'),
+ 'to' => $now->format('Y-m-d H:i:s'),
+ ],
+ 'weekly' => [
+ 'from' => $now->modify('-7 days')->format('Y-m-d H:i:s'),
+ 'to' => $now->format('Y-m-d H:i:s'),
+ ],
+ 'all_time' => [
+ 'from' => null,
+ 'to' => $now->format('Y-m-d H:i:s'),
+ ],
+ ];
+
+ $maxRows = max(10, min(100, (int)Settings::get('store_sales_chart_limit', '10') * 2));
+
+ try {
+ $db->beginTransaction();
+ $delete = $db->prepare("DELETE FROM ac_store_sales_chart_cache WHERE chart_scope = :scope AND chart_window = :window");
+ $insert = $db->prepare("
+ INSERT INTO ac_store_sales_chart_cache
+ (chart_scope, chart_window, rank_no, item_key, item_label, units, revenue, snapshot_from, snapshot_to, updated_at)
+ VALUES (:chart_scope, :chart_window, :rank_no, :item_key, :item_label, :units, :revenue, :snapshot_from, :snapshot_to, :updated_at)
+ ");
+
+ foreach (['tracks', 'releases'] as $scope) {
+ foreach ($ranges as $window => $range) {
+ $delete->execute([':scope' => $scope, ':window' => $window]);
+ $rows = $scope === 'tracks'
+ ? $this->salesChartTrackRows($db, $range['from'], $maxRows)
+ : $this->salesChartReleaseRows($db, $range['from'], $maxRows);
+
+ $rank = 1;
+ foreach ($rows as $row) {
+ $itemKey = trim((string)($row['item_key'] ?? ''));
+ $itemLabel = trim((string)($row['item_label'] ?? ''));
+ if ($itemLabel === '') {
+ continue;
+ }
+ if ($itemKey === '') {
+ $itemKey = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $itemLabel) ?? '');
+ }
+ $insert->execute([
+ ':chart_scope' => $scope,
+ ':chart_window' => $window,
+ ':rank_no' => $rank,
+ ':item_key' => substr($itemKey, 0, 190),
+ ':item_label' => substr($itemLabel, 0, 255),
+ ':units' => max(0, (int)($row['units'] ?? 0)),
+ ':revenue' => round((float)($row['revenue'] ?? 0), 2),
+ ':snapshot_from' => $range['from'],
+ ':snapshot_to' => $range['to'],
+ ':updated_at' => $now->format('Y-m-d H:i:s'),
+ ]);
+ $rank++;
+ }
+ }
+ }
+
+ $db->commit();
+ return true;
+ } catch (Throwable $e) {
+ if ($db->inTransaction()) {
+ $db->rollBack();
+ }
+ return false;
+ }
+ }
+
+ private function salesChartTrackRows(PDO $db, ?string $from, int $limit): array
+ {
+ $sql = "
+ SELECT
+ CONCAT('track:', CAST(oi.item_id AS CHAR)) AS item_key,
+ COALESCE(NULLIF(MAX(rt.title), ''), MAX(oi.title_snapshot)) AS item_label,
+ SUM(oi.qty) AS units,
+ SUM(oi.line_total) AS revenue
+ FROM ac_store_order_items oi
+ JOIN ac_store_orders o ON o.id = oi.order_id
+ LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id
+ WHERE o.status = 'paid'
+ AND oi.item_type = 'track'
+ ";
+ if ($from !== null) {
+ $sql .= " AND o.created_at >= :from ";
+ }
+ $sql .= "
+ GROUP BY oi.item_id
+ ORDER BY units DESC, revenue DESC, item_label ASC
+ LIMIT :lim
+ ";
+ $stmt = $db->prepare($sql);
+ if ($from !== null) {
+ $stmt->bindValue(':from', $from, PDO::PARAM_STR);
+ }
+ $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ private function salesChartReleaseRows(PDO $db, ?string $from, int $limit): array
+ {
+ $sql = "
+ SELECT
+ CONCAT('release:', CAST(COALESCE(ri.release_id, r.id, oi.item_id) AS CHAR)) AS item_key,
+ COALESCE(NULLIF(MAX(r.title), ''), NULLIF(MAX(rr.title), ''), MAX(oi.title_snapshot)) AS item_label,
+ SUM(oi.qty) AS units,
+ SUM(oi.line_total) AS revenue
+ FROM ac_store_order_items oi
+ JOIN ac_store_orders o ON o.id = oi.order_id
+ LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id
+ LEFT JOIN ac_releases rr ON oi.item_type = 'release' AND oi.item_id = rr.id
+ LEFT JOIN ac_releases r ON rt.release_id = r.id
+ LEFT JOIN (
+ SELECT id, id AS release_id FROM ac_releases
+ ) ri ON oi.item_type = 'release' AND oi.item_id = ri.id
+ WHERE o.status = 'paid'
+ ";
+ if ($from !== null) {
+ $sql .= " AND o.created_at >= :from ";
+ }
+ $sql .= "
+ GROUP BY COALESCE(ri.release_id, r.id, oi.item_id)
+ ORDER BY units DESC, revenue DESC, item_label ASC
+ LIMIT :lim
+ ";
+ $stmt = $db->prepare($sql);
+ if ($from !== null) {
+ $stmt->bindValue(':from', $from, PDO::PARAM_STR);
+ }
+ $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+
+ private function salesChartRows(string $scope, string $window, int $limit): array
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return [];
+ }
+ try {
+ $stmt = $db->prepare("
+ SELECT rank_no, item_label, units, revenue, snapshot_from, snapshot_to, updated_at
+ FROM ac_store_sales_chart_cache
+ WHERE chart_scope = :scope
+ AND chart_window = :window
+ ORDER BY rank_no ASC
+ LIMIT :lim
+ ");
+ $stmt->bindValue(':scope', $scope, PDO::PARAM_STR);
+ $stmt->bindValue(':window', $window, PDO::PARAM_STR);
+ $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
+ $stmt->execute();
+ return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+
+ private function salesChartLastRebuildAt(): ?string
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return null;
+ }
+ try {
+ $stmt = $db->query("SELECT MAX(updated_at) AS updated_at FROM ac_store_sales_chart_cache");
+ $row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
+ $value = trim((string)($row['updated_at'] ?? ''));
+ return $value !== '' ? $value : null;
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+
+ private function salesChartCronUrl(): string
+ {
+ $base = $this->baseUrl();
+ $key = trim((string)Settings::get('store_sales_chart_cron_key', ''));
+ if ($base === '' || $key === '') {
+ return '';
+ }
+ return $base . '/store/sales-chart/rebuild?key=' . rawurlencode($key);
+ }
+
+ private function salesChartCronCommand(): string
+ {
+ $url = $this->salesChartCronUrl();
+ $minutes = max(5, min(1440, (int)Settings::get('store_sales_chart_refresh_minutes', '180')));
+ $step = max(1, (int)floor($minutes / 60));
+ $prefix = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *';
+ if ($url === '') {
+ return '';
+ }
+ return $prefix . " /usr/bin/curl -fsS '" . $url . "' >/dev/null 2>&1";
+ }
+
+ private function ensureBundleSchema(): void
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return;
+ }
+ try {
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_bundles (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(190) NOT NULL,
+ slug VARCHAR(190) NOT NULL UNIQUE,
+ bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ currency CHAR(3) NOT NULL DEFAULT 'GBP',
+ purchase_label VARCHAR(120) NULL,
+ is_enabled TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_bundle_items (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ bundle_id INT UNSIGNED NOT NULL,
+ release_id INT UNSIGNED NOT NULL,
+ sort_order INT UNSIGNED NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE KEY uniq_bundle_release (bundle_id, release_id),
+ KEY idx_bundle_id (bundle_id),
+ KEY idx_release_id (release_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ } catch (Throwable $e) {
+ }
+ }
+
+ private function adminBundleRows(): array
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return [];
+ }
+ try {
+ $stmt = $db->query("
+ SELECT
+ b.id, b.name, b.slug, b.bundle_price, b.currency, b.purchase_label, b.is_enabled, b.created_at,
+ COUNT(bi.id) AS release_count
+ FROM ac_store_bundles b
+ LEFT JOIN ac_store_bundle_items bi ON bi.bundle_id = b.id
+ GROUP BY b.id
+ ORDER BY b.created_at DESC
+ LIMIT 300
+ ");
+ return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+
+ private function bundleReleaseOptions(): array
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return [];
+ }
+ try {
+ $stmt = $db->query("
+ SELECT id, title, slug, release_date
+ FROM ac_releases
+ ORDER BY release_date DESC, id DESC
+ LIMIT 1000
+ ");
+ $rows = $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
+ $options = [];
+ foreach ($rows as $row) {
+ $title = trim((string)($row['title'] ?? ''));
+ if ($title === '') {
+ continue;
+ }
+ $date = trim((string)($row['release_date'] ?? ''));
+ $options[] = [
+ 'id' => (int)($row['id'] ?? 0),
+ 'label' => $date !== '' ? ($title . ' (' . $date . ')') : $title,
+ ];
+ }
+ return $options;
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+
+ private function loadBundleForCart(PDO $db, int $bundleId): ?array
+ {
+ if ($bundleId <= 0) {
+ return null;
+ }
+ try {
+ $stmt = $db->prepare("
+ SELECT
+ b.id,
+ b.name,
+ b.bundle_price,
+ b.currency,
+ b.is_enabled,
+ COUNT(DISTINCT bi.release_id) AS release_count,
+ COUNT(DISTINCT t.id) AS track_count
+ FROM ac_store_bundles b
+ LEFT JOIN ac_store_bundle_items bi ON bi.bundle_id = b.id
+ LEFT JOIN ac_release_tracks t ON t.release_id = bi.release_id
+ LEFT JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1
+ WHERE b.id = :id
+ GROUP BY b.id, b.name, b.bundle_price, b.currency, b.is_enabled
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $bundleId]);
+ $bundle = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$bundle || (int)($bundle['is_enabled'] ?? 0) !== 1 || (float)($bundle['bundle_price'] ?? 0) <= 0) {
+ return null;
+ }
+
+ $coverStmt = $db->prepare("
+ SELECT r.cover_url
+ FROM ac_store_bundle_items bi
+ JOIN ac_releases r ON r.id = bi.release_id
+ WHERE bi.bundle_id = :bundle_id
+ AND r.is_published = 1
+ AND (r.release_date IS NULL OR r.release_date <= :today)
+ AND r.cover_url IS NOT NULL
+ AND r.cover_url <> ''
+ ORDER BY bi.sort_order ASC, bi.id ASC
+ LIMIT 1
+ ");
+ $coverStmt->execute([
+ ':bundle_id' => $bundleId,
+ ':today' => date('Y-m-d'),
+ ]);
+ $coverUrl = (string)($coverStmt->fetchColumn() ?: '');
+ $bundle['cover_url'] = $coverUrl;
+ $bundle['release_count'] = (int)($bundle['release_count'] ?? 0);
+ $bundle['track_count'] = (int)($bundle['track_count'] ?? 0);
+ return $bundle;
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+
+ private function ensureDiscountSchema(): void
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return;
+ }
+ try {
+ $db->exec("
+ CREATE TABLE IF NOT EXISTS ac_store_discount_codes (
+ id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ code VARCHAR(64) NOT NULL UNIQUE,
+ discount_type ENUM('percent','fixed') NOT NULL DEFAULT 'percent',
+ discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
+ max_uses INT UNSIGNED NOT NULL DEFAULT 0,
+ used_count INT UNSIGNED NOT NULL DEFAULT 0,
+ expires_at DATETIME NULL,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ ");
+ } catch (Throwable $e) {
+ }
+ }
+
+ private function adminDiscountRows(): array
+ {
+ $db = Database::get();
+ if (!($db instanceof PDO)) {
+ return [];
+ }
+ try {
+ $stmt = $db->query("
+ SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at
+ FROM ac_store_discount_codes
+ ORDER BY created_at DESC
+ LIMIT 300
+ ");
+ return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+
+ private function loadActiveDiscount(PDO $db, string $code): ?array
+ {
+ try {
+ $stmt = $db->prepare("
+ SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active
+ FROM ac_store_discount_codes
+ WHERE code = :code
+ LIMIT 1
+ ");
+ $stmt->execute([':code' => strtoupper(trim($code))]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if (!$row) {
+ return null;
+ }
+ if ((int)($row['is_active'] ?? 0) !== 1) {
+ return null;
+ }
+ $maxUses = (int)($row['max_uses'] ?? 0);
+ $used = (int)($row['used_count'] ?? 0);
+ if ($maxUses > 0 && $used >= $maxUses) {
+ return null;
+ }
+ $expires = trim((string)($row['expires_at'] ?? ''));
+ if ($expires !== '') {
+ try {
+ if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expires)) {
+ return null;
+ }
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+ return $row;
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+
+ private function slugify(string $value): string
+ {
+ $value = strtolower(trim($value));
+ $value = preg_replace('~[^a-z0-9]+~', '-', $value) ?? '';
+ $value = trim($value, '-');
+ return $value !== '' ? $value : 'bundle-' . substr(sha1((string)microtime(true)), 0, 8);
+ }
+
+ private function buildCartTotals(array $items, string $discountCode = ''): array
+ {
+ $totals = [
+ 'count' => 0,
+ 'subtotal' => 0.0,
+ 'discountable_subtotal' => 0.0,
+ 'discount_amount' => 0.0,
+ 'amount' => 0.0,
+ 'currency' => Settings::get('store_currency', 'GBP'),
+ 'discount_code' => '',
+ ];
+ foreach ($items as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $qty = max(1, (int)($item['qty'] ?? 1));
+ $price = (float)($item['price'] ?? 0);
+ $itemType = (string)($item['item_type'] ?? '');
+ $totals['count'] += $qty;
+ $totals['subtotal'] += ($price * $qty);
+ if ($itemType !== 'bundle') {
+ $totals['discountable_subtotal'] += ($price * $qty);
+ }
+ if (!empty($item['currency'])) {
+ $totals['currency'] = (string)$item['currency'];
+ }
+ }
+
+ $discountCode = strtoupper(trim($discountCode));
+ if ($discountCode !== '' && $totals['discountable_subtotal'] > 0) {
+ $db = Database::get();
+ if ($db instanceof PDO) {
+ $this->ensureDiscountSchema();
+ $discount = $this->loadActiveDiscount($db, $discountCode);
+ if ($discount) {
+ $discountType = (string)($discount['discount_type'] ?? 'percent');
+ $discountValue = (float)($discount['discount_value'] ?? 0);
+ if ($discountType === 'fixed') {
+ $totals['discount_amount'] = min($totals['discountable_subtotal'], max(0, round($discountValue, 2)));
+ } else {
+ $percent = min(100, max(0, $discountValue));
+ $totals['discount_amount'] = min($totals['discountable_subtotal'], round($totals['discountable_subtotal'] * ($percent / 100), 2));
+ }
+ $totals['discount_code'] = (string)($discount['code'] ?? '');
+ }
+ }
+ }
+
+ $totals['amount'] = max(0, round($totals['subtotal'] - $totals['discount_amount'], 2));
+ return $totals;
+ }
+
+ private function sendOrderEmail(string $to, string $orderNo, string $currency, float $total, array $items, string $status, string $downloadLinksHtml): void
+ {
+ if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
+ return;
+ }
+ $subjectTpl = (string)Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}');
+ $htmlTpl = (string)Settings::get('store_order_email_html', $this->defaultOrderEmailHtml());
+ $itemsHtml = $this->renderItemsHtml($items, $currency);
+ $siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore'));
+ $logoUrl = trim((string)Settings::get('store_email_logo_url', ''));
+ $logoHtml = $logoUrl !== ''
+ ? '
 . ')
'
+ : '';
+ if ($downloadLinksHtml === '') {
+ $downloadLinksHtml = $status === 'paid'
+ ? '
Your download links will be available in your account/downloads area.
'
+ : '
Download links are sent once payment is confirmed.
';
+ }
+ $map = [
+ '{{site_name}}' => htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8'),
+ '{{order_no}}' => htmlspecialchars($orderNo, ENT_QUOTES, 'UTF-8'),
+ '{{customer_email}}' => htmlspecialchars($to, ENT_QUOTES, 'UTF-8'),
+ '{{currency}}' => htmlspecialchars($currency, ENT_QUOTES, 'UTF-8'),
+ '{{total}}' => number_format($total, 2),
+ '{{status}}' => htmlspecialchars($status, ENT_QUOTES, 'UTF-8'),
+ '{{logo_url}}' => htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8'),
+ '{{logo_html}}' => $logoHtml,
+ '{{items_html}}' => $itemsHtml,
+ '{{download_links_html}}' => $downloadLinksHtml,
+ ];
+ $subject = strtr($subjectTpl, $map);
+ $html = strtr($htmlTpl, $map);
+ $mailSettings = [
+ 'smtp_host' => Settings::get('smtp_host', ''),
+ 'smtp_port' => Settings::get('smtp_port', '587'),
+ 'smtp_user' => Settings::get('smtp_user', ''),
+ 'smtp_pass' => Settings::get('smtp_pass', ''),
+ 'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
+ 'smtp_from_email' => Settings::get('smtp_from_email', ''),
+ 'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
+ ];
+ Mailer::send($to, $subject, $html, $mailSettings);
+ }
+
+ private function sanitizeOrderPrefix(string $prefix): string
+ {
+ $prefix = strtoupper(trim($prefix));
+ $prefix = preg_replace('/[^A-Z0-9-]+/', '-', $prefix) ?? 'AC-ORD';
+ $prefix = trim($prefix, '-');
+ if ($prefix === '') {
+ return 'AC-ORD';
+ }
+ return substr($prefix, 0, 20);
+ }
+
+ private function provisionDownloadTokens(PDO $db, int $orderId, string $email, string $status): string
+ {
+ if ($status !== 'paid') {
+ return '
Download links are sent once payment is confirmed.
';
+ }
+ $downloadLimit = max(1, (int)Settings::get('store_download_limit', '5'));
+ $expiryDays = max(1, (int)Settings::get('store_download_expiry_days', '30'));
+ $expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $expiryDays . ' days')->format('Y-m-d H:i:s');
+
+ try {
+ $itemStmt = $db->prepare("SELECT id, item_type, item_id, title_snapshot FROM ac_store_order_items WHERE order_id = :order_id ORDER BY id ASC");
+ $itemStmt->execute([':order_id' => $orderId]);
+ $orderItems = $itemStmt->fetchAll(PDO::FETCH_ASSOC);
+ if (!$orderItems) {
+ return '
No downloadable items in this order.
';
+ }
+
+ $tokenStmt = $db->prepare("
+ INSERT INTO ac_store_download_tokens
+ (order_id, order_item_id, file_id, email, token, download_limit, downloads_used, expires_at, created_at)
+ VALUES (:order_id, :order_item_id, :file_id, :email, :token, :download_limit, 0, :expires_at, NOW())
+ ");
+
+ $links = [];
+ foreach ($orderItems as $item) {
+ $type = (string)($item['item_type'] ?? '');
+ $itemId = (int)($item['item_id'] ?? 0);
+ $orderItemId = (int)($item['id'] ?? 0);
+ if ($itemId <= 0 || $orderItemId <= 0) {
+ continue;
+ }
+ $files = [];
+ if ($type === 'release') {
+ $trackIdsStmt = $db->prepare("
+ SELECT t.id
+ FROM ac_release_tracks t
+ JOIN ac_store_track_products sp ON sp.release_track_id = t.id
+ WHERE t.release_id = :release_id AND sp.is_enabled = 1
+ ORDER BY t.track_no ASC, t.id ASC
+ ");
+ $trackIdsStmt->execute([':release_id' => $itemId]);
+ $trackIds = array_map(static fn($r) => (int)($r['id'] ?? 0), $trackIdsStmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
+ $trackIds = array_values(array_filter($trackIds, static fn($id) => $id > 0));
+ if ($trackIds) {
+ $placeholders = implode(',', array_fill(0, count($trackIds), '?'));
+ $fileStmt = $db->prepare("
+ SELECT id, file_name
+ FROM ac_store_files
+ WHERE scope_type = 'track' AND scope_id IN ($placeholders) AND is_active = 1
+ ORDER BY id DESC
+ ");
+ $fileStmt->execute($trackIds);
+ $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+ } elseif ($type === 'bundle') {
+ $releaseStmt = $db->prepare("
+ SELECT release_id
+ FROM ac_store_bundle_items
+ WHERE bundle_id = :bundle_id
+ ORDER BY sort_order ASC, id ASC
+ ");
+ $releaseStmt->execute([':bundle_id' => $itemId]);
+ $releaseIds = array_map(static fn($r) => (int)($r['release_id'] ?? 0), $releaseStmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
+ $releaseIds = array_values(array_filter($releaseIds, static fn($id) => $id > 0));
+ if ($releaseIds) {
+ $placeholders = implode(',', array_fill(0, count($releaseIds), '?'));
+ $trackStmt = $db->prepare("
+ SELECT t.id
+ FROM ac_release_tracks t
+ JOIN ac_store_track_products sp ON sp.release_track_id = t.id
+ WHERE t.release_id IN ($placeholders)
+ AND sp.is_enabled = 1
+ ORDER BY t.track_no ASC, t.id ASC
+ ");
+ $trackStmt->execute($releaseIds);
+ $trackIds = array_map(static fn($r) => (int)($r['id'] ?? 0), $trackStmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
+ $trackIds = array_values(array_filter($trackIds, static fn($id) => $id > 0));
+ if ($trackIds) {
+ $trackPh = implode(',', array_fill(0, count($trackIds), '?'));
+ $fileStmt = $db->prepare("
+ SELECT id, file_name
+ FROM ac_store_files
+ WHERE scope_type = 'track' AND scope_id IN ($trackPh) AND is_active = 1
+ ORDER BY id DESC
+ ");
+ $fileStmt->execute($trackIds);
+ $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+ }
+ } else {
+ $fileStmt = $db->prepare("
+ SELECT id, file_name
+ FROM ac_store_files
+ WHERE scope_type = 'track' AND scope_id = :scope_id AND is_active = 1
+ ORDER BY id DESC
+ ");
+ $fileStmt->execute([':scope_id' => $itemId]);
+ $files = $fileStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
+ }
+ foreach ($files as $file) {
+ $fileId = (int)($file['id'] ?? 0);
+ if ($fileId <= 0) {
+ continue;
+ }
+ $token = bin2hex(random_bytes(24));
+ $tokenStmt->execute([
+ ':order_id' => $orderId,
+ ':order_item_id' => $orderItemId,
+ ':file_id' => $fileId,
+ ':email' => $email,
+ ':token' => $token,
+ ':download_limit' => $downloadLimit,
+ ':expires_at' => $expiresAt,
+ ]);
+ $label = $this->buildDownloadLabel($db, $fileId, (string)($item['title_snapshot'] ?? $file['file_name'] ?? 'Download'));
+ $links[] = [
+ 'url' => $this->baseUrl() . '/store/download?token=' . rawurlencode($token),
+ 'label' => $label,
+ ];
+ }
+ }
+ if (!$links) {
+ return '
No downloadable files attached yet.
';
+ }
+ $rows = [];
+ foreach ($links as $link) {
+ $rows[] = '
' . htmlspecialchars($link['label'], ENT_QUOTES, 'UTF-8') . '';
+ }
+ return '
Your Downloads
' . implode('', $rows) . '
';
+ } catch (Throwable $e) {
+ return '
Download links could not be generated yet.
';
+ }
+ }
+
+ private function renderItemsHtml(array $items, string $defaultCurrency): string
+ {
+ $rows = [];
+ foreach ($items as $item) {
+ if (!is_array($item)) {
+ continue;
+ }
+ $title = htmlspecialchars((string)($item['title'] ?? 'Item'), ENT_QUOTES, 'UTF-8');
+ $itemType = (string)($item['item_type'] ?? 'track');
+ $releaseCount = (int)($item['release_count'] ?? 0);
+ $trackCount = (int)($item['track_count'] ?? 0);
+ $qty = max(1, (int)($item['qty'] ?? 1));
+ $price = (float)($item['price'] ?? 0);
+ $currency = htmlspecialchars((string)($item['currency'] ?? $defaultCurrency), ENT_QUOTES, 'UTF-8');
+ $line = number_format($price * $qty, 2);
+ $metaHtml = '';
+ if ($itemType === 'bundle') {
+ $parts = [];
+ if ($releaseCount > 0) {
+ $parts[] = $releaseCount . ' release' . ($releaseCount === 1 ? '' : 's');
+ }
+ if ($trackCount > 0) {
+ $parts[] = $trackCount . ' track' . ($trackCount === 1 ? '' : 's');
+ }
+ if ($parts) {
+ $metaHtml = '
Includes ' . htmlspecialchars(implode(' - ', $parts), ENT_QUOTES, 'UTF-8') . '
';
+ }
+ }
+ $rows[] = '
'
+ . '| ' . $title . $metaHtml . ' | '
+ . '' . $currency . ' ' . $line . ' | '
+ . '
';
+ }
+ if (!$rows) {
+ return '
No items.
';
+ }
+ return '
' . implode('', $rows) . '
';
+ }
+
private function defaultOrderEmailHtml(): string
{
return '{{logo_html}}'
@@ -2868,522 +3914,758 @@ class StoreController
. '
Total: {{currency}} {{total}}
';
}
+ private function setAccountFlash(string $type, string $message): void
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $_SESSION['ac_store_flash_' . $type] = $message;
+ }
+
+ private function consumeAccountFlash(string $type): string
+ {
+ if (session_status() !== PHP_SESSION_ACTIVE) {
+ session_start();
+ }
+ $key = 'ac_store_flash_' . $type;
+ $value = (string)($_SESSION[$key] ?? '');
+ unset($_SESSION[$key]);
+ return $value;
+ }
+
private function safeReturnUrl(string $url): string
{
if ($url === '' || $url[0] !== '/') {
return '/releases';
- }
- if (str_starts_with($url, '//')) {
- return '/releases';
- }
- return $url;
- }
-
- private function baseUrl(): string
- {
- $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443');
- $scheme = $https ? 'https' : 'http';
- $host = (string)($_SERVER['HTTP_HOST'] ?? '');
- if ($host === '') {
- return '';
- }
- return $scheme . '://' . $host;
- }
-
- private function isItemReleased(PDO $db, string $itemType, int $itemId): bool
- {
- if ($itemId <= 0) {
- return false;
- }
- try {
- if ($itemType === 'release') {
- $stmt = $db->prepare("
- SELECT 1
- FROM ac_releases
- WHERE id = :id
- AND is_published = 1
- AND (release_date IS NULL OR release_date <= :today)
- LIMIT 1
- ");
- $stmt->execute([
- ':id' => $itemId,
- ':today' => date('Y-m-d'),
- ]);
- return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
- }
-
- $stmt = $db->prepare("
- SELECT 1
- FROM ac_release_tracks t
- JOIN ac_releases r ON r.id = t.release_id
- WHERE t.id = :id
- AND r.is_published = 1
- AND (r.release_date IS NULL OR r.release_date <= :today)
- LIMIT 1
- ");
- $stmt->execute([
- ':id' => $itemId,
- ':today' => date('Y-m-d'),
- ]);
- return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
- } catch (Throwable $e) {
- return false;
- }
- }
-
- private function logMailDebug(string $ref, array $payload): void
- {
- try {
- $dir = __DIR__ . '/../../storage/logs';
- if (!is_dir($dir)) {
- @mkdir($dir, 0755, true);
- }
- $file = $dir . '/store_mail.log';
- $line = '[' . date('c') . '] ' . $ref . ' ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL;
- @file_put_contents($file, $line, FILE_APPEND);
- } catch (Throwable $e) {
- }
- }
-
- private function clientIp(): string
- {
- $candidates = [
- (string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''),
- (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''),
- (string)($_SERVER['REMOTE_ADDR'] ?? ''),
- ];
- foreach ($candidates as $candidate) {
- if ($candidate === '') {
- continue;
- }
- $first = trim(explode(',', $candidate)[0] ?? '');
- if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) {
- return $first;
- }
- }
- return '';
- }
-
- private function loadCustomerIpHistory(PDO $db): array
- {
- $history = [];
- try {
- $stmt = $db->query("
- SELECT o.email, o.customer_ip AS ip_address, MAX(o.created_at) AS last_seen
- FROM ac_store_orders o
- WHERE o.customer_ip IS NOT NULL AND o.customer_ip <> ''
- GROUP BY o.email, o.customer_ip
- ");
- $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
- foreach ($rows as $row) {
- $email = strtolower(trim((string)($row['email'] ?? '')));
- $ip = trim((string)($row['ip_address'] ?? ''));
- $lastSeen = (string)($row['last_seen'] ?? '');
- if ($email === '' || $ip === '') {
- continue;
- }
- $history[$email][$ip] = $lastSeen;
- }
- } catch (Throwable $e) {
- }
-
- try {
- $stmt = $db->query("
- SELECT t.email, e.ip_address, MAX(e.downloaded_at) AS last_seen
- FROM ac_store_download_events e
- JOIN ac_store_download_tokens t ON t.id = e.token_id
- WHERE e.ip_address IS NOT NULL AND e.ip_address <> ''
- GROUP BY t.email, e.ip_address
- ");
- $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
- foreach ($rows as $row) {
- $email = strtolower(trim((string)($row['email'] ?? '')));
- $ip = trim((string)($row['ip_address'] ?? ''));
- $lastSeen = (string)($row['last_seen'] ?? '');
- if ($email === '' || $ip === '') {
- continue;
- }
- $existing = $history[$email][$ip] ?? '';
- if ($existing === '' || ($lastSeen !== '' && strcmp($lastSeen, $existing) > 0)) {
- $history[$email][$ip] = $lastSeen;
- }
- }
- } catch (Throwable $e) {
- }
-
- $result = [];
- foreach ($history as $email => $ips) {
- arsort($ips);
- $entries = [];
- foreach ($ips as $ip => $lastSeen) {
- $entries[] = [
- 'ip' => $ip,
- 'last_seen' => $lastSeen,
- ];
- if (count($entries) >= 5) {
- break;
- }
- }
- $result[$email] = $entries;
- }
- return $result;
- }
-
- private function upsertCustomerFromOrder(PDO $db, string $email, string $ip, string $userAgent, float $orderTotal): int
- {
- $email = strtolower(trim($email));
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
- return 0;
- }
- try {
- $sel = $db->prepare("SELECT id, orders_count, total_spent FROM ac_store_customers WHERE email = :email LIMIT 1");
- $sel->execute([':email' => $email]);
- $row = $sel->fetch(PDO::FETCH_ASSOC);
- if ($row) {
- $customerId = (int)($row['id'] ?? 0);
- $ordersCount = (int)($row['orders_count'] ?? 0);
- $totalSpent = (float)($row['total_spent'] ?? 0);
- $upd = $db->prepare("
- UPDATE ac_store_customers
- SET last_order_ip = :ip,
- last_order_user_agent = :ua,
- last_seen_at = NOW(),
- orders_count = :orders_count,
- total_spent = :total_spent,
- updated_at = NOW()
- WHERE id = :id
- ");
- $upd->execute([
- ':ip' => $ip !== '' ? $ip : null,
- ':ua' => $userAgent !== '' ? $userAgent : null,
- ':orders_count' => $ordersCount + 1,
- ':total_spent' => $totalSpent + $orderTotal,
- ':id' => $customerId,
- ]);
- return $customerId;
- }
-
- $ins = $db->prepare("
- INSERT INTO ac_store_customers
- (name, email, password_hash, is_active, last_order_ip, last_order_user_agent, last_seen_at, orders_count, total_spent, created_at, updated_at)
- VALUES (NULL, :email, NULL, 1, :ip, :ua, NOW(), 1, :total_spent, NOW(), NOW())
- ");
- $ins->execute([
- ':email' => $email,
- ':ip' => $ip !== '' ? $ip : null,
- ':ua' => $userAgent !== '' ? $userAgent : null,
- ':total_spent' => $orderTotal,
- ]);
- return (int)$db->lastInsertId();
- } catch (Throwable $e) {
- return 0;
- }
- }
-
- private function bumpDiscountUsage(PDO $db, string $code): void
- {
- $code = strtoupper(trim($code));
- if ($code === '') {
- return;
- }
- try {
- $stmt = $db->prepare("
- UPDATE ac_store_discount_codes
- SET used_count = used_count + 1, updated_at = NOW()
- WHERE code = :code
- ");
- $stmt->execute([':code' => $code]);
- } catch (Throwable $e) {
- }
- }
-
- private function isEnabledSetting($value): bool
- {
- if (is_bool($value)) {
- return $value;
- }
- if (is_int($value) || is_float($value)) {
- return (int)$value === 1;
- }
- $normalized = strtolower(trim((string)$value));
- return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
- }
-
- private function paypalTokenProbe(string $clientId, string $secret, bool $sandbox): array
- {
- $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
- $url = $base . '/v1/oauth2/token';
- $headers = [
- 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret),
- 'Content-Type: application/x-www-form-urlencoded',
- 'Accept: application/json',
- ];
- $body = 'grant_type=client_credentials';
-
- if (function_exists('curl_init')) {
- $ch = curl_init($url);
- if ($ch === false) {
- return ['ok' => false, 'error' => 'Unable to initialize cURL'];
- }
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
- curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- curl_setopt($ch, CURLOPT_TIMEOUT, 15);
- $response = (string)curl_exec($ch);
- $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $curlErr = curl_error($ch);
- curl_close($ch);
- if ($curlErr !== '') {
- return ['ok' => false, 'error' => 'PayPal test failed: ' . $curlErr];
- }
- $decoded = json_decode($response, true);
- if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) {
- return ['ok' => true];
- }
- $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : '';
- return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
- }
-
- $context = stream_context_create([
- 'http' => [
- 'method' => 'POST',
- 'header' => implode("\r\n", $headers),
- 'content' => $body,
- 'timeout' => 15,
- 'ignore_errors' => true,
- ],
- ]);
- $response = @file_get_contents($url, false, $context);
- if ($response === false) {
- return ['ok' => false, 'error' => 'PayPal test failed: network error'];
- }
- $statusLine = '';
- if (!empty($http_response_header[0])) {
- $statusLine = (string)$http_response_header[0];
- }
- preg_match('/\s(\d{3})\s/', $statusLine, $m);
- $httpCode = isset($m[1]) ? (int)$m[1] : 0;
- $decoded = json_decode($response, true);
- if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) {
- return ['ok' => true];
- }
- $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : '';
- return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
- }
-
- private function paypalCreateOrder(
- string $clientId,
- string $secret,
- bool $sandbox,
- string $currency,
- float $total,
- string $orderNo,
- string $returnUrl,
- string $cancelUrl
- ): array {
- $token = $this->paypalAccessToken($clientId, $secret, $sandbox);
- if (!($token['ok'] ?? false)) {
- return $token;
- }
-
- $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
- $payload = [
- 'intent' => 'CAPTURE',
- 'purchase_units' => [[
- 'reference_id' => $orderNo,
- 'description' => 'AudioCore order ' . $orderNo,
- 'custom_id' => $orderNo,
- 'amount' => [
- 'currency_code' => $currency,
- 'value' => number_format($total, 2, '.', ''),
- ],
- ]],
- 'application_context' => [
- 'return_url' => $returnUrl,
- 'cancel_url' => $cancelUrl,
- 'shipping_preference' => 'NO_SHIPPING',
- 'user_action' => 'PAY_NOW',
- ],
- ];
- $res = $this->paypalJsonRequest(
- $base . '/v2/checkout/orders',
- 'POST',
- $payload,
- (string)($token['access_token'] ?? '')
- );
- if (!($res['ok'] ?? false)) {
- return $res;
- }
- $body = is_array($res['body'] ?? null) ? $res['body'] : [];
- $orderId = (string)($body['id'] ?? '');
- $approvalUrl = '';
- foreach ((array)($body['links'] ?? []) as $link) {
- if ((string)($link['rel'] ?? '') === 'approve') {
- $approvalUrl = (string)($link['href'] ?? '');
- break;
- }
- }
- if ($orderId === '' || $approvalUrl === '') {
- return ['ok' => false, 'error' => 'PayPal create order response incomplete'];
- }
- return [
- 'ok' => true,
- 'order_id' => $orderId,
- 'approval_url' => $approvalUrl,
- ];
- }
-
- private function paypalCaptureOrder(string $clientId, string $secret, bool $sandbox, string $paypalOrderId): array
- {
- $token = $this->paypalAccessToken($clientId, $secret, $sandbox);
- if (!($token['ok'] ?? false)) {
- return $token;
- }
- $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
- $res = $this->paypalJsonRequest(
- $base . '/v2/checkout/orders/' . rawurlencode($paypalOrderId) . '/capture',
- 'POST',
- new \stdClass(),
- (string)($token['access_token'] ?? '')
- );
- if (!($res['ok'] ?? false)) {
- return $res;
- }
- $body = is_array($res['body'] ?? null) ? $res['body'] : [];
- $status = (string)($body['status'] ?? '');
- if ($status !== 'COMPLETED') {
- return ['ok' => false, 'error' => 'PayPal capture status: ' . ($status !== '' ? $status : 'unknown')];
- }
- $captureId = '';
- $purchaseUnits = (array)($body['purchase_units'] ?? []);
- if (!empty($purchaseUnits[0]['payments']['captures'][0]['id'])) {
- $captureId = (string)$purchaseUnits[0]['payments']['captures'][0]['id'];
- }
- return ['ok' => true, 'capture_id' => $captureId];
- }
-
- private function paypalRefundCapture(
- string $clientId,
- string $secret,
- bool $sandbox,
- string $captureId,
- string $currency,
- float $total
- ): array {
- $token = $this->paypalAccessToken($clientId, $secret, $sandbox);
- if (!($token['ok'] ?? false)) {
- return $token;
- }
- $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
- $payload = [
- 'amount' => [
- 'currency_code' => $currency,
- 'value' => number_format($total, 2, '.', ''),
- ],
- ];
- $res = $this->paypalJsonRequest(
- $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund',
- 'POST',
- $payload,
- (string)($token['access_token'] ?? '')
- );
- if (!($res['ok'] ?? false)) {
- return $res;
- }
- $body = is_array($res['body'] ?? null) ? $res['body'] : [];
- $status = strtoupper((string)($body['status'] ?? ''));
- if (!in_array($status, ['COMPLETED', 'PENDING'], true)) {
- return ['ok' => false, 'error' => 'PayPal refund status: ' . ($status !== '' ? $status : 'unknown')];
- }
- return ['ok' => true];
- }
-
- private function paypalAccessToken(string $clientId, string $secret, bool $sandbox): array
- {
- $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
- $url = $base . '/v1/oauth2/token';
- $headers = [
- 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret),
- 'Content-Type: application/x-www-form-urlencoded',
- 'Accept: application/json',
- ];
- $body = 'grant_type=client_credentials';
- if (function_exists('curl_init')) {
- $ch = curl_init($url);
- if ($ch === false) {
- return ['ok' => false, 'error' => 'Unable to initialize cURL'];
- }
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_POST, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
- curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- curl_setopt($ch, CURLOPT_TIMEOUT, 20);
- $response = (string)curl_exec($ch);
- $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $curlErr = curl_error($ch);
- curl_close($ch);
- if ($curlErr !== '') {
- return ['ok' => false, 'error' => 'PayPal auth failed: ' . $curlErr];
- }
- $decoded = json_decode($response, true);
- if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) {
- return ['ok' => true, 'access_token' => (string)$decoded['access_token']];
- }
- $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : '';
- return ['ok' => false, 'error' => 'PayPal auth rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
- }
- return ['ok' => false, 'error' => 'cURL is required for PayPal checkout'];
- }
-
- private function paypalJsonRequest(string $url, string $method, $payload, string $accessToken): array
- {
- if (!function_exists('curl_init')) {
- return ['ok' => false, 'error' => 'cURL is required for PayPal checkout'];
- }
- $ch = curl_init($url);
- if ($ch === false) {
- return ['ok' => false, 'error' => 'Unable to initialize cURL'];
- }
- $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
- curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
- curl_setopt($ch, CURLOPT_HTTPHEADER, [
- 'Authorization: Bearer ' . $accessToken,
- 'Content-Type: application/json',
- 'Accept: application/json',
- ]);
- curl_setopt($ch, CURLOPT_TIMEOUT, 25);
- $response = (string)curl_exec($ch);
- $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $curlErr = curl_error($ch);
- curl_close($ch);
- if ($curlErr !== '') {
- return ['ok' => false, 'error' => 'PayPal request failed: ' . $curlErr];
- }
- $decoded = json_decode($response, true);
- if ($httpCode >= 200 && $httpCode < 300) {
- return ['ok' => true, 'body' => is_array($decoded) ? $decoded : []];
- }
- $err = is_array($decoded) ? (string)($decoded['message'] ?? $decoded['name'] ?? '') : '';
- return ['ok' => false, 'error' => 'PayPal API error (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
- }
-
- private function orderItemsForEmail(PDO $db, int $orderId): array
- {
- try {
- $stmt = $db->prepare("
- SELECT title_snapshot AS title, unit_price_snapshot AS price, qty, currency_snapshot AS currency
- FROM ac_store_order_items
- WHERE order_id = :order_id
- ORDER BY id ASC
- ");
- $stmt->execute([':order_id' => $orderId]);
- $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
- return is_array($rows) ? $rows : [];
- } catch (Throwable $e) {
- return [];
- }
- }
-}
+ }
+ if (str_starts_with($url, '//')) {
+ return '/releases';
+ }
+ return $url;
+ }
+
+ private function baseUrl(): string
+ {
+ $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ((string)($_SERVER['SERVER_PORT'] ?? '') === '443');
+ $scheme = $https ? 'https' : 'http';
+ $host = (string)($_SERVER['HTTP_HOST'] ?? '');
+ if ($host === '') {
+ return '';
+ }
+ return $scheme . '://' . $host;
+ }
+
+ private function buildDownloadLabel(PDO $db, int $fileId, string $fallback): string
+ {
+ if ($fileId <= 0) {
+ return $fallback !== '' ? $fallback : 'Download';
+ }
+
+ try {
+ $stmt = $db->prepare("
+ SELECT
+ f.file_name,
+ t.title AS track_title,
+ t.mix_name,
+ COALESCE(NULLIF(r.artist_name, ''), a.name, '') AS artist_name
+ FROM ac_store_files f
+ LEFT JOIN ac_release_tracks t
+ ON f.scope_type = 'track' AND f.scope_id = t.id
+ LEFT JOIN ac_releases r
+ ON t.release_id = r.id
+ LEFT JOIN ac_artists a
+ ON r.artist_id = a.id
+ WHERE f.id = :id
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $fileId]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC);
+ if ($row) {
+ $trackTitle = trim((string)($row['track_title'] ?? ''));
+ $mixName = trim((string)($row['mix_name'] ?? ''));
+ $artistName = trim((string)($row['artist_name'] ?? ''));
+ if ($trackTitle !== '' && $mixName !== '') {
+ $trackTitle .= ' (' . $mixName . ')';
+ }
+ if ($trackTitle !== '' && $artistName !== '') {
+ return $artistName . ' - ' . $trackTitle;
+ }
+ if ($trackTitle !== '') {
+ return $trackTitle;
+ }
+ }
+ } catch (Throwable $e) {
+ }
+
+ return $fallback !== '' ? $fallback : 'Download';
+ }
+
+ private function isItemReleased(PDO $db, string $itemType, int $itemId): bool
+ {
+ if ($itemId <= 0) {
+ return false;
+ }
+ try {
+ if ($itemType === 'bundle') {
+ $stmt = $db->prepare("
+ SELECT COUNT(*) AS total_rows,
+ SUM(CASE WHEN r.is_published = 1 AND (r.release_date IS NULL OR r.release_date <= :today) THEN 1 ELSE 0 END) AS live_rows
+ FROM ac_store_bundle_items bi
+ JOIN ac_releases r ON r.id = bi.release_id
+ WHERE bi.bundle_id = :id
+ ");
+ $stmt->execute([
+ ':id' => $itemId,
+ ':today' => date('Y-m-d'),
+ ]);
+ $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
+ $totalRows = (int)($row['total_rows'] ?? 0);
+ $liveRows = (int)($row['live_rows'] ?? 0);
+ return $totalRows > 0 && $totalRows === $liveRows;
+ }
+
+ if ($itemType === 'release') {
+ $stmt = $db->prepare("
+ SELECT 1
+ FROM ac_releases
+ WHERE id = :id
+ AND is_published = 1
+ AND (release_date IS NULL OR release_date <= :today)
+ LIMIT 1
+ ");
+ $stmt->execute([
+ ':id' => $itemId,
+ ':today' => date('Y-m-d'),
+ ]);
+ return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
+ }
+
+ $stmt = $db->prepare("
+ SELECT 1
+ FROM ac_release_tracks t
+ JOIN ac_releases r ON r.id = t.release_id
+ WHERE t.id = :id
+ AND r.is_published = 1
+ AND (r.release_date IS NULL OR r.release_date <= :today)
+ LIMIT 1
+ ");
+ $stmt->execute([
+ ':id' => $itemId,
+ ':today' => date('Y-m-d'),
+ ]);
+ return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ private function hasDownloadableFiles(PDO $db, string $itemType, int $itemId): bool
+ {
+ if ($itemId <= 0) {
+ return false;
+ }
+ try {
+ if ($itemType === 'bundle') {
+ $stmt = $db->prepare("
+ SELECT 1
+ FROM ac_store_bundle_items bi
+ JOIN ac_release_tracks t ON t.release_id = bi.release_id
+ JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1
+ JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = t.id AND f.is_active = 1
+ WHERE bi.bundle_id = :id
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $itemId]);
+ return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
+ }
+
+ if ($itemType === 'release') {
+ $stmt = $db->prepare("
+ SELECT 1
+ FROM ac_release_tracks t
+ JOIN ac_store_track_products sp ON sp.release_track_id = t.id AND sp.is_enabled = 1
+ JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = t.id AND f.is_active = 1
+ WHERE t.release_id = :id
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $itemId]);
+ return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
+ }
+
+ $stmt = $db->prepare("
+ SELECT 1
+ FROM ac_store_track_products sp
+ JOIN ac_store_files f ON f.scope_type = 'track' AND f.scope_id = sp.release_track_id AND f.is_active = 1
+ WHERE sp.release_track_id = :id
+ AND sp.is_enabled = 1
+ LIMIT 1
+ ");
+ $stmt->execute([':id' => $itemId]);
+ return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ private function logMailDebug(string $ref, array $payload): void
+ {
+ try {
+ $dir = __DIR__ . '/../../storage/logs';
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0755, true);
+ }
+ $file = $dir . '/store_mail.log';
+ $line = '[' . date('c') . '] ' . $ref . ' ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . PHP_EOL;
+ @file_put_contents($file, $line, FILE_APPEND);
+ } catch (Throwable $e) {
+ }
+ }
+
+ private function clientIp(): string
+ {
+ $candidates = [
+ (string)($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''),
+ (string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''),
+ (string)($_SERVER['REMOTE_ADDR'] ?? ''),
+ ];
+ foreach ($candidates as $candidate) {
+ if ($candidate === '') {
+ continue;
+ }
+ $first = trim(explode(',', $candidate)[0] ?? '');
+ if ($first !== '' && filter_var($first, FILTER_VALIDATE_IP)) {
+ return $first;
+ }
+ }
+ return '';
+ }
+
+ private function loadCustomerIpHistory(PDO $db): array
+ {
+ $history = [];
+ try {
+ $stmt = $db->query("
+ SELECT o.email, o.customer_ip AS ip_address, MAX(o.created_at) AS last_seen
+ FROM ac_store_orders o
+ WHERE o.customer_ip IS NOT NULL AND o.customer_ip <> ''
+ GROUP BY o.email, o.customer_ip
+ ");
+ $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
+ foreach ($rows as $row) {
+ $email = strtolower(trim((string)($row['email'] ?? '')));
+ $ip = trim((string)($row['ip_address'] ?? ''));
+ $lastSeen = (string)($row['last_seen'] ?? '');
+ if ($email === '' || $ip === '') {
+ continue;
+ }
+ $history[$email][$ip] = $lastSeen;
+ }
+ } catch (Throwable $e) {
+ }
+
+ try {
+ $stmt = $db->query("
+ SELECT t.email, e.ip_address, MAX(e.downloaded_at) AS last_seen
+ FROM ac_store_download_events e
+ JOIN ac_store_download_tokens t ON t.id = e.token_id
+ WHERE e.ip_address IS NOT NULL AND e.ip_address <> ''
+ GROUP BY t.email, e.ip_address
+ ");
+ $rows = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
+ foreach ($rows as $row) {
+ $email = strtolower(trim((string)($row['email'] ?? '')));
+ $ip = trim((string)($row['ip_address'] ?? ''));
+ $lastSeen = (string)($row['last_seen'] ?? '');
+ if ($email === '' || $ip === '') {
+ continue;
+ }
+ $existing = $history[$email][$ip] ?? '';
+ if ($existing === '' || ($lastSeen !== '' && strcmp($lastSeen, $existing) > 0)) {
+ $history[$email][$ip] = $lastSeen;
+ }
+ }
+ } catch (Throwable $e) {
+ }
+
+ $result = [];
+ foreach ($history as $email => $ips) {
+ arsort($ips);
+ $entries = [];
+ foreach ($ips as $ip => $lastSeen) {
+ $entries[] = [
+ 'ip' => $ip,
+ 'last_seen' => $lastSeen,
+ ];
+ if (count($entries) >= 5) {
+ break;
+ }
+ }
+ $result[$email] = $entries;
+ }
+ return $result;
+ }
+
+ private function upsertCustomerFromOrder(PDO $db, string $email, string $ip, string $userAgent, float $orderTotal): int
+ {
+ $email = strtolower(trim($email));
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return 0;
+ }
+ try {
+ $sel = $db->prepare("SELECT id, orders_count, total_spent FROM ac_store_customers WHERE email = :email LIMIT 1");
+ $sel->execute([':email' => $email]);
+ $row = $sel->fetch(PDO::FETCH_ASSOC);
+ if ($row) {
+ $customerId = (int)($row['id'] ?? 0);
+ $ordersCount = (int)($row['orders_count'] ?? 0);
+ $totalSpent = (float)($row['total_spent'] ?? 0);
+ $upd = $db->prepare("
+ UPDATE ac_store_customers
+ SET last_order_ip = :ip,
+ last_order_user_agent = :ua,
+ last_seen_at = NOW(),
+ orders_count = :orders_count,
+ total_spent = :total_spent,
+ updated_at = NOW()
+ WHERE id = :id
+ ");
+ $upd->execute([
+ ':ip' => $ip !== '' ? $ip : null,
+ ':ua' => $userAgent !== '' ? $userAgent : null,
+ ':orders_count' => $ordersCount + 1,
+ ':total_spent' => $totalSpent + $orderTotal,
+ ':id' => $customerId,
+ ]);
+ return $customerId;
+ }
+
+ $ins = $db->prepare("
+ INSERT INTO ac_store_customers
+ (name, email, password_hash, is_active, last_order_ip, last_order_user_agent, last_seen_at, orders_count, total_spent, created_at, updated_at)
+ VALUES (NULL, :email, NULL, 1, :ip, :ua, NOW(), 1, :total_spent, NOW(), NOW())
+ ");
+ $ins->execute([
+ ':email' => $email,
+ ':ip' => $ip !== '' ? $ip : null,
+ ':ua' => $userAgent !== '' ? $userAgent : null,
+ ':total_spent' => $orderTotal,
+ ]);
+ return (int)$db->lastInsertId();
+ } catch (Throwable $e) {
+ return 0;
+ }
+ }
+
+ private function bumpDiscountUsage(PDO $db, string $code): void
+ {
+ $code = strtoupper(trim($code));
+ if ($code === '') {
+ return;
+ }
+ try {
+ $stmt = $db->prepare("
+ UPDATE ac_store_discount_codes
+ SET used_count = used_count + 1, updated_at = NOW()
+ WHERE code = :code
+ ");
+ $stmt->execute([':code' => $code]);
+ } catch (Throwable $e) {
+ }
+ }
+
+ private function isEnabledSetting($value): bool
+ {
+ if (is_bool($value)) {
+ return $value;
+ }
+ if (is_int($value) || is_float($value)) {
+ return (int)$value === 1;
+ }
+ $normalized = strtolower(trim((string)$value));
+ return in_array($normalized, ['1', 'true', 'yes', 'on'], true);
+ }
+
+ private function truthy($value): bool
+ {
+ return $this->isEnabledSetting($value);
+ }
+
+ private function requestPayload(): array
+ {
+ if ($_POST) {
+ return is_array($_POST) ? $_POST : [];
+ }
+ $raw = file_get_contents('php://input');
+ if (!is_string($raw) || trim($raw) === '') {
+ return [];
+ }
+ $decoded = json_decode($raw, true);
+ return is_array($decoded) ? $decoded : [];
+ }
+
+ private function paypalCardCapabilityProbe(string $clientId, string $secret, bool $sandbox): array
+ {
+ $token = $this->paypalGenerateClientToken($clientId, $secret, $sandbox);
+ if ($token['ok'] ?? false) {
+ return [
+ 'ok' => true,
+ 'status' => 'available',
+ 'message' => 'PayPal embedded card fields are available for this account.',
+ ];
+ }
+
+ return [
+ 'ok' => false,
+ 'status' => 'unavailable',
+ 'message' => (string)($token['error'] ?? 'Unable to generate a PayPal client token for card fields.'),
+ ];
+ }
+
+ private function paypalGenerateClientToken(string $clientId, string $secret, bool $sandbox): array
+ {
+ $token = $this->paypalAccessToken($clientId, $secret, $sandbox);
+ if (!($token['ok'] ?? false)) {
+ return $token;
+ }
+
+ $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ $res = $this->paypalJsonRequest(
+ $base . '/v1/identity/generate-token',
+ 'POST',
+ new \stdClass(),
+ (string)($token['access_token'] ?? '')
+ );
+ if (!($res['ok'] ?? false)) {
+ return [
+ 'ok' => false,
+ 'error' => (string)($res['error'] ?? 'Unable to generate a PayPal client token.'),
+ ];
+ }
+
+ $body = is_array($res['body'] ?? null) ? $res['body'] : [];
+ $clientToken = trim((string)($body['client_token'] ?? ''));
+ if ($clientToken === '') {
+ return [
+ 'ok' => false,
+ 'error' => 'PayPal did not return a client token for card fields.',
+ ];
+ }
+
+ return [
+ 'ok' => true,
+ 'client_token' => $clientToken,
+ ];
+ }
+
+ private function paypalTokenProbe(string $clientId, string $secret, bool $sandbox): array
+ {
+ $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ $url = $base . '/v1/oauth2/token';
+ $headers = [
+ 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret),
+ 'Content-Type: application/x-www-form-urlencoded',
+ 'Accept: application/json',
+ ];
+ $body = 'grant_type=client_credentials';
+
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ if ($ch === false) {
+ return ['ok' => false, 'error' => 'Unable to initialize cURL'];
+ }
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 15);
+ $response = (string)curl_exec($ch);
+ $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlErr = curl_error($ch);
+ curl_close($ch);
+ if ($curlErr !== '') {
+ return ['ok' => false, 'error' => 'PayPal test failed: ' . $curlErr];
+ }
+ $decoded = json_decode($response, true);
+ if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) {
+ return ['ok' => true];
+ }
+ $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : '';
+ return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
+ }
+
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'POST',
+ 'header' => implode("\r\n", $headers),
+ 'content' => $body,
+ 'timeout' => 15,
+ 'ignore_errors' => true,
+ ],
+ ]);
+ $response = @file_get_contents($url, false, $context);
+ if ($response === false) {
+ return ['ok' => false, 'error' => 'PayPal test failed: network error'];
+ }
+ $statusLine = '';
+ if (!empty($http_response_header[0])) {
+ $statusLine = (string)$http_response_header[0];
+ }
+ preg_match('/\s(\d{3})\s/', $statusLine, $m);
+ $httpCode = isset($m[1]) ? (int)$m[1] : 0;
+ $decoded = json_decode($response, true);
+ if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) {
+ return ['ok' => true];
+ }
+ $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : '';
+ return ['ok' => false, 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
+ }
+
+ private function paypalCreateOrder(
+ string $clientId,
+ string $secret,
+ bool $sandbox,
+ string $currency,
+ float $total,
+ string $orderNo,
+ string $returnUrl,
+ string $cancelUrl
+ ): array {
+ $token = $this->paypalAccessToken($clientId, $secret, $sandbox);
+ if (!($token['ok'] ?? false)) {
+ return $token;
+ }
+
+ $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ $payload = [
+ 'intent' => 'CAPTURE',
+ 'purchase_units' => [[
+ 'reference_id' => $orderNo,
+ 'description' => 'AudioCore order ' . $orderNo,
+ 'custom_id' => $orderNo,
+ 'amount' => [
+ 'currency_code' => $currency,
+ 'value' => number_format($total, 2, '.', ''),
+ ],
+ ]],
+ 'application_context' => [
+ 'return_url' => $returnUrl,
+ 'cancel_url' => $cancelUrl,
+ 'shipping_preference' => 'NO_SHIPPING',
+ 'user_action' => 'PAY_NOW',
+ ],
+ ];
+ $res = $this->paypalJsonRequest(
+ $base . '/v2/checkout/orders',
+ 'POST',
+ $payload,
+ (string)($token['access_token'] ?? '')
+ );
+ if (!($res['ok'] ?? false)) {
+ return $res;
+ }
+ $body = is_array($res['body'] ?? null) ? $res['body'] : [];
+ $orderId = (string)($body['id'] ?? '');
+ $approvalUrl = '';
+ foreach ((array)($body['links'] ?? []) as $link) {
+ if ((string)($link['rel'] ?? '') === 'approve') {
+ $approvalUrl = (string)($link['href'] ?? '');
+ break;
+ }
+ }
+ if ($orderId === '' || $approvalUrl === '') {
+ return ['ok' => false, 'error' => 'PayPal create order response incomplete'];
+ }
+ return [
+ 'ok' => true,
+ 'order_id' => $orderId,
+ 'approval_url' => $approvalUrl,
+ ];
+ }
+
+ private function paypalCaptureOrder(string $clientId, string $secret, bool $sandbox, string $paypalOrderId): array
+ {
+ $token = $this->paypalAccessToken($clientId, $secret, $sandbox);
+ if (!($token['ok'] ?? false)) {
+ return $token;
+ }
+ $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ $res = $this->paypalJsonRequest(
+ $base . '/v2/checkout/orders/' . rawurlencode($paypalOrderId) . '/capture',
+ 'POST',
+ new \stdClass(),
+ (string)($token['access_token'] ?? '')
+ );
+ if (!($res['ok'] ?? false)) {
+ return $res;
+ }
+ $body = is_array($res['body'] ?? null) ? $res['body'] : [];
+ $status = (string)($body['status'] ?? '');
+ if ($status !== 'COMPLETED') {
+ return ['ok' => false, 'error' => 'PayPal capture status: ' . ($status !== '' ? $status : 'unknown')];
+ }
+ $captureId = '';
+ $purchaseUnits = (array)($body['purchase_units'] ?? []);
+ if (!empty($purchaseUnits[0]['payments']['captures'][0]['id'])) {
+ $captureId = (string)$purchaseUnits[0]['payments']['captures'][0]['id'];
+ }
+ $breakdown = $this->paypalCaptureBreakdown($purchaseUnits);
+ return [
+ 'ok' => true,
+ 'capture_id' => $captureId,
+ 'payment_breakdown' => $breakdown,
+ ];
+ }
+
+ private function paypalCaptureBreakdown(array $purchaseUnits): array
+ {
+ $capture = (array)($purchaseUnits[0]['payments']['captures'][0] ?? []);
+ $breakdown = (array)($capture['seller_receivable_breakdown'] ?? []);
+ return [
+ 'gross' => $this->paypalMoneyValue((array)($breakdown['gross_amount'] ?? [])),
+ 'fee' => $this->paypalMoneyValue((array)($breakdown['paypal_fee'] ?? [])),
+ 'net' => $this->paypalMoneyValue((array)($breakdown['net_amount'] ?? [])),
+ 'currency' => $this->paypalMoneyCurrency((array)($breakdown['gross_amount'] ?? []), (array)($capture['amount'] ?? [])),
+ ];
+ }
+
+ private function paypalMoneyValue(array $money): ?float
+ {
+ $value = trim((string)($money['value'] ?? ''));
+ return $value !== '' ? (float)$value : null;
+ }
+
+ private function paypalMoneyCurrency(array ...$candidates): ?string
+ {
+ foreach ($candidates as $money) {
+ $currency = strtoupper(trim((string)($money['currency_code'] ?? '')));
+ if ($currency !== '') {
+ return $currency;
+ }
+ }
+ return null;
+ }
+
+ private function paypalRefundCapture(
+ string $clientId,
+ string $secret,
+ bool $sandbox,
+ string $captureId,
+ string $currency,
+ float $total
+ ): array {
+ $token = $this->paypalAccessToken($clientId, $secret, $sandbox);
+ if (!($token['ok'] ?? false)) {
+ return $token;
+ }
+ $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ $payload = [
+ 'amount' => [
+ 'currency_code' => $currency,
+ 'value' => number_format($total, 2, '.', ''),
+ ],
+ ];
+ $res = $this->paypalJsonRequest(
+ $base . '/v2/payments/captures/' . rawurlencode($captureId) . '/refund',
+ 'POST',
+ $payload,
+ (string)($token['access_token'] ?? '')
+ );
+ if (!($res['ok'] ?? false)) {
+ return $res;
+ }
+ $body = is_array($res['body'] ?? null) ? $res['body'] : [];
+ $status = strtoupper((string)($body['status'] ?? ''));
+ if (!in_array($status, ['COMPLETED', 'PENDING'], true)) {
+ return ['ok' => false, 'error' => 'PayPal refund status: ' . ($status !== '' ? $status : 'unknown')];
+ }
+ return ['ok' => true];
+ }
+
+ private function paypalAccessToken(string $clientId, string $secret, bool $sandbox): array
+ {
+ $base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
+ $url = $base . '/v1/oauth2/token';
+ $headers = [
+ 'Authorization: Basic ' . base64_encode($clientId . ':' . $secret),
+ 'Content-Type: application/x-www-form-urlencoded',
+ 'Accept: application/json',
+ ];
+ $body = 'grant_type=client_credentials';
+ if (function_exists('curl_init')) {
+ $ch = curl_init($url);
+ if ($ch === false) {
+ return ['ok' => false, 'error' => 'Unable to initialize cURL'];
+ }
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_POST, true);
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 20);
+ $response = (string)curl_exec($ch);
+ $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlErr = curl_error($ch);
+ curl_close($ch);
+ if ($curlErr !== '') {
+ return ['ok' => false, 'error' => 'PayPal auth failed: ' . $curlErr];
+ }
+ $decoded = json_decode($response, true);
+ if ($httpCode >= 200 && $httpCode < 300 && is_array($decoded) && !empty($decoded['access_token'])) {
+ return ['ok' => true, 'access_token' => (string)$decoded['access_token']];
+ }
+ $err = is_array($decoded) ? (string)($decoded['error_description'] ?? $decoded['error'] ?? '') : '';
+ return ['ok' => false, 'error' => 'PayPal auth rejected (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
+ }
+ return ['ok' => false, 'error' => 'cURL is required for PayPal checkout'];
+ }
+
+ private function paypalJsonRequest(string $url, string $method, $payload, string $accessToken): array
+ {
+ if (!function_exists('curl_init')) {
+ return ['ok' => false, 'error' => 'cURL is required for PayPal checkout'];
+ }
+ $ch = curl_init($url);
+ if ($ch === false) {
+ return ['ok' => false, 'error' => 'Unable to initialize cURL'];
+ }
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $json);
+ curl_setopt($ch, CURLOPT_HTTPHEADER, [
+ 'Authorization: Bearer ' . $accessToken,
+ 'Content-Type: application/json',
+ 'Accept: application/json',
+ ]);
+ curl_setopt($ch, CURLOPT_TIMEOUT, 25);
+ $response = (string)curl_exec($ch);
+ $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlErr = curl_error($ch);
+ curl_close($ch);
+ if ($curlErr !== '') {
+ return ['ok' => false, 'error' => 'PayPal request failed: ' . $curlErr];
+ }
+ $decoded = json_decode($response, true);
+ if ($httpCode >= 200 && $httpCode < 300) {
+ return ['ok' => true, 'body' => is_array($decoded) ? $decoded : []];
+ }
+ $err = is_array($decoded) ? (string)($decoded['message'] ?? $decoded['name'] ?? '') : '';
+ return ['ok' => false, 'error' => 'PayPal API error (' . $httpCode . ')' . ($err !== '' ? ': ' . $err : '')];
+ }
+
+ private function orderItemsForEmail(PDO $db, int $orderId): array
+ {
+ try {
+ $stmt = $db->prepare("
+ SELECT title_snapshot AS title, unit_price_snapshot AS price, qty, currency_snapshot AS currency
+ FROM ac_store_order_items
+ WHERE order_id = :order_id
+ ORDER BY id ASC
+ ");
+ $stmt->execute([':order_id' => $orderId]);
+ $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
+ return is_array($rows) ? $rows : [];
+ } catch (Throwable $e) {
+ return [];
+ }
+ }
+}
diff --git a/plugins/store/plugin.php b/plugins/store/plugin.php
index c297d1c..f656692 100644
--- a/plugins/store/plugin.php
+++ b/plugins/store/plugin.php
@@ -181,11 +181,15 @@ return function (Router $router): void {
$router->post('/cart/discount/apply', [$controller, 'cartApplyDiscount']);
$router->post('/cart/discount/remove', [$controller, 'cartClearDiscount']);
$router->get('/checkout', [$controller, 'checkoutIndex']);
+ $router->post('/checkout/card/start', [$controller, 'checkoutCardStart']);
+ $router->get('/checkout/card', [$controller, 'checkoutCard']);
$router->get('/account', [$controller, 'accountIndex']);
$router->post('/account/request-login', [$controller, 'accountRequestLogin']);
$router->get('/account/login', [$controller, 'accountLogin']);
$router->get('/account/logout', [$controller, 'accountLogout']);
$router->post('/checkout/place', [$controller, 'checkoutPlace']);
+ $router->post('/checkout/paypal/create-order', [$controller, 'checkoutPaypalCreateOrder']);
+ $router->post('/checkout/paypal/capture-order', [$controller, 'checkoutPaypalCaptureJson']);
$router->get('/checkout/paypal/return', [$controller, 'checkoutPaypalReturn']);
$router->get('/checkout/paypal/cancel', [$controller, 'checkoutPaypalCancel']);
$router->post('/checkout/sandbox', [$controller, 'checkoutSandbox']);
@@ -199,6 +203,8 @@ return function (Router $router): void {
$router->post('/admin/store/settings/rebuild-sales-chart', [$controller, 'adminRebuildSalesChart']);
$router->post('/admin/store/discounts/create', [$controller, 'adminDiscountCreate']);
$router->post('/admin/store/discounts/delete', [$controller, 'adminDiscountDelete']);
+ $router->post('/admin/store/bundles/create', [$controller, 'adminBundleCreate']);
+ $router->post('/admin/store/bundles/delete', [$controller, 'adminBundleDelete']);
$router->post('/admin/store/settings/test-email', [$controller, 'adminSendTestEmail']);
$router->post('/admin/store/settings/test-paypal', [$controller, 'adminTestPaypal']);
$router->get('/admin/store/customers', [$controller, 'adminCustomers']);
diff --git a/plugins/store/views/admin/customers.php b/plugins/store/views/admin/customers.php
index e0de2f7..294be1e 100644
--- a/plugins/store/views/admin/customers.php
+++ b/plugins/store/views/admin/customers.php
@@ -1,8 +1,11 @@
@@ -20,6 +23,9 @@ ob_start();
Settings
Orders
Customers
+
+ Sales Reports
+