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