3352 lines
144 KiB
PHP
3352 lines
144 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
namespace Plugins\Store;
|
|
|
|
use Core\Http\Response;
|
|
use Core\Services\Auth;
|
|
use Core\Services\Database;
|
|
use Core\Services\Mailer;
|
|
use Core\Services\Settings;
|
|
use Core\Views\View;
|
|
use PDO;
|
|
use Plugins\Store\Gateways\Gateways;
|
|
use Throwable;
|
|
|
|
class StoreController
|
|
{
|
|
private View $view;
|
|
|
|
public function __construct()
|
|
{
|
|
$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,
|
|
'total_customers' => 0,
|
|
];
|
|
$recentOrders = [];
|
|
$newCustomers = [];
|
|
|
|
if ($tablesReady) {
|
|
$db = Database::get();
|
|
if ($db instanceof PDO) {
|
|
try {
|
|
$stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders");
|
|
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
|
|
$stats['total_orders'] = (int)($row['c'] ?? 0);
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_orders WHERE status = 'paid'");
|
|
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
|
|
$stats['paid_orders'] = (int)($row['c'] ?? 0);
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("SELECT COALESCE(SUM(total), 0) AS revenue FROM ac_store_orders WHERE status = 'paid'");
|
|
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
|
|
$stats['total_revenue'] = (float)($row['revenue'] ?? 0);
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("SELECT COUNT(*) AS c FROM ac_store_customers");
|
|
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
|
|
$stats['total_customers'] = (int)($row['c'] ?? 0);
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("
|
|
SELECT order_no, email, status, currency, total, created_at
|
|
FROM ac_store_orders
|
|
ORDER BY created_at DESC
|
|
LIMIT 5
|
|
");
|
|
$recentOrders = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
|
} catch (Throwable $e) {
|
|
$recentOrders = [];
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->query("
|
|
SELECT name, email, is_active, created_at
|
|
FROM ac_store_customers
|
|
ORDER BY created_at DESC
|
|
LIMIT 5
|
|
");
|
|
$newCustomers = $stmt ? $stmt->fetchAll(PDO::FETCH_ASSOC) : [];
|
|
} catch (Throwable $e) {
|
|
$newCustomers = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Response($this->view->render('admin/index.php', [
|
|
'title' => 'Store',
|
|
'tables_ready' => $tablesReady,
|
|
'private_root' => Settings::get('store_private_root', $this->privateRoot()),
|
|
'private_root_ready' => $this->privateRootReady(),
|
|
'stats' => $stats,
|
|
'recent_orders' => $recentOrders,
|
|
'new_customers' => $newCustomers,
|
|
'currency' => Settings::get('store_currency', 'GBP'),
|
|
]));
|
|
}
|
|
|
|
public function adminSettings(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$this->ensureDiscountSchema();
|
|
$this->ensureSalesChartSchema();
|
|
$settings = $this->settingsPayload();
|
|
$gateways = [];
|
|
foreach (Gateways::all() as $gateway) {
|
|
$gateways[] = [
|
|
'key' => $gateway->key(),
|
|
'label' => $gateway->label(),
|
|
'enabled' => $gateway->isEnabled($settings),
|
|
];
|
|
}
|
|
|
|
return new Response($this->view->render('admin/settings.php', [
|
|
'title' => 'Store Settings',
|
|
'settings' => $settings,
|
|
'gateways' => $gateways,
|
|
'discounts' => $this->adminDiscountRows(),
|
|
'private_root_ready' => $this->privateRootReady(),
|
|
'tab' => (string)($_GET['tab'] ?? 'general'),
|
|
'error' => (string)($_GET['error'] ?? ''),
|
|
'saved' => (string)($_GET['saved'] ?? ''),
|
|
'chart_rows' => $this->salesChartRows(
|
|
(string)($settings['store_sales_chart_default_scope'] ?? 'tracks'),
|
|
(string)($settings['store_sales_chart_default_window'] ?? 'latest'),
|
|
max(1, min(30, (int)($settings['store_sales_chart_limit'] ?? '10')))
|
|
),
|
|
'chart_last_rebuild_at' => (string)$this->salesChartLastRebuildAt(),
|
|
'chart_cron_url' => $this->salesChartCronUrl(),
|
|
'chart_cron_cmd' => $this->salesChartCronCommand(),
|
|
]));
|
|
}
|
|
|
|
public function adminSaveSettings(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$current = $this->settingsPayload();
|
|
|
|
$currencyRaw = array_key_exists('store_currency', $_POST) ? (string)$_POST['store_currency'] : (string)($current['store_currency'] ?? 'GBP');
|
|
$currency = strtoupper(trim($currencyRaw));
|
|
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
|
|
$currency = 'GBP';
|
|
}
|
|
$privateRootRaw = array_key_exists('store_private_root', $_POST) ? (string)$_POST['store_private_root'] : (string)($current['store_private_root'] ?? $this->privateRoot());
|
|
$privateRoot = trim($privateRootRaw);
|
|
if ($privateRoot === '') {
|
|
$privateRoot = $this->privateRoot();
|
|
}
|
|
$downloadLimitRaw = array_key_exists('store_download_limit', $_POST) ? (string)$_POST['store_download_limit'] : (string)($current['store_download_limit'] ?? '5');
|
|
$downloadLimit = max(1, (int)$downloadLimitRaw);
|
|
$expiryDaysRaw = array_key_exists('store_download_expiry_days', $_POST) ? (string)$_POST['store_download_expiry_days'] : (string)($current['store_download_expiry_days'] ?? '30');
|
|
$expiryDays = max(1, (int)$expiryDaysRaw);
|
|
$orderPrefixRaw = array_key_exists('store_order_prefix', $_POST) ? (string)$_POST['store_order_prefix'] : (string)($current['store_order_prefix'] ?? 'AC-ORD');
|
|
$orderPrefix = $this->sanitizeOrderPrefix($orderPrefixRaw);
|
|
$testMode = array_key_exists('store_test_mode', $_POST) ? ((string)$_POST['store_test_mode'] === '1' ? '1' : '0') : (string)($current['store_test_mode'] ?? '1');
|
|
$stripeEnabled = array_key_exists('store_stripe_enabled', $_POST) ? ((string)$_POST['store_stripe_enabled'] === '1' ? '1' : '0') : (string)($current['store_stripe_enabled'] ?? '0');
|
|
$stripePublic = trim((string)(array_key_exists('store_stripe_public_key', $_POST) ? $_POST['store_stripe_public_key'] : ($current['store_stripe_public_key'] ?? '')));
|
|
$stripeSecret = trim((string)(array_key_exists('store_stripe_secret_key', $_POST) ? $_POST['store_stripe_secret_key'] : ($current['store_stripe_secret_key'] ?? '')));
|
|
$paypalEnabled = array_key_exists('store_paypal_enabled', $_POST) ? ((string)$_POST['store_paypal_enabled'] === '1' ? '1' : '0') : (string)($current['store_paypal_enabled'] ?? '0');
|
|
$paypalClientId = trim((string)(array_key_exists('store_paypal_client_id', $_POST) ? $_POST['store_paypal_client_id'] : ($current['store_paypal_client_id'] ?? '')));
|
|
$paypalSecret = trim((string)(array_key_exists('store_paypal_secret', $_POST) ? $_POST['store_paypal_secret'] : ($current['store_paypal_secret'] ?? '')));
|
|
$emailLogoUrl = trim((string)(array_key_exists('store_email_logo_url', $_POST) ? $_POST['store_email_logo_url'] : ($current['store_email_logo_url'] ?? '')));
|
|
$orderEmailSubject = trim((string)(array_key_exists('store_order_email_subject', $_POST) ? $_POST['store_order_email_subject'] : ($current['store_order_email_subject'] ?? 'Your AudioCore order {{order_no}}')));
|
|
$orderEmailHtml = trim((string)(array_key_exists('store_order_email_html', $_POST) ? $_POST['store_order_email_html'] : ($current['store_order_email_html'] ?? '')));
|
|
if ($orderEmailHtml === '') {
|
|
$orderEmailHtml = $this->defaultOrderEmailHtml();
|
|
}
|
|
$salesChartDefaultScope = strtolower(trim((string)(array_key_exists('store_sales_chart_default_scope', $_POST) ? $_POST['store_sales_chart_default_scope'] : ($current['store_sales_chart_default_scope'] ?? 'tracks'))));
|
|
if (!in_array($salesChartDefaultScope, ['tracks', 'releases'], true)) {
|
|
$salesChartDefaultScope = 'tracks';
|
|
}
|
|
$salesChartDefaultWindow = strtolower(trim((string)(array_key_exists('store_sales_chart_default_window', $_POST) ? $_POST['store_sales_chart_default_window'] : ($current['store_sales_chart_default_window'] ?? 'latest'))));
|
|
if (!in_array($salesChartDefaultWindow, ['latest', 'weekly', 'all_time'], true)) {
|
|
$salesChartDefaultWindow = 'latest';
|
|
}
|
|
$salesChartLimit = max(1, min(50, (int)(array_key_exists('store_sales_chart_limit', $_POST) ? $_POST['store_sales_chart_limit'] : ($current['store_sales_chart_limit'] ?? '10'))));
|
|
$latestHours = max(1, min(168, (int)(array_key_exists('store_sales_chart_latest_hours', $_POST) ? $_POST['store_sales_chart_latest_hours'] : ($current['store_sales_chart_latest_hours'] ?? '24'))));
|
|
$refreshMinutes = max(5, min(1440, (int)(array_key_exists('store_sales_chart_refresh_minutes', $_POST) ? $_POST['store_sales_chart_refresh_minutes'] : ($current['store_sales_chart_refresh_minutes'] ?? '180'))));
|
|
|
|
Settings::set('store_currency', $currency);
|
|
Settings::set('store_private_root', $privateRoot);
|
|
Settings::set('store_download_limit', (string)$downloadLimit);
|
|
Settings::set('store_download_expiry_days', (string)$expiryDays);
|
|
Settings::set('store_order_prefix', $orderPrefix);
|
|
Settings::set('store_test_mode', $testMode);
|
|
Settings::set('store_stripe_enabled', $stripeEnabled);
|
|
Settings::set('store_stripe_public_key', $stripePublic);
|
|
Settings::set('store_stripe_secret_key', $stripeSecret);
|
|
Settings::set('store_paypal_enabled', $paypalEnabled);
|
|
Settings::set('store_paypal_client_id', $paypalClientId);
|
|
Settings::set('store_paypal_secret', $paypalSecret);
|
|
Settings::set('store_email_logo_url', $emailLogoUrl);
|
|
Settings::set('store_order_email_subject', $orderEmailSubject !== '' ? $orderEmailSubject : 'Your AudioCore order {{order_no}}');
|
|
Settings::set('store_order_email_html', $orderEmailHtml);
|
|
Settings::set('store_sales_chart_default_scope', $salesChartDefaultScope);
|
|
Settings::set('store_sales_chart_default_window', $salesChartDefaultWindow);
|
|
Settings::set('store_sales_chart_limit', (string)$salesChartLimit);
|
|
Settings::set('store_sales_chart_latest_hours', (string)$latestHours);
|
|
Settings::set('store_sales_chart_refresh_minutes', (string)$refreshMinutes);
|
|
if (isset($_POST['store_sales_chart_regen_key'])) {
|
|
try {
|
|
Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24)));
|
|
} catch (Throwable $e) {
|
|
}
|
|
}
|
|
if (trim((string)Settings::get('store_sales_chart_cron_key', '')) === '') {
|
|
try {
|
|
Settings::set('store_sales_chart_cron_key', bin2hex(random_bytes(24)));
|
|
} catch (Throwable $e) {
|
|
}
|
|
}
|
|
$this->ensureSalesChartSchema();
|
|
|
|
if (!$this->ensurePrivateRoot($privateRoot)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?error=Unable+to+create+or+write+private+download+folder']);
|
|
}
|
|
|
|
$tab = trim((string)($_POST['tab'] ?? 'general'));
|
|
$tab = in_array($tab, ['general', 'payments', 'emails', 'discounts', 'sales_chart'], true) ? $tab : 'general';
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?saved=1&tab=' . rawurlencode($tab)]);
|
|
}
|
|
|
|
public function adminRebuildSalesChart(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
$this->ensureSalesChartSchema();
|
|
$ok = $this->rebuildSalesChartCache();
|
|
if (!$ok) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&error=Unable+to+rebuild+sales+chart']);
|
|
}
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=sales_chart&saved=1']);
|
|
}
|
|
|
|
public function salesChartCron(): Response
|
|
{
|
|
$this->ensureSalesChartSchema();
|
|
$expected = trim((string)Settings::get('store_sales_chart_cron_key', ''));
|
|
$provided = trim((string)($_GET['key'] ?? ''));
|
|
if ($expected === '' || !hash_equals($expected, $provided)) {
|
|
return new Response('Unauthorized', 401);
|
|
}
|
|
$ok = $this->rebuildSalesChartCache();
|
|
if (!$ok) {
|
|
return new Response('Sales chart rebuild failed', 500);
|
|
}
|
|
return new Response('OK');
|
|
}
|
|
|
|
public function adminDiscountCreate(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
$this->ensureDiscountSchema();
|
|
$code = strtoupper(trim((string)($_POST['code'] ?? '')));
|
|
$type = trim((string)($_POST['discount_type'] ?? 'percent'));
|
|
$value = (float)($_POST['discount_value'] ?? 0);
|
|
$maxUses = (int)($_POST['max_uses'] ?? 0);
|
|
$expiresAt = trim((string)($_POST['expires_at'] ?? ''));
|
|
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
|
|
|
if (!preg_match('/^[A-Z0-9_-]{3,32}$/', $code)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+code+format']);
|
|
}
|
|
if (!in_array($type, ['percent', 'fixed'], true)) {
|
|
$type = 'percent';
|
|
}
|
|
if ($value <= 0) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Discount+value+must+be+greater+than+0']);
|
|
}
|
|
if ($type === 'percent' && $value > 100) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Percent+cannot+exceed+100']);
|
|
}
|
|
if ($maxUses < 0) {
|
|
$maxUses = 0;
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Database+unavailable']);
|
|
}
|
|
try {
|
|
$stmt = $db->prepare("
|
|
INSERT INTO ac_store_discount_codes
|
|
(code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at, updated_at)
|
|
VALUES (:code, :discount_type, :discount_value, :max_uses, 0, :expires_at, :is_active, NOW(), NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
discount_type = VALUES(discount_type),
|
|
discount_value = VALUES(discount_value),
|
|
max_uses = VALUES(max_uses),
|
|
expires_at = VALUES(expires_at),
|
|
is_active = VALUES(is_active),
|
|
updated_at = NOW()
|
|
");
|
|
$stmt->execute([
|
|
':code' => $code,
|
|
':discount_type' => $type,
|
|
':discount_value' => $value,
|
|
':max_uses' => $maxUses,
|
|
':expires_at' => $expiresAt !== '' ? $expiresAt : null,
|
|
':is_active' => $isActive,
|
|
]);
|
|
} catch (Throwable $e) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+save+discount+code']);
|
|
}
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']);
|
|
}
|
|
|
|
public function adminDiscountDelete(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
$this->ensureDiscountSchema();
|
|
$id = (int)($_POST['id'] ?? 0);
|
|
if ($id <= 0) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Invalid+discount+id']);
|
|
}
|
|
$db = Database::get();
|
|
if ($db instanceof PDO) {
|
|
try {
|
|
$stmt = $db->prepare("DELETE FROM ac_store_discount_codes WHERE id = :id LIMIT 1");
|
|
$stmt->execute([':id' => $id]);
|
|
} catch (Throwable $e) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&error=Unable+to+delete+discount']);
|
|
}
|
|
}
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=discounts&saved=1']);
|
|
}
|
|
|
|
public function adminSendTestEmail(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
$to = trim((string)($_POST['test_email_to'] ?? ''));
|
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=Enter+a+valid+test+email']);
|
|
}
|
|
|
|
$subjectTpl = trim((string)($_POST['store_order_email_subject'] ?? Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}')));
|
|
$htmlTpl = trim((string)($_POST['store_order_email_html'] ?? Settings::get('store_order_email_html', $this->defaultOrderEmailHtml())));
|
|
if ($htmlTpl === '') {
|
|
$htmlTpl = $this->defaultOrderEmailHtml();
|
|
}
|
|
|
|
$mockItems = [
|
|
['title' => 'Demo Track One', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'],
|
|
['title' => 'Demo Track Two', 'price' => 1.49, 'qty' => 1, 'currency' => 'GBP'],
|
|
];
|
|
$siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore'));
|
|
$logoUrl = trim((string)Settings::get('store_email_logo_url', ''));
|
|
$logoHtml = $logoUrl !== ''
|
|
? '<img src="' . htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8') . '" alt="' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '" style="max-height:60px; width:auto;">'
|
|
: '';
|
|
$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}}' => '<p>Example download links appear here after payment.</p>',
|
|
];
|
|
$subject = strtr($subjectTpl !== '' ? $subjectTpl : 'Your AudioCore order {{order_no}}', $map);
|
|
$html = strtr($htmlTpl, $map);
|
|
$mailSettings = [
|
|
'smtp_host' => Settings::get('smtp_host', ''),
|
|
'smtp_port' => Settings::get('smtp_port', '587'),
|
|
'smtp_user' => Settings::get('smtp_user', ''),
|
|
'smtp_pass' => Settings::get('smtp_pass', ''),
|
|
'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
|
|
'smtp_from_email' => Settings::get('smtp_from_email', ''),
|
|
'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
|
|
];
|
|
$result = Mailer::send($to, $subject, $html, $mailSettings);
|
|
$ref = 'mailtest-' . date('YmdHis') . '-' . random_int(100, 999);
|
|
$this->logMailDebug($ref, [
|
|
'to' => $to,
|
|
'subject' => $subject,
|
|
'result' => $result,
|
|
]);
|
|
if (!($result['ok'] ?? false)) {
|
|
$msg = rawurlencode('Unable to send test email. Ref ' . $ref . ': ' . (string)($result['error'] ?? 'Unknown'));
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&error=' . $msg]);
|
|
}
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=emails&saved=1']);
|
|
}
|
|
|
|
public function adminTestPaypal(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$settings = $this->settingsPayload();
|
|
$clientId = trim((string)($_POST['store_paypal_client_id'] ?? ($settings['store_paypal_client_id'] ?? '')));
|
|
$secret = trim((string)($_POST['store_paypal_secret'] ?? ($settings['store_paypal_secret'] ?? '')));
|
|
if ($clientId === '' || $secret === '') {
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=Enter+PayPal+Client+ID+and+Secret+first']);
|
|
}
|
|
|
|
$probeMode = strtolower(trim((string)($_POST['paypal_probe_mode'] ?? 'live')));
|
|
$isSandbox = ($probeMode === 'sandbox');
|
|
$result = $this->paypalTokenProbe($clientId, $secret, $isSandbox);
|
|
if (!($result['ok'] ?? false)) {
|
|
$err = rawurlencode((string)($result['error'] ?? 'PayPal validation failed'));
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&error=' . $err]);
|
|
}
|
|
|
|
return new Response('', 302, ['Location' => '/admin/store/settings?tab=payments&saved=1&paypal_test=' . rawurlencode($isSandbox ? 'sandbox' : 'live')]);
|
|
}
|
|
|
|
public function adminCustomers(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$this->ensureAnalyticsSchema();
|
|
$q = trim((string)($_GET['q'] ?? ''));
|
|
$like = '%' . $q . '%';
|
|
|
|
$rows = [];
|
|
$db = Database::get();
|
|
if ($db instanceof PDO) {
|
|
try {
|
|
$sql = "
|
|
SELECT
|
|
c.id,
|
|
c.name,
|
|
c.email,
|
|
c.is_active,
|
|
c.created_at,
|
|
COALESCE(os.order_count, 0) AS order_count,
|
|
COALESCE(os.revenue, 0) AS revenue,
|
|
os.last_order_no,
|
|
os.last_order_id,
|
|
os.last_ip,
|
|
os.last_order_at
|
|
FROM ac_store_customers c
|
|
LEFT JOIN (
|
|
SELECT
|
|
o.email,
|
|
COUNT(*) AS order_count,
|
|
SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue,
|
|
MAX(o.created_at) AS last_order_at,
|
|
SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no,
|
|
SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id,
|
|
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip
|
|
FROM ac_store_orders o
|
|
GROUP BY o.email
|
|
) os ON os.email = c.email
|
|
";
|
|
if ($q !== '') {
|
|
$sql .= " WHERE c.email LIKE :q OR c.name LIKE :q OR os.last_order_no LIKE :q ";
|
|
}
|
|
$sql .= "
|
|
ORDER BY COALESCE(os.last_order_at, c.created_at) DESC
|
|
LIMIT 500
|
|
";
|
|
$stmt = $db->prepare($sql);
|
|
if ($q !== '') {
|
|
$stmt->execute([':q' => $like]);
|
|
} else {
|
|
$stmt->execute();
|
|
}
|
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
} catch (Throwable $e) {
|
|
$rows = [];
|
|
}
|
|
|
|
try {
|
|
$guestSql = "
|
|
SELECT
|
|
NULL AS id,
|
|
'' AS name,
|
|
o.email AS email,
|
|
1 AS is_active,
|
|
MIN(o.created_at) AS created_at,
|
|
COUNT(*) AS order_count,
|
|
SUM(CASE WHEN o.status = 'paid' THEN o.total ELSE 0 END) AS revenue,
|
|
SUBSTRING_INDEX(GROUP_CONCAT(o.order_no ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_no,
|
|
SUBSTRING_INDEX(GROUP_CONCAT(o.id ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_order_id,
|
|
SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(o.customer_ip, '') ORDER BY o.created_at DESC SEPARATOR ','), ',', 1) AS last_ip,
|
|
MAX(o.created_at) AS last_order_at
|
|
FROM ac_store_orders o
|
|
LEFT JOIN ac_store_customers c ON c.email = o.email
|
|
WHERE c.id IS NULL
|
|
";
|
|
if ($q !== '') {
|
|
$guestSql .= " AND (o.email LIKE :q OR o.order_no LIKE :q) ";
|
|
}
|
|
$guestSql .= "
|
|
GROUP BY o.email
|
|
ORDER BY MAX(o.created_at) DESC
|
|
LIMIT 500
|
|
";
|
|
$guestStmt = $db->prepare($guestSql);
|
|
if ($q !== '') {
|
|
$guestStmt->execute([':q' => $like]);
|
|
} else {
|
|
$guestStmt->execute();
|
|
}
|
|
$guests = $guestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
if ($guests) {
|
|
$rows = array_merge($rows, $guests);
|
|
}
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
if ($rows) {
|
|
$ipHistoryMap = $this->loadCustomerIpHistory($db);
|
|
foreach ($rows as &$row) {
|
|
$emailKey = strtolower(trim((string)($row['email'] ?? '')));
|
|
$row['ips'] = $ipHistoryMap[$emailKey] ?? [];
|
|
}
|
|
unset($row);
|
|
}
|
|
}
|
|
|
|
return new Response($this->view->render('admin/customers.php', [
|
|
'title' => 'Store Customers',
|
|
'customers' => $rows,
|
|
'currency' => Settings::get('store_currency', 'GBP'),
|
|
'q' => $q,
|
|
]));
|
|
}
|
|
|
|
public function adminOrders(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$this->ensureAnalyticsSchema();
|
|
$q = trim((string)($_GET['q'] ?? ''));
|
|
$like = '%' . $q . '%';
|
|
|
|
$rows = [];
|
|
$db = Database::get();
|
|
if ($db instanceof PDO) {
|
|
try {
|
|
$sql = "
|
|
SELECT id, order_no, email, status, currency, total, created_at, customer_ip
|
|
FROM ac_store_orders
|
|
";
|
|
if ($q !== '') {
|
|
$sql .= " WHERE order_no LIKE :q OR email LIKE :q OR customer_ip LIKE :q OR status LIKE :q ";
|
|
}
|
|
$sql .= "
|
|
ORDER BY created_at DESC
|
|
LIMIT 500
|
|
";
|
|
$stmt = $db->prepare($sql);
|
|
if ($q !== '') {
|
|
$stmt->execute([':q' => $like]);
|
|
} else {
|
|
$stmt->execute();
|
|
}
|
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
} catch (Throwable $e) {
|
|
$rows = [];
|
|
}
|
|
}
|
|
|
|
return new Response($this->view->render('admin/orders.php', [
|
|
'title' => 'Store Orders',
|
|
'orders' => $rows,
|
|
'q' => $q,
|
|
'saved' => (string)($_GET['saved'] ?? ''),
|
|
'error' => (string)($_GET['error'] ?? ''),
|
|
]));
|
|
}
|
|
|
|
public function adminOrderCreate(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$email = strtolower(trim((string)($_POST['email'] ?? '')));
|
|
$currency = strtoupper(trim((string)($_POST['currency'] ?? Settings::get('store_currency', 'GBP'))));
|
|
$total = (float)($_POST['total'] ?? 0);
|
|
$status = trim((string)($_POST['status'] ?? 'pending'));
|
|
$orderNo = trim((string)($_POST['order_no'] ?? ''));
|
|
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Enter+a+valid+email']);
|
|
}
|
|
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
|
|
$currency = 'GBP';
|
|
}
|
|
if (!in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) {
|
|
$status = 'pending';
|
|
}
|
|
if ($total < 0) {
|
|
$total = 0.0;
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
|
|
}
|
|
|
|
if ($orderNo === '') {
|
|
$prefix = $this->sanitizeOrderPrefix((string)Settings::get('store_order_prefix', 'AC-ORD'));
|
|
$orderNo = $prefix . '-' . date('YmdHis') . '-' . random_int(100, 999);
|
|
}
|
|
|
|
try {
|
|
$customerId = $this->upsertCustomerFromOrder($db, $email, $this->clientIp(), substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255), $total);
|
|
$stmt = $db->prepare("
|
|
INSERT INTO ac_store_orders
|
|
(order_no, customer_id, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, customer_user_agent, created_at, updated_at)
|
|
VALUES (:order_no, :customer_id, :email, :status, :currency, :subtotal, :total, :provider, :payment_ref, :customer_ip, :customer_user_agent, NOW(), NOW())
|
|
");
|
|
$stmt->execute([
|
|
':order_no' => $orderNo,
|
|
':customer_id' => $customerId > 0 ? $customerId : null,
|
|
':email' => $email,
|
|
':status' => $status,
|
|
':currency' => $currency,
|
|
':subtotal' => $total,
|
|
':total' => $total,
|
|
':provider' => 'manual',
|
|
':payment_ref' => null,
|
|
':customer_ip' => $this->clientIp(),
|
|
':customer_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+create+order']);
|
|
}
|
|
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?saved=created']);
|
|
}
|
|
|
|
public function adminOrderStatus(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$orderId = (int)($_POST['id'] ?? 0);
|
|
$status = trim((string)($_POST['status'] ?? ''));
|
|
if ($orderId <= 0 || !in_array($status, ['pending', 'paid', 'failed', 'refunded'], true)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+update']);
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->prepare("UPDATE ac_store_orders SET status = :status, updated_at = NOW() WHERE id = :id LIMIT 1");
|
|
$stmt->execute([
|
|
':status' => $status,
|
|
':id' => $orderId,
|
|
]);
|
|
$this->rebuildSalesChartCache();
|
|
} catch (Throwable $e) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+update+order']);
|
|
}
|
|
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?saved=status']);
|
|
}
|
|
|
|
public function adminOrderDelete(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$orderId = (int)($_POST['id'] ?? 0);
|
|
if ($orderId <= 0) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']);
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
|
|
}
|
|
|
|
try {
|
|
$db->beginTransaction();
|
|
$db->prepare("DELETE FROM ac_store_download_events WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
|
|
$db->prepare("DELETE FROM ac_store_download_tokens WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
|
|
$db->prepare("DELETE FROM ac_store_order_items WHERE order_id = :order_id")->execute([':order_id' => $orderId]);
|
|
$db->prepare("DELETE FROM ac_store_orders WHERE id = :id LIMIT 1")->execute([':id' => $orderId]);
|
|
$db->commit();
|
|
$this->rebuildSalesChartCache();
|
|
} catch (Throwable $e) {
|
|
if ($db->inTransaction()) {
|
|
$db->rollBack();
|
|
}
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+delete+order']);
|
|
}
|
|
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?saved=deleted']);
|
|
}
|
|
|
|
public function adminOrderRefund(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$orderId = (int)($_POST['id'] ?? 0);
|
|
if ($orderId <= 0) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Invalid+order+id']);
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Database+unavailable']);
|
|
}
|
|
|
|
try {
|
|
$stmt = $db->prepare("
|
|
SELECT id, status, payment_provider, payment_ref, currency, total
|
|
FROM ac_store_orders
|
|
WHERE id = :id
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([':id' => $orderId]);
|
|
$order = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
if (!$order) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Order+not+found']);
|
|
}
|
|
|
|
$status = (string)($order['status'] ?? '');
|
|
if ($status === 'refunded') {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']);
|
|
}
|
|
if ($status !== 'paid') {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Only+paid+orders+can+be+refunded']);
|
|
}
|
|
|
|
$provider = strtolower(trim((string)($order['payment_provider'] ?? '')));
|
|
$paymentRef = trim((string)($order['payment_ref'] ?? ''));
|
|
|
|
if ($provider === 'paypal') {
|
|
if ($paymentRef === '') {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Missing+PayPal+capture+reference']);
|
|
}
|
|
$clientId = trim((string)Settings::get('store_paypal_client_id', ''));
|
|
$secret = trim((string)Settings::get('store_paypal_secret', ''));
|
|
if ($clientId === '' || $secret === '') {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=PayPal+credentials+missing']);
|
|
}
|
|
$refund = $this->paypalRefundCapture(
|
|
$clientId,
|
|
$secret,
|
|
$this->isEnabledSetting(Settings::get('store_test_mode', '1')),
|
|
$paymentRef,
|
|
(string)($order['currency'] ?? 'GBP'),
|
|
(float)($order['total'] ?? 0)
|
|
);
|
|
if (!($refund['ok'] ?? false)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=' . rawurlencode((string)($refund['error'] ?? 'Refund failed'))]);
|
|
}
|
|
}
|
|
|
|
$upd = $db->prepare("UPDATE ac_store_orders SET status = 'refunded', updated_at = NOW() WHERE id = :id LIMIT 1");
|
|
$upd->execute([':id' => $orderId]);
|
|
$revoke = $db->prepare("
|
|
UPDATE ac_store_download_tokens
|
|
SET downloads_used = download_limit,
|
|
expires_at = NOW()
|
|
WHERE order_id = :order_id
|
|
");
|
|
$revoke->execute([':order_id' => $orderId]);
|
|
$this->rebuildSalesChartCache();
|
|
} catch (Throwable $e) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?error=Unable+to+refund+order']);
|
|
}
|
|
|
|
return new Response('', 302, ['Location' => '/admin/store/orders?saved=refunded']);
|
|
}
|
|
|
|
public function adminOrderView(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$this->ensureAnalyticsSchema();
|
|
|
|
$orderId = (int)($_GET['id'] ?? 0);
|
|
if ($orderId <= 0) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders']);
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders']);
|
|
}
|
|
|
|
$order = null;
|
|
$items = [];
|
|
$downloadsByItem = [];
|
|
$downloadEvents = [];
|
|
try {
|
|
$orderStmt = $db->prepare("
|
|
SELECT id, order_no, email, status, currency, subtotal, total, payment_provider, payment_ref, customer_ip, created_at, updated_at
|
|
FROM ac_store_orders
|
|
WHERE id = :id
|
|
LIMIT 1
|
|
");
|
|
$orderStmt->execute([':id' => $orderId]);
|
|
$order = $orderStmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
|
if (!$order) {
|
|
return new Response('', 302, ['Location' => '/admin/store/orders']);
|
|
}
|
|
|
|
$itemStmt = $db->prepare("
|
|
SELECT
|
|
oi.id,
|
|
oi.item_type,
|
|
oi.item_id,
|
|
oi.title_snapshot,
|
|
oi.unit_price_snapshot,
|
|
oi.currency_snapshot,
|
|
oi.qty,
|
|
oi.line_total,
|
|
oi.created_at,
|
|
t.id AS token_id,
|
|
t.download_limit,
|
|
t.downloads_used,
|
|
t.expires_at,
|
|
f.file_name,
|
|
f.file_url
|
|
FROM ac_store_order_items oi
|
|
LEFT JOIN ac_store_download_tokens t ON t.order_item_id = oi.id
|
|
LEFT JOIN ac_store_files f ON f.id = t.file_id
|
|
WHERE oi.order_id = :order_id
|
|
ORDER BY oi.id ASC
|
|
");
|
|
$itemStmt->execute([':order_id' => $orderId]);
|
|
$items = $itemStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
|
|
$eventStmt = $db->prepare("
|
|
SELECT
|
|
e.id,
|
|
e.order_item_id,
|
|
e.file_id,
|
|
e.ip_address,
|
|
e.user_agent,
|
|
e.downloaded_at,
|
|
f.file_name
|
|
FROM ac_store_download_events e
|
|
LEFT JOIN ac_store_files f ON f.id = e.file_id
|
|
WHERE e.order_id = :order_id
|
|
ORDER BY e.downloaded_at DESC
|
|
LIMIT 500
|
|
");
|
|
$eventStmt->execute([':order_id' => $orderId]);
|
|
$downloadEvents = $eventStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
foreach ($downloadEvents as $event) {
|
|
$key = (int)($event['order_item_id'] ?? 0);
|
|
if ($key <= 0) {
|
|
continue;
|
|
}
|
|
if (!isset($downloadsByItem[$key])) {
|
|
$downloadsByItem[$key] = [
|
|
'count' => 0,
|
|
'ips' => [],
|
|
];
|
|
}
|
|
$downloadsByItem[$key]['count']++;
|
|
$ip = trim((string)($event['ip_address'] ?? ''));
|
|
if ($ip !== '' && !in_array($ip, $downloadsByItem[$key]['ips'], true)) {
|
|
$downloadsByItem[$key]['ips'][] = $ip;
|
|
}
|
|
}
|
|
|
|
return new Response($this->view->render('admin/order.php', [
|
|
'title' => 'Order Detail',
|
|
'order' => $order,
|
|
'items' => $items,
|
|
'downloads_by_item' => $downloadsByItem,
|
|
'download_events' => $downloadEvents,
|
|
]));
|
|
}
|
|
|
|
public function adminInstall(): Response
|
|
{
|
|
if ($guard = $this->guard()) {
|
|
return $guard;
|
|
}
|
|
|
|
$db = Database::get();
|
|
if ($db instanceof PDO) {
|
|
try {
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_release_products (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
release_id INT UNSIGNED NOT NULL UNIQUE,
|
|
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
bundle_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
|
currency CHAR(3) NOT NULL DEFAULT 'GBP',
|
|
purchase_label VARCHAR(120) NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_track_products (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
release_track_id INT UNSIGNED NOT NULL UNIQUE,
|
|
is_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
|
track_price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
|
currency CHAR(3) NOT NULL DEFAULT 'GBP',
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_files (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
scope_type ENUM('release','track') NOT NULL,
|
|
scope_id INT UNSIGNED NOT NULL,
|
|
file_url VARCHAR(1024) NOT NULL,
|
|
file_name VARCHAR(255) NOT NULL,
|
|
file_size BIGINT UNSIGNED NULL,
|
|
mime_type VARCHAR(128) NULL,
|
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_customers (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
name VARCHAR(140) NULL,
|
|
email VARCHAR(190) NOT NULL UNIQUE,
|
|
password_hash VARCHAR(255) NULL,
|
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_orders (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
order_no VARCHAR(32) NOT NULL UNIQUE,
|
|
customer_id INT UNSIGNED NULL,
|
|
email VARCHAR(190) NOT NULL,
|
|
status ENUM('pending','paid','failed','refunded') NOT NULL DEFAULT 'pending',
|
|
currency CHAR(3) NOT NULL DEFAULT 'GBP',
|
|
subtotal DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
|
total DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
|
discount_code VARCHAR(64) NULL,
|
|
discount_amount DECIMAL(10,2) NULL,
|
|
payment_provider VARCHAR(40) NULL,
|
|
payment_ref VARCHAR(120) NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_order_items (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
order_id INT UNSIGNED NOT NULL,
|
|
item_type ENUM('release','track') NOT NULL,
|
|
item_id INT UNSIGNED NOT NULL,
|
|
title_snapshot VARCHAR(255) NOT NULL,
|
|
unit_price_snapshot DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
|
currency_snapshot CHAR(3) NOT NULL DEFAULT 'GBP',
|
|
qty INT UNSIGNED NOT NULL DEFAULT 1,
|
|
line_total DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_download_tokens (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
order_id INT UNSIGNED NOT NULL,
|
|
order_item_id INT UNSIGNED NOT NULL,
|
|
file_id INT UNSIGNED NOT NULL,
|
|
email VARCHAR(190) NOT NULL,
|
|
token VARCHAR(96) NOT NULL UNIQUE,
|
|
download_limit INT UNSIGNED NOT NULL DEFAULT 5,
|
|
downloads_used INT UNSIGNED NOT NULL DEFAULT 0,
|
|
expires_at DATETIME NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_download_events (
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
token_id INT UNSIGNED NOT NULL,
|
|
order_id INT UNSIGNED NOT NULL,
|
|
order_item_id INT UNSIGNED NOT NULL,
|
|
file_id INT UNSIGNED NOT NULL,
|
|
email VARCHAR(190) NULL,
|
|
ip_address VARCHAR(64) NULL,
|
|
user_agent VARCHAR(255) NULL,
|
|
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
KEY idx_store_download_events_order (order_id),
|
|
KEY idx_store_download_events_item (order_item_id),
|
|
KEY idx_store_download_events_ip (ip_address)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
email VARCHAR(190) NOT NULL,
|
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
|
expires_at DATETIME NOT NULL,
|
|
used_at DATETIME NULL,
|
|
request_ip VARCHAR(64) NULL,
|
|
request_user_agent VARCHAR(255) NULL,
|
|
used_ip VARCHAR(64) NULL,
|
|
used_user_agent VARCHAR(255) NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
KEY idx_store_login_tokens_email (email),
|
|
KEY idx_store_login_tokens_expires (expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code");
|
|
} catch (Throwable $e) {
|
|
}
|
|
} catch (Throwable $e) {
|
|
}
|
|
}
|
|
|
|
$this->ensurePrivateRoot(Settings::get('store_private_root', $this->privateRoot()));
|
|
$this->ensureSalesChartSchema();
|
|
return new Response('', 302, ['Location' => '/admin/store']);
|
|
}
|
|
|
|
public function accountIndex(): Response
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
$this->ensureAnalyticsSchema();
|
|
|
|
$email = strtolower(trim((string)($_SESSION['ac_store_customer_email'] ?? '')));
|
|
$flash = (string)($_GET['message'] ?? '');
|
|
$error = (string)($_GET['error'] ?? '');
|
|
$orders = [];
|
|
$downloads = [];
|
|
|
|
if ($email !== '') {
|
|
$db = Database::get();
|
|
if ($db instanceof PDO) {
|
|
try {
|
|
$orderStmt = $db->prepare("
|
|
SELECT id, order_no, status, currency, total, created_at
|
|
FROM ac_store_orders
|
|
WHERE email = :email
|
|
ORDER BY created_at DESC
|
|
LIMIT 100
|
|
");
|
|
$orderStmt->execute([':email' => $email]);
|
|
$orders = $orderStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
} catch (Throwable $e) {
|
|
$orders = [];
|
|
}
|
|
|
|
try {
|
|
$downloadStmt = $db->prepare("
|
|
SELECT
|
|
o.order_no,
|
|
f.file_name,
|
|
t.download_limit,
|
|
t.downloads_used,
|
|
t.expires_at,
|
|
t.token
|
|
FROM ac_store_download_tokens t
|
|
JOIN ac_store_orders o ON o.id = t.order_id
|
|
JOIN ac_store_files f ON f.id = t.file_id
|
|
WHERE t.email = :email
|
|
ORDER BY t.created_at DESC
|
|
LIMIT 500
|
|
");
|
|
$downloadStmt->execute([':email' => $email]);
|
|
$rows = $downloadStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
foreach ($rows as $row) {
|
|
$token = trim((string)($row['token'] ?? ''));
|
|
if ($token === '') {
|
|
continue;
|
|
}
|
|
$downloads[] = [
|
|
'order_no' => (string)($row['order_no'] ?? ''),
|
|
'file_name' => (string)($row['file_name'] ?? 'Download'),
|
|
'download_limit' => (int)($row['download_limit'] ?? 0),
|
|
'downloads_used' => (int)($row['downloads_used'] ?? 0),
|
|
'expires_at' => (string)($row['expires_at'] ?? ''),
|
|
'url' => '/store/download?token=' . rawurlencode($token),
|
|
];
|
|
}
|
|
} catch (Throwable $e) {
|
|
$downloads = [];
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Response($this->view->render('site/account.php', [
|
|
'title' => 'Account',
|
|
'is_logged_in' => ($email !== ''),
|
|
'email' => $email,
|
|
'orders' => $orders,
|
|
'downloads' => $downloads,
|
|
'message' => $flash,
|
|
'error' => $error,
|
|
'download_limit' => (int)Settings::get('store_download_limit', '5'),
|
|
'download_expiry_days' => (int)Settings::get('store_download_expiry_days', '30'),
|
|
]));
|
|
}
|
|
|
|
public function accountRequestLogin(): Response
|
|
{
|
|
$email = strtolower(trim((string)($_POST['email'] ?? '')));
|
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
return new Response('', 302, ['Location' => '/account?error=Enter+a+valid+email+address']);
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']);
|
|
}
|
|
$this->ensureAnalyticsSchema();
|
|
|
|
try {
|
|
// Rate limit token requests per email.
|
|
$limitStmt = $db->prepare("
|
|
SELECT COUNT(*) AS c
|
|
FROM ac_store_login_tokens
|
|
WHERE email = :email
|
|
AND created_at >= (NOW() - INTERVAL 10 MINUTE)
|
|
");
|
|
$limitStmt->execute([':email' => $email]);
|
|
$limitRow = $limitStmt->fetch(PDO::FETCH_ASSOC);
|
|
if ((int)($limitRow['c'] ?? 0) >= 5) {
|
|
return new Response('', 302, ['Location' => '/account?error=Too+many+login+requests.+Please+wait+10+minutes']);
|
|
}
|
|
|
|
// Send generic response even if no orders exist.
|
|
$orderStmt = $db->prepare("SELECT COUNT(*) AS c FROM ac_store_orders WHERE email = :email");
|
|
$orderStmt->execute([':email' => $email]);
|
|
$orderCount = (int)(($orderStmt->fetch(PDO::FETCH_ASSOC)['c'] ?? 0));
|
|
|
|
if ($orderCount > 0) {
|
|
$rawToken = bin2hex(random_bytes(24));
|
|
$tokenHash = hash('sha256', $rawToken);
|
|
$expiresAt = (new \DateTimeImmutable('now'))->modify('+15 minutes')->format('Y-m-d H:i:s');
|
|
|
|
$ins = $db->prepare("
|
|
INSERT INTO ac_store_login_tokens
|
|
(email, token_hash, expires_at, request_ip, request_user_agent, created_at)
|
|
VALUES (:email, :token_hash, :expires_at, :request_ip, :request_user_agent, NOW())
|
|
");
|
|
$ins->execute([
|
|
':email' => $email,
|
|
':token_hash' => $tokenHash,
|
|
':expires_at' => $expiresAt,
|
|
':request_ip' => $this->clientIp(),
|
|
':request_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
|
]);
|
|
|
|
$siteName = trim((string)Settings::get('site_title', ''));
|
|
if ($siteName === '') {
|
|
$siteName = 'AudioCore V1.5';
|
|
}
|
|
$loginUrl = $this->baseUrl() . '/account/login?token=' . rawurlencode($rawToken);
|
|
$subject = $siteName . ' account access link';
|
|
$html = '<p>Hello,</p>'
|
|
. '<p>Use this secure link to access your downloads:</p>'
|
|
. '<p><a href="' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '</a></p>'
|
|
. '<p>This link expires in 15 minutes and can only be used once.</p>';
|
|
$mailSettings = [
|
|
'smtp_host' => Settings::get('smtp_host', ''),
|
|
'smtp_port' => Settings::get('smtp_port', '587'),
|
|
'smtp_user' => Settings::get('smtp_user', ''),
|
|
'smtp_pass' => Settings::get('smtp_pass', ''),
|
|
'smtp_encryption' => Settings::get('smtp_encryption', 'tls'),
|
|
'smtp_from_email' => Settings::get('smtp_from_email', ''),
|
|
'smtp_from_name' => Settings::get('smtp_from_name', 'AudioCore'),
|
|
];
|
|
Mailer::send($email, $subject, $html, $mailSettings);
|
|
}
|
|
} catch (Throwable $e) {
|
|
return new Response('', 302, ['Location' => '/account?error=Unable+to+send+login+email+right+now']);
|
|
}
|
|
|
|
return new Response('', 302, ['Location' => '/account?message=If+we+found+orders+for+that+email%2C+a+login+link+has+been+sent']);
|
|
}
|
|
|
|
public function accountLogin(): Response
|
|
{
|
|
$token = trim((string)($_GET['token'] ?? ''));
|
|
if ($token === '') {
|
|
return new Response('', 302, ['Location' => '/account?error=Invalid+login+token']);
|
|
}
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/account?error=Account+login+service+is+currently+unavailable']);
|
|
}
|
|
$this->ensureAnalyticsSchema();
|
|
|
|
try {
|
|
$hash = hash('sha256', $token);
|
|
$stmt = $db->prepare("
|
|
SELECT id, email, expires_at, used_at
|
|
FROM ac_store_login_tokens
|
|
WHERE token_hash = :token_hash
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([':token_hash' => $hash]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) {
|
|
return new Response('', 302, ['Location' => '/account?error=Login+link+is+invalid']);
|
|
}
|
|
if (!empty($row['used_at'])) {
|
|
return new Response('', 302, ['Location' => '/account?error=Login+link+has+already+been+used']);
|
|
}
|
|
$expiresAt = (string)($row['expires_at'] ?? '');
|
|
if ($expiresAt !== '') {
|
|
if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) {
|
|
return new Response('', 302, ['Location' => '/account?error=Login+link+has+expired']);
|
|
}
|
|
}
|
|
|
|
$upd = $db->prepare("
|
|
UPDATE ac_store_login_tokens
|
|
SET used_at = NOW(), used_ip = :used_ip, used_user_agent = :used_user_agent
|
|
WHERE id = :id
|
|
");
|
|
$upd->execute([
|
|
':id' => (int)$row['id'],
|
|
':used_ip' => $this->clientIp(),
|
|
':used_user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
|
]);
|
|
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
$_SESSION['ac_store_customer_email'] = strtolower(trim((string)($row['email'] ?? '')));
|
|
} catch (Throwable $e) {
|
|
return new Response('', 302, ['Location' => '/account?error=Unable+to+complete+login']);
|
|
}
|
|
|
|
return new Response('', 302, ['Location' => '/account?message=Signed+in+successfully']);
|
|
}
|
|
|
|
public function accountLogout(): Response
|
|
{
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
unset($_SESSION['ac_store_customer_email']);
|
|
return new Response('', 302, ['Location' => '/account?message=You+have+been+signed+out']);
|
|
}
|
|
|
|
public function cartAdd(): Response
|
|
{
|
|
$itemType = trim((string)($_POST['item_type'] ?? 'track'));
|
|
if (!in_array($itemType, ['track', 'release'], true)) {
|
|
$itemType = 'track';
|
|
}
|
|
|
|
$itemId = (int)($_POST['item_id'] ?? 0);
|
|
$title = trim((string)($_POST['title'] ?? 'Item'));
|
|
$coverUrl = trim((string)($_POST['cover_url'] ?? ''));
|
|
$currency = strtoupper(trim((string)($_POST['currency'] ?? 'GBP')));
|
|
$price = (float)($_POST['price'] ?? 0);
|
|
$qty = max(1, (int)($_POST['qty'] ?? 1));
|
|
$returnUrl = trim((string)($_POST['return_url'] ?? '/releases'));
|
|
$itemTitle = $title !== '' ? $title : 'Item';
|
|
|
|
if ($itemId <= 0 || $price <= 0) {
|
|
return new Response('', 302, ['Location' => $this->safeReturnUrl($returnUrl)]);
|
|
}
|
|
if (!preg_match('/^[A-Z]{3}$/', $currency)) {
|
|
$currency = 'GBP';
|
|
}
|
|
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
$cart = $_SESSION['ac_cart'] ?? [];
|
|
if (!is_array($cart)) {
|
|
$cart = [];
|
|
}
|
|
|
|
$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
|
|
{
|
|
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']);
|
|
}
|
|
|
|
$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']);
|
|
}
|
|
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']);
|
|
}
|
|
|
|
$orderStmt = $db->prepare("
|
|
SELECT id, order_no, email, status, currency, total, discount_code
|
|
FROM ac_store_orders
|
|
WHERE payment_provider = 'paypal' AND payment_ref = :payment_ref
|
|
LIMIT 1
|
|
");
|
|
$orderStmt->execute([':payment_ref' => $paypalOrderId]);
|
|
$order = $orderStmt->fetch(PDO::FETCH_ASSOC);
|
|
if (!$order) {
|
|
return new Response('', 302, ['Location' => '/checkout?error=Order+not+found+for+PayPal+payment']);
|
|
}
|
|
|
|
$orderId = (int)($order['id'] ?? 0);
|
|
$orderNo = (string)($order['order_no'] ?? '');
|
|
$email = (string)($order['email'] ?? '');
|
|
$status = (string)($order['status'] ?? 'pending');
|
|
if ($orderId <= 0 || $orderNo === '') {
|
|
return new Response('', 302, ['Location' => '/checkout?error=Invalid+order+record']);
|
|
}
|
|
|
|
if ($status === 'paid') {
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
$_SESSION['ac_last_order_no'] = $orderNo;
|
|
$_SESSION['ac_cart'] = [];
|
|
unset($_SESSION['ac_discount_code']);
|
|
return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]);
|
|
}
|
|
|
|
$clientId = trim((string)Settings::get('store_paypal_client_id', ''));
|
|
$secret = trim((string)Settings::get('store_paypal_secret', ''));
|
|
if ($clientId === '' || $secret === '') {
|
|
return new Response('', 302, ['Location' => '/checkout?error=PayPal+credentials+missing']);
|
|
}
|
|
|
|
$capture = $this->paypalCaptureOrder(
|
|
$clientId,
|
|
$secret,
|
|
$this->isEnabledSetting(Settings::get('store_test_mode', '1')),
|
|
$paypalOrderId
|
|
);
|
|
if (!($capture['ok'] ?? false)) {
|
|
return new Response('', 302, ['Location' => '/checkout?error=' . rawurlencode((string)($capture['error'] ?? 'PayPal capture failed'))]);
|
|
}
|
|
|
|
try {
|
|
$captureRef = trim((string)($capture['capture_id'] ?? ''));
|
|
$upd = $db->prepare("
|
|
UPDATE ac_store_orders
|
|
SET status = 'paid', payment_ref = :payment_ref, updated_at = NOW()
|
|
WHERE id = :id
|
|
");
|
|
$upd->execute([
|
|
':id' => $orderId,
|
|
':payment_ref' => $captureRef !== '' ? $captureRef : $paypalOrderId,
|
|
]);
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
$itemsForEmail = $this->orderItemsForEmail($db, $orderId);
|
|
$downloadLinksHtml = $this->provisionDownloadTokens($db, $orderId, $email, 'paid');
|
|
$discountCode = trim((string)($order['discount_code'] ?? ''));
|
|
if ($discountCode !== '') {
|
|
$this->bumpDiscountUsage($db, $discountCode);
|
|
}
|
|
$this->rebuildSalesChartCache();
|
|
$this->sendOrderEmail(
|
|
$email,
|
|
$orderNo,
|
|
(string)($order['currency'] ?? 'GBP'),
|
|
(float)($order['total'] ?? 0),
|
|
$itemsForEmail,
|
|
'paid',
|
|
$downloadLinksHtml
|
|
);
|
|
|
|
if (session_status() !== PHP_SESSION_ACTIVE) {
|
|
session_start();
|
|
}
|
|
$_SESSION['ac_last_order_no'] = $orderNo;
|
|
$_SESSION['ac_cart'] = [];
|
|
unset($_SESSION['ac_discount_code']);
|
|
return new Response('', 302, ['Location' => '/checkout?success=1&order_no=' . rawurlencode($orderNo)]);
|
|
}
|
|
|
|
public function checkoutPaypalCancel(): Response
|
|
{
|
|
return new Response('', 302, ['Location' => '/checkout?error=PayPal+checkout+was+cancelled']);
|
|
}
|
|
|
|
public function download(): Response
|
|
{
|
|
$token = trim((string)($_GET['token'] ?? ''));
|
|
if ($token === '') {
|
|
return new Response('Invalid download token.', 400);
|
|
}
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return new Response('Download service unavailable.', 500);
|
|
}
|
|
$this->ensureAnalyticsSchema();
|
|
try {
|
|
$stmt = $db->prepare("
|
|
SELECT t.id, t.order_id, t.order_item_id, t.file_id, t.email, t.download_limit, t.downloads_used, t.expires_at,
|
|
f.file_url, f.file_name, f.mime_type, o.status AS order_status
|
|
FROM ac_store_download_tokens t
|
|
JOIN ac_store_files f ON f.id = t.file_id
|
|
JOIN ac_store_orders o ON o.id = t.order_id
|
|
WHERE t.token = :token
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([':token' => $token]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) {
|
|
return new Response('Download link is invalid.', 404);
|
|
}
|
|
$orderStatus = strtolower(trim((string)($row['order_status'] ?? '')));
|
|
if ($orderStatus !== 'paid') {
|
|
return new Response('Downloads are no longer available for this order.', 410);
|
|
}
|
|
|
|
$limit = (int)($row['download_limit'] ?? 0);
|
|
$used = (int)($row['downloads_used'] ?? 0);
|
|
if ($limit > 0 && $used >= $limit) {
|
|
return new Response('Download limit reached.', 410);
|
|
}
|
|
|
|
$expiresAt = (string)($row['expires_at'] ?? '');
|
|
if ($expiresAt !== '') {
|
|
try {
|
|
if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expiresAt)) {
|
|
return new Response('Download link expired.', 410);
|
|
}
|
|
} catch (Throwable $e) {
|
|
}
|
|
}
|
|
|
|
$relative = ltrim((string)($row['file_url'] ?? ''), '/');
|
|
if ($relative === '' || str_contains($relative, '..')) {
|
|
return new Response('Invalid file path.', 400);
|
|
}
|
|
$root = rtrim((string)Settings::get('store_private_root', $this->privateRoot()), '/');
|
|
$path = $root . '/' . $relative;
|
|
if (!is_file($path) || !is_readable($path)) {
|
|
return new Response('File not found.', 404);
|
|
}
|
|
|
|
$upd = $db->prepare("UPDATE ac_store_download_tokens SET downloads_used = downloads_used + 1 WHERE id = :id");
|
|
$upd->execute([':id' => (int)$row['id']]);
|
|
try {
|
|
$evt = $db->prepare("
|
|
INSERT INTO ac_store_download_events
|
|
(token_id, order_id, order_item_id, file_id, email, ip_address, user_agent, downloaded_at)
|
|
VALUES (:token_id, :order_id, :order_item_id, :file_id, :email, :ip_address, :user_agent, NOW())
|
|
");
|
|
$evt->execute([
|
|
':token_id' => (int)($row['id'] ?? 0),
|
|
':order_id' => (int)($row['order_id'] ?? 0),
|
|
':order_item_id' => (int)($row['order_item_id'] ?? 0),
|
|
':file_id' => (int)($row['file_id'] ?? 0),
|
|
':email' => (string)($row['email'] ?? ''),
|
|
':ip_address' => $this->clientIp(),
|
|
':user_agent' => substr((string)($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
|
|
]);
|
|
} catch (Throwable $e) {
|
|
}
|
|
|
|
$fileName = (string)($row['file_name'] ?? basename($path));
|
|
$mime = (string)($row['mime_type'] ?? '');
|
|
if ($mime === '') {
|
|
$mime = 'application/octet-stream';
|
|
}
|
|
header('Content-Type: ' . $mime);
|
|
header('Content-Length: ' . (string)filesize($path));
|
|
header('Content-Disposition: attachment; filename="' . str_replace('"', '', $fileName) . '"');
|
|
readfile($path);
|
|
exit;
|
|
} catch (Throwable $e) {
|
|
return new Response('Download failed.', 500);
|
|
}
|
|
}
|
|
|
|
private function guard(): ?Response
|
|
{
|
|
if (!Auth::check()) {
|
|
return new Response('', 302, ['Location' => '/admin/login']);
|
|
}
|
|
if (!Auth::hasRole(['admin', 'manager'])) {
|
|
return new Response('', 302, ['Location' => '/admin']);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function ensureAnalyticsSchema(): void
|
|
{
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return;
|
|
}
|
|
$this->ensureSalesChartSchema();
|
|
try {
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_download_events (
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
token_id INT UNSIGNED NOT NULL,
|
|
order_id INT UNSIGNED NOT NULL,
|
|
order_item_id INT UNSIGNED NOT NULL,
|
|
file_id INT UNSIGNED NOT NULL,
|
|
email VARCHAR(190) NULL,
|
|
ip_address VARCHAR(64) NULL,
|
|
user_agent VARCHAR(255) NULL,
|
|
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
KEY idx_store_download_events_order (order_id),
|
|
KEY idx_store_download_events_item (order_item_id),
|
|
KEY idx_store_download_events_ip (ip_address)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
email VARCHAR(190) NOT NULL,
|
|
token_hash CHAR(64) NOT NULL UNIQUE,
|
|
expires_at DATETIME NOT NULL,
|
|
used_at DATETIME NULL,
|
|
request_ip VARCHAR(64) NULL,
|
|
request_user_agent VARCHAR(255) NULL,
|
|
used_ip VARCHAR(64) NULL,
|
|
used_user_agent VARCHAR(255) NULL,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
KEY idx_store_login_tokens_email (email),
|
|
KEY idx_store_login_tokens_expires (expires_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_ip VARCHAR(64) NULL AFTER is_active");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_order_user_agent VARCHAR(255) NULL AFTER last_order_ip");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_customers ADD COLUMN last_seen_at DATETIME NULL AFTER last_order_user_agent");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_customers ADD COLUMN orders_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_seen_at");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_customers ADD COLUMN total_spent DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER orders_count");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total");
|
|
} catch (Throwable $e) {
|
|
}
|
|
try {
|
|
$db->exec("ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code");
|
|
} catch (Throwable $e) {
|
|
}
|
|
}
|
|
|
|
private function tablesReady(): bool
|
|
{
|
|
$db = Database::get();
|
|
if (!$db instanceof PDO) {
|
|
return false;
|
|
}
|
|
try {
|
|
$db->query("SELECT 1 FROM ac_store_release_products LIMIT 1");
|
|
$db->query("SELECT 1 FROM ac_store_track_products LIMIT 1");
|
|
$db->query("SELECT 1 FROM ac_store_files LIMIT 1");
|
|
$db->query("SELECT 1 FROM ac_store_orders LIMIT 1");
|
|
return true;
|
|
} catch (Throwable $e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function privateRoot(): string
|
|
{
|
|
return '/home/audiocore.site/private_downloads';
|
|
}
|
|
|
|
private function ensurePrivateRoot(string $path): bool
|
|
{
|
|
$path = rtrim($path, '/');
|
|
if ($path === '') {
|
|
return false;
|
|
}
|
|
if (!is_dir($path) && !mkdir($path, 0755, true)) {
|
|
return false;
|
|
}
|
|
if (!is_writable($path)) {
|
|
return false;
|
|
}
|
|
$tracks = $path . '/tracks';
|
|
if (!is_dir($tracks) && !mkdir($tracks, 0755, true)) {
|
|
return false;
|
|
}
|
|
return is_writable($tracks);
|
|
}
|
|
|
|
private function privateRootReady(): bool
|
|
{
|
|
$path = Settings::get('store_private_root', $this->privateRoot());
|
|
$path = rtrim($path, '/');
|
|
if ($path === '' || !is_dir($path) || !is_writable($path)) {
|
|
return false;
|
|
}
|
|
$tracks = $path . '/tracks';
|
|
return is_dir($tracks) && is_writable($tracks);
|
|
}
|
|
|
|
private function settingsPayload(): array
|
|
{
|
|
$cronKey = trim((string)Settings::get('store_sales_chart_cron_key', ''));
|
|
if ($cronKey === '') {
|
|
try {
|
|
$cronKey = bin2hex(random_bytes(24));
|
|
Settings::set('store_sales_chart_cron_key', $cronKey);
|
|
} catch (Throwable $e) {
|
|
$cronKey = '';
|
|
}
|
|
}
|
|
return [
|
|
'store_currency' => Settings::get('store_currency', 'GBP'),
|
|
'store_private_root' => Settings::get('store_private_root', $this->privateRoot()),
|
|
'store_download_limit' => Settings::get('store_download_limit', '5'),
|
|
'store_download_expiry_days' => Settings::get('store_download_expiry_days', '30'),
|
|
'store_order_prefix' => Settings::get('store_order_prefix', 'AC-ORD'),
|
|
'store_test_mode' => Settings::get('store_test_mode', '1'),
|
|
'store_stripe_enabled' => Settings::get('store_stripe_enabled', '0'),
|
|
'store_stripe_public_key' => Settings::get('store_stripe_public_key', ''),
|
|
'store_stripe_secret_key' => Settings::get('store_stripe_secret_key', ''),
|
|
'store_paypal_enabled' => Settings::get('store_paypal_enabled', '0'),
|
|
'store_paypal_client_id' => Settings::get('store_paypal_client_id', ''),
|
|
'store_paypal_secret' => Settings::get('store_paypal_secret', ''),
|
|
'store_email_logo_url' => Settings::get('store_email_logo_url', ''),
|
|
'store_order_email_subject' => Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}'),
|
|
'store_order_email_html' => Settings::get('store_order_email_html', $this->defaultOrderEmailHtml()),
|
|
'store_sales_chart_default_scope' => Settings::get('store_sales_chart_default_scope', 'tracks'),
|
|
'store_sales_chart_default_window' => Settings::get('store_sales_chart_default_window', 'latest'),
|
|
'store_sales_chart_limit' => Settings::get('store_sales_chart_limit', '10'),
|
|
'store_sales_chart_latest_hours' => Settings::get('store_sales_chart_latest_hours', '24'),
|
|
'store_sales_chart_refresh_minutes' => Settings::get('store_sales_chart_refresh_minutes', '180'),
|
|
'store_sales_chart_cron_key' => $cronKey,
|
|
];
|
|
}
|
|
|
|
private function ensureSalesChartSchema(): void
|
|
{
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return;
|
|
}
|
|
try {
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_sales_chart_cache (
|
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
chart_scope ENUM('tracks','releases') NOT NULL,
|
|
chart_window ENUM('latest','weekly','all_time') NOT NULL,
|
|
rank_no INT UNSIGNED NOT NULL,
|
|
item_key VARCHAR(190) NOT NULL,
|
|
item_label VARCHAR(255) NOT NULL,
|
|
units INT UNSIGNED NOT NULL DEFAULT 0,
|
|
revenue DECIMAL(12,2) NOT NULL DEFAULT 0.00,
|
|
snapshot_from DATETIME NULL,
|
|
snapshot_to DATETIME NULL,
|
|
updated_at DATETIME NOT NULL,
|
|
UNIQUE KEY uniq_sales_chart_rank (chart_scope, chart_window, rank_no),
|
|
KEY idx_sales_chart_item (chart_scope, chart_window, item_key)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
} catch (Throwable $e) {
|
|
}
|
|
}
|
|
|
|
public function rebuildSalesChartCache(): bool
|
|
{
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return false;
|
|
}
|
|
$this->ensureSalesChartSchema();
|
|
|
|
$latestHours = max(1, min(168, (int)Settings::get('store_sales_chart_latest_hours', '24')));
|
|
$now = new \DateTimeImmutable('now');
|
|
$ranges = [
|
|
'latest' => [
|
|
'from' => $now->modify('-' . $latestHours . ' hours')->format('Y-m-d H:i:s'),
|
|
'to' => $now->format('Y-m-d H:i:s'),
|
|
],
|
|
'weekly' => [
|
|
'from' => $now->modify('-7 days')->format('Y-m-d H:i:s'),
|
|
'to' => $now->format('Y-m-d H:i:s'),
|
|
],
|
|
'all_time' => [
|
|
'from' => null,
|
|
'to' => $now->format('Y-m-d H:i:s'),
|
|
],
|
|
];
|
|
|
|
$maxRows = max(10, min(100, (int)Settings::get('store_sales_chart_limit', '10') * 2));
|
|
|
|
try {
|
|
$db->beginTransaction();
|
|
$delete = $db->prepare("DELETE FROM ac_store_sales_chart_cache WHERE chart_scope = :scope AND chart_window = :window");
|
|
$insert = $db->prepare("
|
|
INSERT INTO ac_store_sales_chart_cache
|
|
(chart_scope, chart_window, rank_no, item_key, item_label, units, revenue, snapshot_from, snapshot_to, updated_at)
|
|
VALUES (:chart_scope, :chart_window, :rank_no, :item_key, :item_label, :units, :revenue, :snapshot_from, :snapshot_to, :updated_at)
|
|
");
|
|
|
|
foreach (['tracks', 'releases'] as $scope) {
|
|
foreach ($ranges as $window => $range) {
|
|
$delete->execute([':scope' => $scope, ':window' => $window]);
|
|
$rows = $scope === 'tracks'
|
|
? $this->salesChartTrackRows($db, $range['from'], $maxRows)
|
|
: $this->salesChartReleaseRows($db, $range['from'], $maxRows);
|
|
|
|
$rank = 1;
|
|
foreach ($rows as $row) {
|
|
$itemKey = trim((string)($row['item_key'] ?? ''));
|
|
$itemLabel = trim((string)($row['item_label'] ?? ''));
|
|
if ($itemLabel === '') {
|
|
continue;
|
|
}
|
|
if ($itemKey === '') {
|
|
$itemKey = strtolower(preg_replace('/[^a-z0-9]+/i', '-', $itemLabel) ?? '');
|
|
}
|
|
$insert->execute([
|
|
':chart_scope' => $scope,
|
|
':chart_window' => $window,
|
|
':rank_no' => $rank,
|
|
':item_key' => substr($itemKey, 0, 190),
|
|
':item_label' => substr($itemLabel, 0, 255),
|
|
':units' => max(0, (int)($row['units'] ?? 0)),
|
|
':revenue' => round((float)($row['revenue'] ?? 0), 2),
|
|
':snapshot_from' => $range['from'],
|
|
':snapshot_to' => $range['to'],
|
|
':updated_at' => $now->format('Y-m-d H:i:s'),
|
|
]);
|
|
$rank++;
|
|
}
|
|
}
|
|
}
|
|
|
|
$db->commit();
|
|
return true;
|
|
} catch (Throwable $e) {
|
|
if ($db->inTransaction()) {
|
|
$db->rollBack();
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function salesChartTrackRows(PDO $db, ?string $from, int $limit): array
|
|
{
|
|
$sql = "
|
|
SELECT
|
|
CONCAT('track:', CAST(oi.item_id AS CHAR)) AS item_key,
|
|
COALESCE(NULLIF(MAX(rt.title), ''), MAX(oi.title_snapshot)) AS item_label,
|
|
SUM(oi.qty) AS units,
|
|
SUM(oi.line_total) AS revenue
|
|
FROM ac_store_order_items oi
|
|
JOIN ac_store_orders o ON o.id = oi.order_id
|
|
LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id
|
|
WHERE o.status = 'paid'
|
|
AND oi.item_type = 'track'
|
|
";
|
|
if ($from !== null) {
|
|
$sql .= " AND o.created_at >= :from ";
|
|
}
|
|
$sql .= "
|
|
GROUP BY oi.item_id
|
|
ORDER BY units DESC, revenue DESC, item_label ASC
|
|
LIMIT :lim
|
|
";
|
|
$stmt = $db->prepare($sql);
|
|
if ($from !== null) {
|
|
$stmt->bindValue(':from', $from, PDO::PARAM_STR);
|
|
}
|
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
}
|
|
|
|
private function salesChartReleaseRows(PDO $db, ?string $from, int $limit): array
|
|
{
|
|
$sql = "
|
|
SELECT
|
|
CONCAT('release:', CAST(COALESCE(ri.release_id, r.id, oi.item_id) AS CHAR)) AS item_key,
|
|
COALESCE(NULLIF(MAX(r.title), ''), NULLIF(MAX(rr.title), ''), MAX(oi.title_snapshot)) AS item_label,
|
|
SUM(oi.qty) AS units,
|
|
SUM(oi.line_total) AS revenue
|
|
FROM ac_store_order_items oi
|
|
JOIN ac_store_orders o ON o.id = oi.order_id
|
|
LEFT JOIN ac_release_tracks rt ON oi.item_type = 'track' AND oi.item_id = rt.id
|
|
LEFT JOIN ac_releases rr ON oi.item_type = 'release' AND oi.item_id = rr.id
|
|
LEFT JOIN ac_releases r ON rt.release_id = r.id
|
|
LEFT JOIN (
|
|
SELECT id, id AS release_id FROM ac_releases
|
|
) ri ON oi.item_type = 'release' AND oi.item_id = ri.id
|
|
WHERE o.status = 'paid'
|
|
";
|
|
if ($from !== null) {
|
|
$sql .= " AND o.created_at >= :from ";
|
|
}
|
|
$sql .= "
|
|
GROUP BY COALESCE(ri.release_id, r.id, oi.item_id)
|
|
ORDER BY units DESC, revenue DESC, item_label ASC
|
|
LIMIT :lim
|
|
";
|
|
$stmt = $db->prepare($sql);
|
|
if ($from !== null) {
|
|
$stmt->bindValue(':from', $from, PDO::PARAM_STR);
|
|
}
|
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
}
|
|
|
|
private function salesChartRows(string $scope, string $window, int $limit): array
|
|
{
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return [];
|
|
}
|
|
try {
|
|
$stmt = $db->prepare("
|
|
SELECT rank_no, item_label, units, revenue, snapshot_from, snapshot_to, updated_at
|
|
FROM ac_store_sales_chart_cache
|
|
WHERE chart_scope = :scope
|
|
AND chart_window = :window
|
|
ORDER BY rank_no ASC
|
|
LIMIT :lim
|
|
");
|
|
$stmt->bindValue(':scope', $scope, PDO::PARAM_STR);
|
|
$stmt->bindValue(':window', $window, PDO::PARAM_STR);
|
|
$stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
|
|
$stmt->execute();
|
|
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
|
|
} catch (Throwable $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function salesChartLastRebuildAt(): ?string
|
|
{
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return null;
|
|
}
|
|
try {
|
|
$stmt = $db->query("SELECT MAX(updated_at) AS updated_at FROM ac_store_sales_chart_cache");
|
|
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
|
|
$value = trim((string)($row['updated_at'] ?? ''));
|
|
return $value !== '' ? $value : null;
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function salesChartCronUrl(): string
|
|
{
|
|
$base = $this->baseUrl();
|
|
$key = trim((string)Settings::get('store_sales_chart_cron_key', ''));
|
|
if ($base === '' || $key === '') {
|
|
return '';
|
|
}
|
|
return $base . '/store/sales-chart/rebuild?key=' . rawurlencode($key);
|
|
}
|
|
|
|
private function salesChartCronCommand(): string
|
|
{
|
|
$url = $this->salesChartCronUrl();
|
|
$minutes = max(5, min(1440, (int)Settings::get('store_sales_chart_refresh_minutes', '180')));
|
|
$step = max(1, (int)floor($minutes / 60));
|
|
$prefix = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *';
|
|
if ($url === '') {
|
|
return '';
|
|
}
|
|
return $prefix . " /usr/bin/curl -fsS '" . $url . "' >/dev/null 2>&1";
|
|
}
|
|
|
|
private function ensureDiscountSchema(): void
|
|
{
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return;
|
|
}
|
|
try {
|
|
$db->exec("
|
|
CREATE TABLE IF NOT EXISTS ac_store_discount_codes (
|
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
code VARCHAR(64) NOT NULL UNIQUE,
|
|
discount_type ENUM('percent','fixed') NOT NULL DEFAULT 'percent',
|
|
discount_value DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
|
max_uses INT UNSIGNED NOT NULL DEFAULT 0,
|
|
used_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
expires_at DATETIME NULL,
|
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
");
|
|
} catch (Throwable $e) {
|
|
}
|
|
}
|
|
|
|
private function adminDiscountRows(): array
|
|
{
|
|
$db = Database::get();
|
|
if (!($db instanceof PDO)) {
|
|
return [];
|
|
}
|
|
try {
|
|
$stmt = $db->query("
|
|
SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active, created_at
|
|
FROM ac_store_discount_codes
|
|
ORDER BY created_at DESC
|
|
LIMIT 300
|
|
");
|
|
return $stmt ? ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: []) : [];
|
|
} catch (Throwable $e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private function loadActiveDiscount(PDO $db, string $code): ?array
|
|
{
|
|
try {
|
|
$stmt = $db->prepare("
|
|
SELECT id, code, discount_type, discount_value, max_uses, used_count, expires_at, is_active
|
|
FROM ac_store_discount_codes
|
|
WHERE code = :code
|
|
LIMIT 1
|
|
");
|
|
$stmt->execute([':code' => strtoupper(trim($code))]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) {
|
|
return null;
|
|
}
|
|
if ((int)($row['is_active'] ?? 0) !== 1) {
|
|
return null;
|
|
}
|
|
$maxUses = (int)($row['max_uses'] ?? 0);
|
|
$used = (int)($row['used_count'] ?? 0);
|
|
if ($maxUses > 0 && $used >= $maxUses) {
|
|
return null;
|
|
}
|
|
$expires = trim((string)($row['expires_at'] ?? ''));
|
|
if ($expires !== '') {
|
|
try {
|
|
if (new \DateTimeImmutable('now') > new \DateTimeImmutable($expires)) {
|
|
return null;
|
|
}
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
return $row;
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function buildCartTotals(array $items, string $discountCode = ''): array
|
|
{
|
|
$totals = [
|
|
'count' => 0,
|
|
'subtotal' => 0.0,
|
|
'discount_amount' => 0.0,
|
|
'amount' => 0.0,
|
|
'currency' => Settings::get('store_currency', 'GBP'),
|
|
'discount_code' => '',
|
|
];
|
|
foreach ($items as $item) {
|
|
if (!is_array($item)) {
|
|
continue;
|
|
}
|
|
$qty = max(1, (int)($item['qty'] ?? 1));
|
|
$price = (float)($item['price'] ?? 0);
|
|
$totals['count'] += $qty;
|
|
$totals['subtotal'] += ($price * $qty);
|
|
if (!empty($item['currency'])) {
|
|
$totals['currency'] = (string)$item['currency'];
|
|
}
|
|
}
|
|
|
|
$discountCode = strtoupper(trim($discountCode));
|
|
if ($discountCode !== '' && $totals['subtotal'] > 0) {
|
|
$db = Database::get();
|
|
if ($db instanceof PDO) {
|
|
$this->ensureDiscountSchema();
|
|
$discount = $this->loadActiveDiscount($db, $discountCode);
|
|
if ($discount) {
|
|
$discountType = (string)($discount['discount_type'] ?? 'percent');
|
|
$discountValue = (float)($discount['discount_value'] ?? 0);
|
|
if ($discountType === 'fixed') {
|
|
$totals['discount_amount'] = min($totals['subtotal'], max(0, round($discountValue, 2)));
|
|
} else {
|
|
$percent = min(100, max(0, $discountValue));
|
|
$totals['discount_amount'] = min($totals['subtotal'], round($totals['subtotal'] * ($percent / 100), 2));
|
|
}
|
|
$totals['discount_code'] = (string)($discount['code'] ?? '');
|
|
}
|
|
}
|
|
}
|
|
|
|
$totals['amount'] = max(0, round($totals['subtotal'] - $totals['discount_amount'], 2));
|
|
return $totals;
|
|
}
|
|
|
|
private function sendOrderEmail(string $to, string $orderNo, string $currency, float $total, array $items, string $status, string $downloadLinksHtml): void
|
|
{
|
|
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
|
return;
|
|
}
|
|
$subjectTpl = (string)Settings::get('store_order_email_subject', 'Your AudioCore order {{order_no}}');
|
|
$htmlTpl = (string)Settings::get('store_order_email_html', $this->defaultOrderEmailHtml());
|
|
$itemsHtml = $this->renderItemsHtml($items, $currency);
|
|
$siteName = (string)Settings::get('site_title', Settings::get('footer_text', 'AudioCore'));
|
|
$logoUrl = trim((string)Settings::get('store_email_logo_url', ''));
|
|
$logoHtml = $logoUrl !== ''
|
|
? '<img src="' . htmlspecialchars($logoUrl, ENT_QUOTES, 'UTF-8') . '" alt="' . htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8') . '" style="max-height:60px; width:auto;">'
|
|
: '';
|
|
if ($downloadLinksHtml === '') {
|
|
$downloadLinksHtml = $status === 'paid'
|
|
? '<p>Your download links will be available in your account/downloads area.</p>'
|
|
: '<p>Download links are sent once payment is confirmed.</p>';
|
|
}
|
|
$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 '<p>Download links are sent once payment is confirmed.</p>';
|
|
}
|
|
$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 '<p>No downloadable items in this order.</p>';
|
|
}
|
|
|
|
$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 '<p>No downloadable files attached yet.</p>';
|
|
}
|
|
$rows = [];
|
|
foreach ($links as $link) {
|
|
$rows[] = '<li style="margin:0 0 8px;"><a href="' . htmlspecialchars($link['url'], ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($link['label'], ENT_QUOTES, 'UTF-8') . '</a></li>';
|
|
}
|
|
return '<div><h3 style="margin:0 0 8px;">Your Downloads</h3><ul style="padding-left:18px;margin:0;">' . implode('', $rows) . '</ul></div>';
|
|
} catch (Throwable $e) {
|
|
return '<p>Download links could not be generated yet.</p>';
|
|
}
|
|
}
|
|
|
|
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[] = '<tr>'
|
|
. '<td style="padding:8px;border-bottom:1px solid #ddd;">' . $title . '</td>'
|
|
. '<td style="padding:8px;border-bottom:1px solid #ddd;text-align:right;">' . $currency . ' ' . $line . '</td>'
|
|
. '</tr>';
|
|
}
|
|
if (!$rows) {
|
|
return '<p>No items.</p>';
|
|
}
|
|
return '<table style="width:100%;border-collapse:collapse;">' . implode('', $rows) . '</table>';
|
|
}
|
|
|
|
private function defaultOrderEmailHtml(): string
|
|
{
|
|
return '{{logo_html}}'
|
|
. '<h2>{{site_name}} - Order {{order_no}}</h2>'
|
|
. '<p>Status: <strong>{{status}}</strong></p>'
|
|
. '<p>Email: {{customer_email}}</p>'
|
|
. '{{items_html}}'
|
|
. '{{download_links_html}}'
|
|
. '<p style="margin-top:12px;"><strong>Total: {{currency}} {{total}}</strong></p>';
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
}
|
|
}
|