Files
AudioCore/plugins/store/StoreController.php

4672 lines
206 KiB
PHP
Raw Normal View History

2026-04-01 14:12:17 +00:00
<?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;
2026-04-01 14:12:17 +00:00
use Core\Services\RateLimiter;
use Core\Services\Settings;
2026-04-01 14:12:17 +00:00
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,
2026-04-01 14:12:17 +00:00
'before_fees' => 0.0,
'paypal_fees' => 0.0,
'after_fees' => 0.0,
'total_customers' => 0,
];
2026-04-01 14:12:17 +00:00
$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("
2026-04-01 14:12:17 +00:00
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
2026-04-01 14:12:17 +00:00
WHERE status = 'paid'
");
2026-04-01 14:12:17 +00:00
$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) {
}
2026-04-01 14:12:17 +00:00
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("
2026-04-01 14:12:17 +00:00
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
2026-04-01 14:12:17 +00:00
");
$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
{
2026-04-01 14:12:17 +00:00
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
2026-04-01 14:12:17 +00:00
$this->ensureAnalyticsSchema();
2026-04-01 14:12:17 +00:00
$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
{
2026-04-01 14:12:17 +00:00
$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']);
}
2026-04-01 14:12:17 +00:00
$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']);
}
2026-04-01 14:12:17 +00:00
$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']);
}
2026-04-01 14:12:17 +00:00
// 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']);
}
}
2026-04-01 14:12:17 +00:00
} catch (Throwable $e) {
$this->setAccountFlash('error', 'Unable to send login email right now');
return new Response('', 302, ['Location' => '/account']);
}
2026-04-01 14:12:17 +00:00
$this->setAccountFlash('message', 'If we found orders for that email, a login link has been sent');
return new Response('', 302, ['Location' => '/account']);
}
2026-04-01 14:12:17 +00:00
public function accountLogin(): Response
{
2026-04-01 14:12:17 +00:00
$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)) {
2026-04-01 14:12:17 +00:00
$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']);
}
2026-04-01 14:12:17 +00:00
if (!empty($row['used_at'])) {
$this->setAccountFlash('error', 'Login link has already been used');
return new Response('', 302, ['Location' => '/account']);
}
2026-04-01 14:12:17 +00:00
$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']);
}
}
2026-04-01 14:12:17 +00:00
$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();
}
2026-04-01 14:12:17 +00:00
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']);
}
2026-04-01 14:12:17 +00:00
$this->setAccountFlash('message', 'Signed in successfully');
return new Response('', 302, ['Location' => '/account']);
}
2026-04-01 14:12:17 +00:00
public function accountLogout(): Response
{
2026-04-01 14:12:17 +00:00
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
2026-04-01 14:12:17 +00:00
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']);
}
2026-04-01 14:12:17 +00:00
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
{
2026-04-01 14:12:17 +00:00
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'))]);
}
2026-04-01 14:12:17 +00:00
if ((float)$context['total'] <= 0.0) {
return $this->checkoutPlace();
}
$db = Database::get();
if (!($db instanceof PDO)) {
2026-04-01 14:12:17 +00:00
return new Response('', 302, ['Location' => '/checkout?error=Database+unavailable']);
}
2026-04-01 14:12:17 +00:00
$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'))]);
}
2026-04-01 14:12:17 +00:00
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
2026-04-01 14:12:17 +00:00
$_SESSION['ac_checkout_card_email'] = $email;
$_SESSION['ac_checkout_card_terms'] = 1;
return new Response('', 302, ['Location' => '/checkout/card']);
}
2026-04-01 14:12:17 +00:00
public function checkoutCard(): Response
{
2026-04-01 14:12:17 +00:00
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
2026-04-01 14:12:17 +00:00
$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>';
}
2026-04-01 14:12:17 +00:00
private function setAccountFlash(string $type, string $message): void
{
2026-04-01 14:12:17 +00:00
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
2026-04-01 14:12:17 +00:00
$_SESSION['ac_store_flash_' . $type] = $message;
}
2026-04-01 14:12:17 +00:00
private function consumeAccountFlash(string $type): string
{
2026-04-01 14:12:17 +00:00
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
2026-04-01 14:12:17 +00:00
$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';
2026-04-01 14:12:17 +00:00
}
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 [];
}
}
}