2026-03-04 20:46:11 +00:00
< ? php
declare ( strict_types = 1 );
namespace Plugins\Store ;
use Core\Http\Response ;
use Core\Services\Auth ;
use Core\Services\Database ;
use Core\Services\Mailer ;
use Core\Services\Settings ;
use Core\Views\View ;
use PDO ;
use Plugins\Store\Gateways\Gateways ;
use Throwable ;
class StoreController
{
private View $view ;
public function __construct ()
{
2026-03-05 15:27:58 +00:00
$this -> applyStoreTimezone ();
2026-03-04 20:46:11 +00:00
$this -> view = new View ( __DIR__ . '/views' );
}
public function adminIndex () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureAnalyticsSchema ();
$tablesReady = $this -> tablesReady ();
$stats = [
'total_orders' => 0 ,
'paid_orders' => 0 ,
'total_revenue' => 0.0 ,
'total_customers' => 0 ,
];
$recentOrders = [];
$newCustomers = [];
if ( $tablesReady ) {
$db = Database :: get ();
if ( $db instanceof PDO ) {
try {
$stmt = $db -> query ( " SELECT COUNT(*) AS c FROM ac_store_orders " );
$row = $stmt ? $stmt -> fetch ( PDO :: FETCH_ASSOC ) : null ;
$stats [ 'total_orders' ] = ( int )( $row [ 'c' ] ? ? 0 );
} catch ( Throwable $e ) {
}
try {
$stmt = $db -> query ( " SELECT COUNT(*) AS c FROM ac_store_orders WHERE status = 'paid' " );
$row = $stmt ? $stmt -> fetch ( PDO :: FETCH_ASSOC ) : null ;
$stats [ 'paid_orders' ] = ( int )( $row [ 'c' ] ? ? 0 );
} catch ( Throwable $e ) {
}
try {
$stmt = $db -> query ( " SELECT COALESCE(SUM(total), 0) AS revenue FROM ac_store_orders WHERE status = 'paid' " );
$row = $stmt ? $stmt -> fetch ( PDO :: FETCH_ASSOC ) : null ;
$stats [ 'total_revenue' ] = ( float )( $row [ 'revenue' ] ? ? 0 );
} catch ( Throwable $e ) {
}
try {
$stmt = $db -> query ( " SELECT COUNT(*) AS c FROM ac_store_customers " );
$row = $stmt ? $stmt -> fetch ( PDO :: FETCH_ASSOC ) : null ;
$stats [ 'total_customers' ] = ( int )( $row [ 'c' ] ? ? 0 );
} catch ( Throwable $e ) {
}
try {
$stmt = $db -> query ( "
SELECT order_no , email , status , currency , total , created_at
FROM ac_store_orders
ORDER BY created_at DESC
LIMIT 5
" );
$recentOrders = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
} catch ( Throwable $e ) {
$recentOrders = [];
}
try {
$stmt = $db -> query ( "
SELECT name , email , is_active , created_at
FROM ac_store_customers
ORDER BY created_at DESC
LIMIT 5
" );
$newCustomers = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
} catch ( Throwable $e ) {
$newCustomers = [];
}
}
}
return new Response ( $this -> view -> render ( 'admin/index.php' , [
'title' => 'Store' ,
'tables_ready' => $tablesReady ,
'private_root' => Settings :: get ( 'store_private_root' , $this -> privateRoot ()),
'private_root_ready' => $this -> privateRootReady (),
'stats' => $stats ,
'recent_orders' => $recentOrders ,
'new_customers' => $newCustomers ,
'currency' => Settings :: get ( 'store_currency' , 'GBP' ),
]));
}
public function adminSettings () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureDiscountSchema ();
$this -> ensureSalesChartSchema ();
$settings = $this -> settingsPayload ();
$gateways = [];
foreach ( Gateways :: all () as $gateway ) {
$gateways [] = [
'key' => $gateway -> key (),
'label' => $gateway -> label (),
'enabled' => $gateway -> isEnabled ( $settings ),
];
}
return new Response ( $this -> view -> render ( 'admin/settings.php' , [
'title' => 'Store Settings' ,
'settings' => $settings ,
'gateways' => $gateways ,
'discounts' => $this -> adminDiscountRows (),
'private_root_ready' => $this -> privateRootReady (),
'tab' => ( string )( $_GET [ 'tab' ] ? ? 'general' ),
'error' => ( string )( $_GET [ 'error' ] ? ? '' ),
'saved' => ( string )( $_GET [ 'saved' ] ? ? '' ),
'chart_rows' => $this -> salesChartRows (
( string )( $settings [ 'store_sales_chart_default_scope' ] ? ? 'tracks' ),
( string )( $settings [ 'store_sales_chart_default_window' ] ? ? 'latest' ),
max ( 1 , min ( 30 , ( int )( $settings [ 'store_sales_chart_limit' ] ? ? '10' )))
),
'chart_last_rebuild_at' => ( string ) $this -> salesChartLastRebuildAt (),
'chart_cron_url' => $this -> salesChartCronUrl (),
'chart_cron_cmd' => $this -> salesChartCronCommand (),
]));
}
public function adminSaveSettings () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$current = $this -> settingsPayload ();
$currencyRaw = array_key_exists ( 'store_currency' , $_POST ) ? ( string ) $_POST [ 'store_currency' ] : ( string )( $current [ 'store_currency' ] ? ? 'GBP' );
$currency = strtoupper ( trim ( $currencyRaw ));
if ( ! preg_match ( '/^[A-Z]{3}$/' , $currency )) {
$currency = 'GBP' ;
}
$privateRootRaw = array_key_exists ( 'store_private_root' , $_POST ) ? ( string ) $_POST [ 'store_private_root' ] : ( string )( $current [ 'store_private_root' ] ? ? $this -> privateRoot ());
$privateRoot = trim ( $privateRootRaw );
if ( $privateRoot === '' ) {
$privateRoot = $this -> privateRoot ();
}
$downloadLimitRaw = array_key_exists ( 'store_download_limit' , $_POST ) ? ( string ) $_POST [ 'store_download_limit' ] : ( string )( $current [ 'store_download_limit' ] ? ? '5' );
$downloadLimit = max ( 1 , ( int ) $downloadLimitRaw );
$expiryDaysRaw = array_key_exists ( 'store_download_expiry_days' , $_POST ) ? ( string ) $_POST [ 'store_download_expiry_days' ] : ( string )( $current [ 'store_download_expiry_days' ] ? ? '30' );
$expiryDays = max ( 1 , ( int ) $expiryDaysRaw );
$orderPrefixRaw = array_key_exists ( 'store_order_prefix' , $_POST ) ? ( string ) $_POST [ 'store_order_prefix' ] : ( string )( $current [ 'store_order_prefix' ] ? ? 'AC-ORD' );
$orderPrefix = $this -> sanitizeOrderPrefix ( $orderPrefixRaw );
2026-03-05 15:27:58 +00:00
$timezoneRaw = array_key_exists ( 'store_timezone' , $_POST ) ? ( string ) $_POST [ 'store_timezone' ] : ( string )( $current [ 'store_timezone' ] ? ? 'UTC' );
$timezone = $this -> normalizeTimezone ( $timezoneRaw );
2026-03-04 20:46:11 +00:00
$testMode = array_key_exists ( 'store_test_mode' , $_POST ) ? (( string ) $_POST [ 'store_test_mode' ] === '1' ? '1' : '0' ) : ( string )( $current [ 'store_test_mode' ] ? ? '1' );
$stripeEnabled = array_key_exists ( 'store_stripe_enabled' , $_POST ) ? (( string ) $_POST [ 'store_stripe_enabled' ] === '1' ? '1' : '0' ) : ( string )( $current [ 'store_stripe_enabled' ] ? ? '0' );
$stripePublic = trim (( string )( array_key_exists ( 'store_stripe_public_key' , $_POST ) ? $_POST [ 'store_stripe_public_key' ] : ( $current [ 'store_stripe_public_key' ] ? ? '' )));
$stripeSecret = trim (( string )( array_key_exists ( 'store_stripe_secret_key' , $_POST ) ? $_POST [ 'store_stripe_secret_key' ] : ( $current [ 'store_stripe_secret_key' ] ? ? '' )));
$paypalEnabled = array_key_exists ( 'store_paypal_enabled' , $_POST ) ? (( string ) $_POST [ 'store_paypal_enabled' ] === '1' ? '1' : '0' ) : ( string )( $current [ 'store_paypal_enabled' ] ? ? '0' );
$paypalClientId = trim (( string )( array_key_exists ( 'store_paypal_client_id' , $_POST ) ? $_POST [ 'store_paypal_client_id' ] : ( $current [ 'store_paypal_client_id' ] ? ? '' )));
$paypalSecret = trim (( string )( array_key_exists ( 'store_paypal_secret' , $_POST ) ? $_POST [ 'store_paypal_secret' ] : ( $current [ 'store_paypal_secret' ] ? ? '' )));
$emailLogoUrl = trim (( string )( array_key_exists ( 'store_email_logo_url' , $_POST ) ? $_POST [ 'store_email_logo_url' ] : ( $current [ 'store_email_logo_url' ] ? ? '' )));
$orderEmailSubject = trim (( string )( array_key_exists ( 'store_order_email_subject' , $_POST ) ? $_POST [ 'store_order_email_subject' ] : ( $current [ 'store_order_email_subject' ] ? ? 'Your AudioCore order {{order_no}}' )));
$orderEmailHtml = trim (( string )( array_key_exists ( 'store_order_email_html' , $_POST ) ? $_POST [ 'store_order_email_html' ] : ( $current [ 'store_order_email_html' ] ? ? '' )));
if ( $orderEmailHtml === '' ) {
$orderEmailHtml = $this -> defaultOrderEmailHtml ();
}
$salesChartDefaultScope = strtolower ( trim (( string )( array_key_exists ( 'store_sales_chart_default_scope' , $_POST ) ? $_POST [ 'store_sales_chart_default_scope' ] : ( $current [ 'store_sales_chart_default_scope' ] ? ? 'tracks' ))));
if ( ! in_array ( $salesChartDefaultScope , [ 'tracks' , 'releases' ], true )) {
$salesChartDefaultScope = 'tracks' ;
}
$salesChartDefaultWindow = strtolower ( trim (( string )( array_key_exists ( 'store_sales_chart_default_window' , $_POST ) ? $_POST [ 'store_sales_chart_default_window' ] : ( $current [ 'store_sales_chart_default_window' ] ? ? 'latest' ))));
if ( ! in_array ( $salesChartDefaultWindow , [ 'latest' , 'weekly' , 'all_time' ], true )) {
$salesChartDefaultWindow = 'latest' ;
}
$salesChartLimit = max ( 1 , min ( 50 , ( int )( array_key_exists ( 'store_sales_chart_limit' , $_POST ) ? $_POST [ 'store_sales_chart_limit' ] : ( $current [ 'store_sales_chart_limit' ] ? ? '10' ))));
$latestHours = max ( 1 , min ( 168 , ( int )( array_key_exists ( 'store_sales_chart_latest_hours' , $_POST ) ? $_POST [ 'store_sales_chart_latest_hours' ] : ( $current [ 'store_sales_chart_latest_hours' ] ? ? '24' ))));
$refreshMinutes = max ( 5 , min ( 1440 , ( int )( array_key_exists ( 'store_sales_chart_refresh_minutes' , $_POST ) ? $_POST [ 'store_sales_chart_refresh_minutes' ] : ( $current [ 'store_sales_chart_refresh_minutes' ] ? ? '180' ))));
Settings :: set ( 'store_currency' , $currency );
Settings :: set ( 'store_private_root' , $privateRoot );
Settings :: set ( 'store_download_limit' , ( string ) $downloadLimit );
Settings :: set ( 'store_download_expiry_days' , ( string ) $expiryDays );
Settings :: set ( 'store_order_prefix' , $orderPrefix );
2026-03-05 15:27:58 +00:00
Settings :: set ( 'store_timezone' , $timezone );
2026-03-04 20:46:11 +00:00
Settings :: set ( 'store_test_mode' , $testMode );
Settings :: set ( 'store_stripe_enabled' , $stripeEnabled );
Settings :: set ( 'store_stripe_public_key' , $stripePublic );
Settings :: set ( 'store_stripe_secret_key' , $stripeSecret );
Settings :: set ( 'store_paypal_enabled' , $paypalEnabled );
Settings :: set ( 'store_paypal_client_id' , $paypalClientId );
Settings :: set ( 'store_paypal_secret' , $paypalSecret );
Settings :: set ( 'store_email_logo_url' , $emailLogoUrl );
Settings :: set ( 'store_order_email_subject' , $orderEmailSubject !== '' ? $orderEmailSubject : 'Your AudioCore order {{order_no}}' );
Settings :: set ( 'store_order_email_html' , $orderEmailHtml );
Settings :: set ( 'store_sales_chart_default_scope' , $salesChartDefaultScope );
Settings :: set ( 'store_sales_chart_default_window' , $salesChartDefaultWindow );
Settings :: set ( 'store_sales_chart_limit' , ( string ) $salesChartLimit );
Settings :: set ( 'store_sales_chart_latest_hours' , ( string ) $latestHours );
Settings :: set ( 'store_sales_chart_refresh_minutes' , ( string ) $refreshMinutes );
if ( isset ( $_POST [ 'store_sales_chart_regen_key' ])) {
try {
Settings :: set ( 'store_sales_chart_cron_key' , bin2hex ( random_bytes ( 24 )));
} catch ( Throwable $e ) {
}
}
if ( trim (( string ) Settings :: get ( 'store_sales_chart_cron_key' , '' )) === '' ) {
try {
Settings :: set ( 'store_sales_chart_cron_key' , bin2hex ( random_bytes ( 24 )));
} catch ( Throwable $e ) {
}
}
$this -> ensureSalesChartSchema ();
if ( ! $this -> ensurePrivateRoot ( $privateRoot )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?error=Unable+to+create+or+write+private+download+folder' ]);
}
$tab = trim (( string )( $_POST [ 'tab' ] ? ? 'general' ));
$tab = in_array ( $tab , [ 'general' , 'payments' , 'emails' , 'discounts' , 'sales_chart' ], true ) ? $tab : 'general' ;
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?saved=1&tab=' . rawurlencode ( $tab )]);
}
public function adminRebuildSalesChart () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureSalesChartSchema ();
$ok = $this -> rebuildSalesChartCache ();
if ( ! $ok ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=sales_chart&error=Unable+to+rebuild+sales+chart' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=sales_chart&saved=1' ]);
}
public function salesChartCron () : Response
{
$this -> ensureSalesChartSchema ();
$expected = trim (( string ) Settings :: get ( 'store_sales_chart_cron_key' , '' ));
$provided = trim (( string )( $_GET [ 'key' ] ? ? '' ));
if ( $expected === '' || ! hash_equals ( $expected , $provided )) {
return new Response ( 'Unauthorized' , 401 );
}
$ok = $this -> rebuildSalesChartCache ();
if ( ! $ok ) {
return new Response ( 'Sales chart rebuild failed' , 500 );
}
return new Response ( 'OK' );
}
public function adminDiscountCreate () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureDiscountSchema ();
$code = strtoupper ( trim (( string )( $_POST [ 'code' ] ? ? '' )));
$type = trim (( string )( $_POST [ 'discount_type' ] ? ? 'percent' ));
$value = ( float )( $_POST [ 'discount_value' ] ? ? 0 );
$maxUses = ( int )( $_POST [ 'max_uses' ] ? ? 0 );
$expiresAt = trim (( string )( $_POST [ 'expires_at' ] ? ? '' ));
$isActive = isset ( $_POST [ 'is_active' ]) ? 1 : 0 ;
if ( ! preg_match ( '/^[A-Z0-9_-]{3,32}$/' , $code )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&error=Invalid+code+format' ]);
}
if ( ! in_array ( $type , [ 'percent' , 'fixed' ], true )) {
$type = 'percent' ;
}
if ( $value <= 0 ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&error=Discount+value+must+be+greater+than+0' ]);
}
if ( $type === 'percent' && $value > 100 ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&error=Percent+cannot+exceed+100' ]);
}
if ( $maxUses < 0 ) {
$maxUses = 0 ;
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&error=Database+unavailable' ]);
}
try {
$stmt = $db -> prepare ( "
INSERT INTO ac_store_discount_codes
( code , discount_type , discount_value , max_uses , used_count , expires_at , is_active , created_at , updated_at )
VALUES ( : code , : discount_type , : discount_value , : max_uses , 0 , : expires_at , : is_active , NOW (), NOW ())
ON DUPLICATE KEY UPDATE
discount_type = VALUES ( discount_type ),
discount_value = VALUES ( discount_value ),
max_uses = VALUES ( max_uses ),
expires_at = VALUES ( expires_at ),
is_active = VALUES ( is_active ),
updated_at = NOW ()
" );
$stmt -> execute ([
':code' => $code ,
':discount_type' => $type ,
':discount_value' => $value ,
':max_uses' => $maxUses ,
':expires_at' => $expiresAt !== '' ? $expiresAt : null ,
':is_active' => $isActive ,
]);
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&error=Unable+to+save+discount+code' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&saved=1' ]);
}
public function adminDiscountDelete () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureDiscountSchema ();
$id = ( int )( $_POST [ 'id' ] ? ? 0 );
if ( $id <= 0 ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&error=Invalid+discount+id' ]);
}
$db = Database :: get ();
if ( $db instanceof PDO ) {
try {
$stmt = $db -> prepare ( " DELETE FROM ac_store_discount_codes WHERE id = :id LIMIT 1 " );
$stmt -> execute ([ ':id' => $id ]);
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&error=Unable+to+delete+discount' ]);
}
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=discounts&saved=1' ]);
}
public function adminSendTestEmail () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$to = trim (( string )( $_POST [ 'test_email_to' ] ? ? '' ));
if ( ! filter_var ( $to , FILTER_VALIDATE_EMAIL )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=emails&error=Enter+a+valid+test+email' ]);
}
$subjectTpl = trim (( string )( $_POST [ 'store_order_email_subject' ] ? ? Settings :: get ( 'store_order_email_subject' , 'Your AudioCore order {{order_no}}' )));
$htmlTpl = trim (( string )( $_POST [ 'store_order_email_html' ] ? ? Settings :: get ( 'store_order_email_html' , $this -> defaultOrderEmailHtml ())));
if ( $htmlTpl === '' ) {
$htmlTpl = $this -> defaultOrderEmailHtml ();
}
$mockItems = [
[ 'title' => 'Demo Track One' , 'price' => 1.49 , 'qty' => 1 , 'currency' => 'GBP' ],
[ 'title' => 'Demo Track Two' , 'price' => 1.49 , 'qty' => 1 , 'currency' => 'GBP' ],
];
$siteName = ( string ) Settings :: get ( 'site_title' , Settings :: get ( 'footer_text' , 'AudioCore' ));
$logoUrl = trim (( string ) Settings :: get ( 'store_email_logo_url' , '' ));
$logoHtml = $logoUrl !== ''
? '<img src="' . htmlspecialchars ( $logoUrl , ENT_QUOTES , 'UTF-8' ) . '" alt="' . htmlspecialchars ( $siteName , ENT_QUOTES , 'UTF-8' ) . '" style="max-height:60px; width:auto;">'
: '' ;
$map = [
'{{site_name}}' => htmlspecialchars ( $siteName , ENT_QUOTES , 'UTF-8' ),
'{{order_no}}' => 'AC-TEST-' . date ( 'YmdHis' ),
'{{customer_email}}' => htmlspecialchars ( $to , ENT_QUOTES , 'UTF-8' ),
'{{currency}}' => 'GBP' ,
'{{total}}' => '2.98' ,
'{{status}}' => 'paid' ,
'{{logo_url}}' => htmlspecialchars ( $logoUrl , ENT_QUOTES , 'UTF-8' ),
'{{logo_html}}' => $logoHtml ,
'{{items_html}}' => $this -> renderItemsHtml ( $mockItems , 'GBP' ),
'{{download_links_html}}' => '<p>Example download links appear here after payment.</p>' ,
];
$subject = strtr ( $subjectTpl !== '' ? $subjectTpl : 'Your AudioCore order {{order_no}}' , $map );
$html = strtr ( $htmlTpl , $map );
$mailSettings = [
'smtp_host' => Settings :: get ( 'smtp_host' , '' ),
'smtp_port' => Settings :: get ( 'smtp_port' , '587' ),
'smtp_user' => Settings :: get ( 'smtp_user' , '' ),
'smtp_pass' => Settings :: get ( 'smtp_pass' , '' ),
'smtp_encryption' => Settings :: get ( 'smtp_encryption' , 'tls' ),
'smtp_from_email' => Settings :: get ( 'smtp_from_email' , '' ),
'smtp_from_name' => Settings :: get ( 'smtp_from_name' , 'AudioCore' ),
];
$result = Mailer :: send ( $to , $subject , $html , $mailSettings );
$ref = 'mailtest-' . date ( 'YmdHis' ) . '-' . random_int ( 100 , 999 );
$this -> logMailDebug ( $ref , [
'to' => $to ,
'subject' => $subject ,
'result' => $result ,
]);
if ( ! ( $result [ 'ok' ] ? ? false )) {
$msg = rawurlencode ( 'Unable to send test email. Ref ' . $ref . ': ' . ( string )( $result [ 'error' ] ? ? 'Unknown' ));
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=emails&error=' . $msg ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=emails&saved=1' ]);
}
public function adminTestPaypal () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$settings = $this -> settingsPayload ();
$clientId = trim (( string )( $_POST [ 'store_paypal_client_id' ] ? ? ( $settings [ 'store_paypal_client_id' ] ? ? '' )));
$secret = trim (( string )( $_POST [ 'store_paypal_secret' ] ? ? ( $settings [ 'store_paypal_secret' ] ? ? '' )));
if ( $clientId === '' || $secret === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=payments&error=Enter+PayPal+Client+ID+and+Secret+first' ]);
}
$probeMode = strtolower ( trim (( string )( $_POST [ 'paypal_probe_mode' ] ? ? 'live' )));
$isSandbox = ( $probeMode === 'sandbox' );
$result = $this -> paypalTokenProbe ( $clientId , $secret , $isSandbox );
if ( ! ( $result [ 'ok' ] ? ? false )) {
$err = rawurlencode (( string )( $result [ 'error' ] ? ? 'PayPal validation failed' ));
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=payments&error=' . $err ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/settings?tab=payments&saved=1&paypal_test=' . rawurlencode ( $isSandbox ? 'sandbox' : 'live' )]);
}
public function adminCustomers () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureAnalyticsSchema ();
$q = trim (( string )( $_GET [ 'q' ] ? ? '' ));
$like = '%' . $q . '%' ;
$rows = [];
$db = Database :: get ();
if ( $db instanceof PDO ) {
try {
$sql = "
SELECT
c . id ,
c . name ,
c . email ,
c . is_active ,
c . created_at ,
COALESCE ( os . order_count , 0 ) AS order_count ,
COALESCE ( os . revenue , 0 ) AS revenue ,
os . last_order_no ,
os . last_order_id ,
os . last_ip ,
os . last_order_at
FROM ac_store_customers c
LEFT JOIN (
SELECT
o . email ,
COUNT ( * ) AS order_count ,
SUM ( CASE WHEN o . status = 'paid' THEN o . total ELSE 0 END ) AS revenue ,
MAX ( o . created_at ) AS last_order_at ,
SUBSTRING_INDEX ( GROUP_CONCAT ( o . order_no ORDER BY o . created_at DESC SEPARATOR ',' ), ',' , 1 ) AS last_order_no ,
SUBSTRING_INDEX ( GROUP_CONCAT ( o . id ORDER BY o . created_at DESC SEPARATOR ',' ), ',' , 1 ) AS last_order_id ,
SUBSTRING_INDEX ( GROUP_CONCAT ( COALESCE ( o . customer_ip , '' ) ORDER BY o . created_at DESC SEPARATOR ',' ), ',' , 1 ) AS last_ip
FROM ac_store_orders o
GROUP BY o . email
) os ON os . email = c . email
" ;
if ( $q !== '' ) {
$sql .= " WHERE c.email LIKE :q OR c.name LIKE :q OR os.last_order_no LIKE :q " ;
}
$sql .= "
ORDER BY COALESCE ( os . last_order_at , c . created_at ) DESC
LIMIT 500
" ;
$stmt = $db -> prepare ( $sql );
if ( $q !== '' ) {
$stmt -> execute ([ ':q' => $like ]);
} else {
$stmt -> execute ();
}
$rows = $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
} catch ( Throwable $e ) {
$rows = [];
}
try {
$guestSql = "
SELECT
NULL AS id ,
'' AS name ,
o . email AS email ,
1 AS is_active ,
MIN ( o . created_at ) AS created_at ,
COUNT ( * ) AS order_count ,
SUM ( CASE WHEN o . status = 'paid' THEN o . total ELSE 0 END ) AS revenue ,
SUBSTRING_INDEX ( GROUP_CONCAT ( o . order_no ORDER BY o . created_at DESC SEPARATOR ',' ), ',' , 1 ) AS last_order_no ,
SUBSTRING_INDEX ( GROUP_CONCAT ( o . id ORDER BY o . created_at DESC SEPARATOR ',' ), ',' , 1 ) AS last_order_id ,
SUBSTRING_INDEX ( GROUP_CONCAT ( COALESCE ( o . customer_ip , '' ) ORDER BY o . created_at DESC SEPARATOR ',' ), ',' , 1 ) AS last_ip ,
MAX ( o . created_at ) AS last_order_at
FROM ac_store_orders o
LEFT JOIN ac_store_customers c ON c . email = o . email
WHERE c . id IS NULL
" ;
if ( $q !== '' ) {
$guestSql .= " AND (o.email LIKE :q OR o.order_no LIKE :q) " ;
}
$guestSql .= "
GROUP BY o . email
ORDER BY MAX ( o . created_at ) DESC
LIMIT 500
" ;
$guestStmt = $db -> prepare ( $guestSql );
if ( $q !== '' ) {
$guestStmt -> execute ([ ':q' => $like ]);
} else {
$guestStmt -> execute ();
}
$guests = $guestStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
if ( $guests ) {
$rows = array_merge ( $rows , $guests );
}
} catch ( Throwable $e ) {
}
if ( $rows ) {
$ipHistoryMap = $this -> loadCustomerIpHistory ( $db );
foreach ( $rows as & $row ) {
$emailKey = strtolower ( trim (( string )( $row [ 'email' ] ? ? '' )));
$row [ 'ips' ] = $ipHistoryMap [ $emailKey ] ? ? [];
}
unset ( $row );
}
}
return new Response ( $this -> view -> render ( 'admin/customers.php' , [
'title' => 'Store Customers' ,
'customers' => $rows ,
'currency' => Settings :: get ( 'store_currency' , 'GBP' ),
'q' => $q ,
]));
}
public function adminOrders () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureAnalyticsSchema ();
$q = trim (( string )( $_GET [ 'q' ] ? ? '' ));
$like = '%' . $q . '%' ;
$rows = [];
$db = Database :: get ();
if ( $db instanceof PDO ) {
try {
$sql = "
SELECT id , order_no , email , status , currency , total , created_at , customer_ip
FROM ac_store_orders
" ;
if ( $q !== '' ) {
$sql .= " WHERE order_no LIKE :q OR email LIKE :q OR customer_ip LIKE :q OR status LIKE :q " ;
}
$sql .= "
ORDER BY created_at DESC
LIMIT 500
" ;
$stmt = $db -> prepare ( $sql );
if ( $q !== '' ) {
$stmt -> execute ([ ':q' => $like ]);
} else {
$stmt -> execute ();
}
$rows = $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
} catch ( Throwable $e ) {
$rows = [];
}
}
return new Response ( $this -> view -> render ( 'admin/orders.php' , [
'title' => 'Store Orders' ,
'orders' => $rows ,
'q' => $q ,
'saved' => ( string )( $_GET [ 'saved' ] ? ? '' ),
'error' => ( string )( $_GET [ 'error' ] ? ? '' ),
]));
}
public function adminOrderCreate () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$email = strtolower ( trim (( string )( $_POST [ 'email' ] ? ? '' )));
$currency = strtoupper ( trim (( string )( $_POST [ 'currency' ] ? ? Settings :: get ( 'store_currency' , 'GBP' ))));
$total = ( float )( $_POST [ 'total' ] ? ? 0 );
$status = trim (( string )( $_POST [ 'status' ] ? ? 'pending' ));
$orderNo = trim (( string )( $_POST [ 'order_no' ] ? ? '' ));
if ( ! filter_var ( $email , FILTER_VALIDATE_EMAIL )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Enter+a+valid+email' ]);
}
if ( ! preg_match ( '/^[A-Z]{3}$/' , $currency )) {
$currency = 'GBP' ;
}
if ( ! in_array ( $status , [ 'pending' , 'paid' , 'failed' , 'refunded' ], true )) {
$status = 'pending' ;
}
if ( $total < 0 ) {
$total = 0.0 ;
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Database+unavailable' ]);
}
if ( $orderNo === '' ) {
$prefix = $this -> sanitizeOrderPrefix (( string ) Settings :: get ( 'store_order_prefix' , 'AC-ORD' ));
$orderNo = $prefix . '-' . date ( 'YmdHis' ) . '-' . random_int ( 100 , 999 );
}
try {
$customerId = $this -> upsertCustomerFromOrder ( $db , $email , $this -> clientIp (), substr (( string )( $_SERVER [ 'HTTP_USER_AGENT' ] ? ? '' ), 0 , 255 ), $total );
$stmt = $db -> prepare ( "
INSERT INTO ac_store_orders
( order_no , customer_id , email , status , currency , subtotal , total , payment_provider , payment_ref , customer_ip , customer_user_agent , created_at , updated_at )
VALUES ( : order_no , : customer_id , : email , : status , : currency , : subtotal , : total , : provider , : payment_ref , : customer_ip , : customer_user_agent , NOW (), NOW ())
" );
$stmt -> execute ([
':order_no' => $orderNo ,
':customer_id' => $customerId > 0 ? $customerId : null ,
':email' => $email ,
':status' => $status ,
':currency' => $currency ,
':subtotal' => $total ,
':total' => $total ,
':provider' => 'manual' ,
':payment_ref' => null ,
':customer_ip' => $this -> clientIp (),
':customer_user_agent' => substr (( string )( $_SERVER [ 'HTTP_USER_AGENT' ] ? ? '' ), 0 , 255 ),
]);
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Unable+to+create+order' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?saved=created' ]);
}
public function adminOrderStatus () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$orderId = ( int )( $_POST [ 'id' ] ? ? 0 );
$status = trim (( string )( $_POST [ 'status' ] ? ? '' ));
if ( $orderId <= 0 || ! in_array ( $status , [ 'pending' , 'paid' , 'failed' , 'refunded' ], true )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Invalid+order+update' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Database+unavailable' ]);
}
try {
$stmt = $db -> prepare ( " UPDATE ac_store_orders SET status = :status, updated_at = NOW() WHERE id = :id LIMIT 1 " );
$stmt -> execute ([
':status' => $status ,
':id' => $orderId ,
]);
$this -> rebuildSalesChartCache ();
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Unable+to+update+order' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?saved=status' ]);
}
public function adminOrderDelete () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$orderId = ( int )( $_POST [ 'id' ] ? ? 0 );
if ( $orderId <= 0 ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Invalid+order+id' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Database+unavailable' ]);
}
try {
$db -> beginTransaction ();
$db -> prepare ( " DELETE FROM ac_store_download_events WHERE order_id = :order_id " ) -> execute ([ ':order_id' => $orderId ]);
$db -> prepare ( " DELETE FROM ac_store_download_tokens WHERE order_id = :order_id " ) -> execute ([ ':order_id' => $orderId ]);
$db -> prepare ( " DELETE FROM ac_store_order_items WHERE order_id = :order_id " ) -> execute ([ ':order_id' => $orderId ]);
$db -> prepare ( " DELETE FROM ac_store_orders WHERE id = :id LIMIT 1 " ) -> execute ([ ':id' => $orderId ]);
$db -> commit ();
$this -> rebuildSalesChartCache ();
} catch ( Throwable $e ) {
if ( $db -> inTransaction ()) {
$db -> rollBack ();
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Unable+to+delete+order' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?saved=deleted' ]);
}
public function adminOrderRefund () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$orderId = ( int )( $_POST [ 'id' ] ? ? 0 );
if ( $orderId <= 0 ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Invalid+order+id' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Database+unavailable' ]);
}
try {
$stmt = $db -> prepare ( "
SELECT id , status , payment_provider , payment_ref , currency , total
FROM ac_store_orders
WHERE id = : id
LIMIT 1
" );
$stmt -> execute ([ ':id' => $orderId ]);
$order = $stmt -> fetch ( PDO :: FETCH_ASSOC );
if ( ! $order ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Order+not+found' ]);
}
$status = ( string )( $order [ 'status' ] ? ? '' );
if ( $status === 'refunded' ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?saved=refunded' ]);
}
if ( $status !== 'paid' ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Only+paid+orders+can+be+refunded' ]);
}
$provider = strtolower ( trim (( string )( $order [ 'payment_provider' ] ? ? '' )));
$paymentRef = trim (( string )( $order [ 'payment_ref' ] ? ? '' ));
if ( $provider === 'paypal' ) {
if ( $paymentRef === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Missing+PayPal+capture+reference' ]);
}
$clientId = trim (( string ) Settings :: get ( 'store_paypal_client_id' , '' ));
$secret = trim (( string ) Settings :: get ( 'store_paypal_secret' , '' ));
if ( $clientId === '' || $secret === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=PayPal+credentials+missing' ]);
}
$refund = $this -> paypalRefundCapture (
$clientId ,
$secret ,
$this -> isEnabledSetting ( Settings :: get ( 'store_test_mode' , '1' )),
$paymentRef ,
( string )( $order [ 'currency' ] ? ? 'GBP' ),
( float )( $order [ 'total' ] ? ? 0 )
);
if ( ! ( $refund [ 'ok' ] ? ? false )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=' . rawurlencode (( string )( $refund [ 'error' ] ? ? 'Refund failed' ))]);
}
}
$upd = $db -> prepare ( " UPDATE ac_store_orders SET status = 'refunded', updated_at = NOW() WHERE id = :id LIMIT 1 " );
$upd -> execute ([ ':id' => $orderId ]);
$revoke = $db -> prepare ( "
UPDATE ac_store_download_tokens
SET downloads_used = download_limit ,
expires_at = NOW ()
WHERE order_id = : order_id
" );
$revoke -> execute ([ ':order_id' => $orderId ]);
$this -> rebuildSalesChartCache ();
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?error=Unable+to+refund+order' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders?saved=refunded' ]);
}
public function adminOrderView () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$this -> ensureAnalyticsSchema ();
$orderId = ( int )( $_GET [ 'id' ] ? ? 0 );
if ( $orderId <= 0 ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders' ]);
}
$order = null ;
$items = [];
$downloadsByItem = [];
$downloadEvents = [];
try {
$orderStmt = $db -> prepare ( "
SELECT id , order_no , email , status , currency , subtotal , total , payment_provider , payment_ref , customer_ip , created_at , updated_at
FROM ac_store_orders
WHERE id = : id
LIMIT 1
" );
$orderStmt -> execute ([ ':id' => $orderId ]);
$order = $orderStmt -> fetch ( PDO :: FETCH_ASSOC ) ? : null ;
if ( ! $order ) {
return new Response ( '' , 302 , [ 'Location' => '/admin/store/orders' ]);
}
$itemStmt = $db -> prepare ( "
SELECT
oi . id ,
oi . item_type ,
oi . item_id ,
oi . title_snapshot ,
oi . unit_price_snapshot ,
oi . currency_snapshot ,
oi . qty ,
oi . line_total ,
oi . created_at ,
t . id AS token_id ,
t . download_limit ,
t . downloads_used ,
t . expires_at ,
f . file_name ,
f . file_url
FROM ac_store_order_items oi
LEFT JOIN ac_store_download_tokens t ON t . order_item_id = oi . id
LEFT JOIN ac_store_files f ON f . id = t . file_id
WHERE oi . order_id = : order_id
ORDER BY oi . id ASC
" );
$itemStmt -> execute ([ ':order_id' => $orderId ]);
$items = $itemStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
$eventStmt = $db -> prepare ( "
SELECT
e . id ,
e . order_item_id ,
e . file_id ,
e . ip_address ,
e . user_agent ,
e . downloaded_at ,
f . file_name
FROM ac_store_download_events e
LEFT JOIN ac_store_files f ON f . id = e . file_id
WHERE e . order_id = : order_id
ORDER BY e . downloaded_at DESC
LIMIT 500
" );
$eventStmt -> execute ([ ':order_id' => $orderId ]);
$downloadEvents = $eventStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
} catch ( Throwable $e ) {
}
foreach ( $downloadEvents as $event ) {
$key = ( int )( $event [ 'order_item_id' ] ? ? 0 );
if ( $key <= 0 ) {
continue ;
}
if ( ! isset ( $downloadsByItem [ $key ])) {
$downloadsByItem [ $key ] = [
'count' => 0 ,
'ips' => [],
];
}
$downloadsByItem [ $key ][ 'count' ] ++ ;
$ip = trim (( string )( $event [ 'ip_address' ] ? ? '' ));
if ( $ip !== '' && ! in_array ( $ip , $downloadsByItem [ $key ][ 'ips' ], true )) {
$downloadsByItem [ $key ][ 'ips' ][] = $ip ;
}
}
return new Response ( $this -> view -> render ( 'admin/order.php' , [
'title' => 'Order Detail' ,
'order' => $order ,
'items' => $items ,
'downloads_by_item' => $downloadsByItem ,
'download_events' => $downloadEvents ,
]));
}
public function adminInstall () : Response
{
if ( $guard = $this -> guard ()) {
return $guard ;
}
$db = Database :: get ();
if ( $db instanceof PDO ) {
try {
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_release_products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
release_id INT UNSIGNED NOT NULL UNIQUE ,
is_enabled TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
bundle_price DECIMAL ( 10 , 2 ) NOT NULL DEFAULT 0.00 ,
currency CHAR ( 3 ) NOT NULL DEFAULT 'GBP' ,
purchase_label VARCHAR ( 120 ) NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_track_products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
release_track_id INT UNSIGNED NOT NULL UNIQUE ,
is_enabled TINYINT ( 1 ) NOT NULL DEFAULT 0 ,
track_price DECIMAL ( 10 , 2 ) NOT NULL DEFAULT 0.00 ,
currency CHAR ( 3 ) NOT NULL DEFAULT 'GBP' ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_files (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
scope_type ENUM ( 'release' , 'track' ) NOT NULL ,
scope_id INT UNSIGNED NOT NULL ,
file_url VARCHAR ( 1024 ) NOT NULL ,
file_name VARCHAR ( 255 ) NOT NULL ,
file_size BIGINT UNSIGNED NULL ,
mime_type VARCHAR ( 128 ) NULL ,
is_active TINYINT ( 1 ) NOT NULL DEFAULT 1 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_customers (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
name VARCHAR ( 140 ) NULL ,
email VARCHAR ( 190 ) NOT NULL UNIQUE ,
password_hash VARCHAR ( 255 ) NULL ,
is_active TINYINT ( 1 ) NOT NULL DEFAULT 1 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_orders (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
order_no VARCHAR ( 32 ) NOT NULL UNIQUE ,
customer_id INT UNSIGNED NULL ,
email VARCHAR ( 190 ) NOT NULL ,
status ENUM ( 'pending' , 'paid' , 'failed' , 'refunded' ) NOT NULL DEFAULT 'pending' ,
currency CHAR ( 3 ) NOT NULL DEFAULT 'GBP' ,
subtotal DECIMAL ( 10 , 2 ) NOT NULL DEFAULT 0.00 ,
total DECIMAL ( 10 , 2 ) NOT NULL DEFAULT 0.00 ,
discount_code VARCHAR ( 64 ) NULL ,
discount_amount DECIMAL ( 10 , 2 ) NULL ,
payment_provider VARCHAR ( 40 ) NULL ,
payment_ref VARCHAR ( 120 ) NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_order_items (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
order_id INT UNSIGNED NOT NULL ,
item_type ENUM ( 'release' , 'track' ) NOT NULL ,
item_id INT UNSIGNED NOT NULL ,
title_snapshot VARCHAR ( 255 ) NOT NULL ,
unit_price_snapshot DECIMAL ( 10 , 2 ) NOT NULL DEFAULT 0.00 ,
currency_snapshot CHAR ( 3 ) NOT NULL DEFAULT 'GBP' ,
qty INT UNSIGNED NOT NULL DEFAULT 1 ,
line_total DECIMAL ( 10 , 2 ) NOT NULL DEFAULT 0.00 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_download_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
order_id INT UNSIGNED NOT NULL ,
order_item_id INT UNSIGNED NOT NULL ,
file_id INT UNSIGNED NOT NULL ,
email VARCHAR ( 190 ) NOT NULL ,
token VARCHAR ( 96 ) NOT NULL UNIQUE ,
download_limit INT UNSIGNED NOT NULL DEFAULT 5 ,
downloads_used INT UNSIGNED NOT NULL DEFAULT 0 ,
expires_at DATETIME NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_download_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
token_id INT UNSIGNED NOT NULL ,
order_id INT UNSIGNED NOT NULL ,
order_item_id INT UNSIGNED NOT NULL ,
file_id INT UNSIGNED NOT NULL ,
email VARCHAR ( 190 ) NULL ,
ip_address VARCHAR ( 64 ) NULL ,
user_agent VARCHAR ( 255 ) NULL ,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
KEY idx_store_download_events_order ( order_id ),
KEY idx_store_download_events_item ( order_item_id ),
KEY idx_store_download_events_ip ( ip_address )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
email VARCHAR ( 190 ) NOT NULL ,
token_hash CHAR ( 64 ) NOT NULL UNIQUE ,
expires_at DATETIME NOT NULL ,
used_at DATETIME NULL ,
request_ip VARCHAR ( 64 ) NULL ,
request_user_agent VARCHAR ( 255 ) NULL ,
used_ip VARCHAR ( 64 ) NULL ,
used_user_agent VARCHAR ( 255 ) NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
KEY idx_store_login_tokens_email ( email ),
KEY idx_store_login_tokens_expires ( expires_at )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code " );
} catch ( Throwable $e ) {
}
} catch ( Throwable $e ) {
}
}
$this -> ensurePrivateRoot ( Settings :: get ( 'store_private_root' , $this -> privateRoot ()));
$this -> ensureSalesChartSchema ();
return new Response ( '' , 302 , [ 'Location' => '/admin/store' ]);
}
public function accountIndex () : Response
{
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$this -> ensureAnalyticsSchema ();
$email = strtolower ( trim (( string )( $_SESSION [ 'ac_store_customer_email' ] ? ? '' )));
$flash = ( string )( $_GET [ 'message' ] ? ? '' );
$error = ( string )( $_GET [ 'error' ] ? ? '' );
$orders = [];
$downloads = [];
if ( $email !== '' ) {
$db = Database :: get ();
if ( $db instanceof PDO ) {
try {
$orderStmt = $db -> prepare ( "
SELECT id , order_no , status , currency , total , created_at
FROM ac_store_orders
WHERE email = : email
ORDER BY created_at DESC
LIMIT 100
" );
$orderStmt -> execute ([ ':email' => $email ]);
$orders = $orderStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
} catch ( Throwable $e ) {
$orders = [];
}
try {
$downloadStmt = $db -> prepare ( "
SELECT
o . order_no ,
f . file_name ,
t . download_limit ,
t . downloads_used ,
t . expires_at ,
t . token
FROM ac_store_download_tokens t
JOIN ac_store_orders o ON o . id = t . order_id
JOIN ac_store_files f ON f . id = t . file_id
WHERE t . email = : email
ORDER BY t . created_at DESC
LIMIT 500
" );
$downloadStmt -> execute ([ ':email' => $email ]);
$rows = $downloadStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
foreach ( $rows as $row ) {
$token = trim (( string )( $row [ 'token' ] ? ? '' ));
if ( $token === '' ) {
continue ;
}
$downloads [] = [
'order_no' => ( string )( $row [ 'order_no' ] ? ? '' ),
'file_name' => ( string )( $row [ 'file_name' ] ? ? 'Download' ),
'download_limit' => ( int )( $row [ 'download_limit' ] ? ? 0 ),
'downloads_used' => ( int )( $row [ 'downloads_used' ] ? ? 0 ),
'expires_at' => ( string )( $row [ 'expires_at' ] ? ? '' ),
'url' => '/store/download?token=' . rawurlencode ( $token ),
];
}
} catch ( Throwable $e ) {
$downloads = [];
}
}
}
return new Response ( $this -> view -> render ( 'site/account.php' , [
'title' => 'Account' ,
'is_logged_in' => ( $email !== '' ),
'email' => $email ,
'orders' => $orders ,
'downloads' => $downloads ,
'message' => $flash ,
'error' => $error ,
'download_limit' => ( int ) Settings :: get ( 'store_download_limit' , '5' ),
'download_expiry_days' => ( int ) Settings :: get ( 'store_download_expiry_days' , '30' ),
]));
}
public function accountRequestLogin () : Response
{
$email = strtolower ( trim (( string )( $_POST [ 'email' ] ? ? '' )));
if ( ! filter_var ( $email , FILTER_VALIDATE_EMAIL )) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Enter+a+valid+email+address' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Account+login+service+is+currently+unavailable' ]);
}
$this -> ensureAnalyticsSchema ();
try {
// Rate limit token requests per email.
$limitStmt = $db -> prepare ( "
SELECT COUNT ( * ) AS c
FROM ac_store_login_tokens
WHERE email = : email
AND created_at >= ( NOW () - INTERVAL 10 MINUTE )
" );
$limitStmt -> execute ([ ':email' => $email ]);
$limitRow = $limitStmt -> fetch ( PDO :: FETCH_ASSOC );
if (( int )( $limitRow [ 'c' ] ? ? 0 ) >= 5 ) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Too+many+login+requests.+Please+wait+10+minutes' ]);
}
// Send generic response even if no orders exist.
$orderStmt = $db -> prepare ( " SELECT COUNT(*) AS c FROM ac_store_orders WHERE email = :email " );
$orderStmt -> execute ([ ':email' => $email ]);
$orderCount = ( int )(( $orderStmt -> fetch ( PDO :: FETCH_ASSOC )[ 'c' ] ? ? 0 ));
if ( $orderCount > 0 ) {
$rawToken = bin2hex ( random_bytes ( 24 ));
$tokenHash = hash ( 'sha256' , $rawToken );
$expiresAt = ( new \DateTimeImmutable ( 'now' )) -> modify ( '+15 minutes' ) -> format ( 'Y-m-d H:i:s' );
$ins = $db -> prepare ( "
INSERT INTO ac_store_login_tokens
( email , token_hash , expires_at , request_ip , request_user_agent , created_at )
VALUES ( : email , : token_hash , : expires_at , : request_ip , : request_user_agent , NOW ())
" );
$ins -> execute ([
':email' => $email ,
':token_hash' => $tokenHash ,
':expires_at' => $expiresAt ,
':request_ip' => $this -> clientIp (),
':request_user_agent' => substr (( string )( $_SERVER [ 'HTTP_USER_AGENT' ] ? ? '' ), 0 , 255 ),
]);
$siteName = trim (( string ) Settings :: get ( 'site_title' , '' ));
if ( $siteName === '' ) {
$siteName = 'AudioCore V1.5' ;
}
$loginUrl = $this -> baseUrl () . '/account/login?token=' . rawurlencode ( $rawToken );
$subject = $siteName . ' account access link' ;
$html = '<p>Hello,</p>'
. '<p>Use this secure link to access your downloads:</p>'
. '<p><a href="' . htmlspecialchars ( $loginUrl , ENT_QUOTES , 'UTF-8' ) . '">' . htmlspecialchars ( $loginUrl , ENT_QUOTES , 'UTF-8' ) . '</a></p>'
. '<p>This link expires in 15 minutes and can only be used once.</p>' ;
$mailSettings = [
'smtp_host' => Settings :: get ( 'smtp_host' , '' ),
'smtp_port' => Settings :: get ( 'smtp_port' , '587' ),
'smtp_user' => Settings :: get ( 'smtp_user' , '' ),
'smtp_pass' => Settings :: get ( 'smtp_pass' , '' ),
'smtp_encryption' => Settings :: get ( 'smtp_encryption' , 'tls' ),
'smtp_from_email' => Settings :: get ( 'smtp_from_email' , '' ),
'smtp_from_name' => Settings :: get ( 'smtp_from_name' , 'AudioCore' ),
];
Mailer :: send ( $email , $subject , $html , $mailSettings );
}
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Unable+to+send+login+email+right+now' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/account?message=If+we+found+orders+for+that+email%2C+a+login+link+has+been+sent' ]);
}
public function accountLogin () : Response
{
$token = trim (( string )( $_GET [ 'token' ] ? ? '' ));
if ( $token === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Invalid+login+token' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Account+login+service+is+currently+unavailable' ]);
}
$this -> ensureAnalyticsSchema ();
try {
$hash = hash ( 'sha256' , $token );
$stmt = $db -> prepare ( "
SELECT id , email , expires_at , used_at
FROM ac_store_login_tokens
WHERE token_hash = : token_hash
LIMIT 1
" );
$stmt -> execute ([ ':token_hash' => $hash ]);
$row = $stmt -> fetch ( PDO :: FETCH_ASSOC );
if ( ! $row ) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Login+link+is+invalid' ]);
}
if ( ! empty ( $row [ 'used_at' ])) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Login+link+has+already+been+used' ]);
}
$expiresAt = ( string )( $row [ 'expires_at' ] ? ? '' );
if ( $expiresAt !== '' ) {
if ( new \DateTimeImmutable ( 'now' ) > new \DateTimeImmutable ( $expiresAt )) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Login+link+has+expired' ]);
}
}
$upd = $db -> prepare ( "
UPDATE ac_store_login_tokens
SET used_at = NOW (), used_ip = : used_ip , used_user_agent = : used_user_agent
WHERE id = : id
" );
$upd -> execute ([
':id' => ( int ) $row [ 'id' ],
':used_ip' => $this -> clientIp (),
':used_user_agent' => substr (( string )( $_SERVER [ 'HTTP_USER_AGENT' ] ? ? '' ), 0 , 255 ),
]);
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$_SESSION [ 'ac_store_customer_email' ] = strtolower ( trim (( string )( $row [ 'email' ] ? ? '' )));
} catch ( Throwable $e ) {
return new Response ( '' , 302 , [ 'Location' => '/account?error=Unable+to+complete+login' ]);
}
return new Response ( '' , 302 , [ 'Location' => '/account?message=Signed+in+successfully' ]);
}
public function accountLogout () : Response
{
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
unset ( $_SESSION [ 'ac_store_customer_email' ]);
return new Response ( '' , 302 , [ 'Location' => '/account?message=You+have+been+signed+out' ]);
}
public function cartAdd () : Response
{
$itemType = trim (( string )( $_POST [ 'item_type' ] ? ? 'track' ));
if ( ! in_array ( $itemType , [ 'track' , 'release' ], true )) {
$itemType = 'track' ;
}
$itemId = ( int )( $_POST [ 'item_id' ] ? ? 0 );
$title = trim (( string )( $_POST [ 'title' ] ? ? 'Item' ));
$coverUrl = trim (( string )( $_POST [ 'cover_url' ] ? ? '' ));
$currency = strtoupper ( trim (( string )( $_POST [ 'currency' ] ? ? 'GBP' )));
$price = ( float )( $_POST [ 'price' ] ? ? 0 );
$qty = max ( 1 , ( int )( $_POST [ 'qty' ] ? ? 1 ));
$returnUrl = trim (( string )( $_POST [ 'return_url' ] ? ? '/releases' ));
$itemTitle = $title !== '' ? $title : 'Item' ;
if ( $itemId <= 0 || $price <= 0 ) {
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
if ( ! preg_match ( '/^[A-Z]{3}$/' , $currency )) {
$currency = 'GBP' ;
}
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$cart = $_SESSION [ 'ac_cart' ] ? ? [];
if ( ! is_array ( $cart )) {
$cart = [];
}
2026-03-05 14:18:20 +00:00
$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 )]);
}
}
2026-03-04 20:46:11 +00:00
if ( $itemType === 'release' ) {
if ( $db instanceof PDO ) {
try {
$trackStmt = $db -> prepare ( "
SELECT t . id ,
t . title ,
t . mix_name ,
COALESCE ( sp . track_price , 0.00 ) AS track_price ,
COALESCE ( sp . currency , : currency ) AS currency
FROM ac_release_tracks t
JOIN ac_store_track_products sp ON sp . release_track_id = t . id
WHERE t . release_id = : release_id
AND sp . is_enabled = 1
AND sp . track_price > 0
ORDER BY t . track_no ASC , t . id ASC
" );
$trackStmt -> execute ([
':release_id' => $itemId ,
':currency' => $currency ,
]);
$trackRows = $trackStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
if ( $trackRows ) {
$releaseKey = 'release:' . $itemId ;
$removedTracks = 0 ;
foreach ( $trackRows as $row ) {
$trackId = ( int )( $row [ 'id' ] ? ? 0 );
if ( $trackId <= 0 ) {
continue ;
}
$trackKey = 'track:' . $trackId ;
if ( isset ( $cart [ $trackKey ])) {
unset ( $cart [ $trackKey ]);
$removedTracks ++ ;
}
}
$cart [ $releaseKey ] = [
'key' => $releaseKey ,
'item_type' => 'release' ,
'item_id' => $itemId ,
'title' => $itemTitle ,
'cover_url' => $coverUrl ,
'price' => $price ,
'currency' => $currency ,
'qty' => 1 ,
];
$_SESSION [ 'ac_cart' ] = $cart ;
$msg = '"' . $itemTitle . '" added as full release.' ;
if ( $removedTracks > 0 ) {
$msg .= ' Removed ' . $removedTracks . ' individual track' . ( $removedTracks === 1 ? '' : 's' ) . ' from cart.' ;
}
$_SESSION [ 'ac_site_notice' ] = [ 'type' => 'ok' , 'text' => $msg ];
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
} catch ( Throwable $e ) {
}
}
}
$key = $itemType . ':' . $itemId ;
if ( isset ( $cart [ $key ]) && is_array ( $cart [ $key ])) {
if ( $itemType === 'track' ) {
$_SESSION [ 'ac_site_notice' ] = [
'type' => 'info' ,
'text' => '"' . $itemTitle . '" is already in your cart.' ,
];
} else {
$cart [ $key ][ 'qty' ] = max ( 1 , ( int )( $cart [ $key ][ 'qty' ] ? ? 1 )) + $qty ;
$cart [ $key ][ 'price' ] = $price ;
$cart [ $key ][ 'currency' ] = $currency ;
$cart [ $key ][ 'title' ] = $itemTitle ;
if ( $coverUrl !== '' ) {
$cart [ $key ][ 'cover_url' ] = $coverUrl ;
}
$_SESSION [ 'ac_site_notice' ] = [
'type' => 'ok' ,
'text' => '"' . $itemTitle . '" quantity updated in your cart.' ,
];
}
} else {
$cart [ $key ] = [
'key' => $key ,
'item_type' => $itemType ,
'item_id' => $itemId ,
'title' => $itemTitle ,
'cover_url' => $coverUrl ,
'price' => $price ,
'currency' => $currency ,
'qty' => $qty ,
];
$_SESSION [ 'ac_site_notice' ] = [
'type' => 'ok' ,
'text' => '"' . $itemTitle . '" added to your cart.' ,
];
}
$_SESSION [ 'ac_cart' ] = $cart ;
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
public function cartApplyDiscount () : Response
{
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$code = strtoupper ( trim (( string )( $_POST [ 'discount_code' ] ? ? '' )));
$returnUrl = trim (( string )( $_POST [ 'return_url' ] ? ? '/cart' ));
if ( $code === '' ) {
$_SESSION [ 'ac_site_notice' ] = [ 'type' => 'info' , 'text' => 'Enter a discount code first.' ];
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
$_SESSION [ 'ac_site_notice' ] = [ 'type' => 'info' , 'text' => 'Discount service unavailable right now.' ];
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
$this -> ensureDiscountSchema ();
$discount = $this -> loadActiveDiscount ( $db , $code );
if ( ! $discount ) {
$_SESSION [ 'ac_site_notice' ] = [ 'type' => 'info' , 'text' => 'That discount code is invalid or expired.' ];
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
$_SESSION [ 'ac_discount_code' ] = ( string ) $discount [ 'code' ];
$_SESSION [ 'ac_site_notice' ] = [ 'type' => 'ok' , 'text' => 'Discount code "' . ( string ) $discount [ 'code' ] . '" applied.' ];
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
public function cartClearDiscount () : Response
{
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$returnUrl = trim (( string )( $_POST [ 'return_url' ] ? ? '/cart' ));
unset ( $_SESSION [ 'ac_discount_code' ]);
$_SESSION [ 'ac_site_notice' ] = [ 'type' => 'info' , 'text' => 'Discount code removed.' ];
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
public function cartIndex () : Response
{
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$cart = $_SESSION [ 'ac_cart' ] ? ? [];
if ( ! is_array ( $cart )) {
$cart = [];
}
$items = array_values ( array_filter ( $cart , static function ( $item ) : bool {
return is_array ( $item );
}));
$db = Database :: get ();
2026-03-05 14:18:20 +00:00
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 ;
}
2026-03-04 20:46:11 +00:00
if ( $db instanceof PDO ) {
foreach ( $items as $idx => $item ) {
$cover = trim (( string )( $item [ 'cover_url' ] ? ? '' ));
if ( $cover !== '' ) {
continue ;
}
$itemType = ( string )( $item [ 'item_type' ] ? ? '' );
$itemId = ( int )( $item [ 'item_id' ] ? ? 0 );
if ( $itemId <= 0 ) {
continue ;
}
try {
if ( $itemType === 'track' ) {
$stmt = $db -> prepare ( "
SELECT r . cover_url
FROM ac_release_tracks t
JOIN ac_releases r ON r . id = t . release_id
WHERE t . id = : id
LIMIT 1
" );
$stmt -> execute ([ ':id' => $itemId ]);
} elseif ( $itemType === 'release' ) {
$stmt = $db -> prepare ( " SELECT cover_url FROM ac_releases WHERE id = :id LIMIT 1 " );
$stmt -> execute ([ ':id' => $itemId ]);
} else {
continue ;
}
$row = $stmt -> fetch ( PDO :: FETCH_ASSOC );
$coverUrl = trim (( string )( $row [ 'cover_url' ] ? ? '' ));
if ( $coverUrl !== '' ) {
$items [ $idx ][ 'cover_url' ] = $coverUrl ;
}
} catch ( Throwable $e ) {
}
}
}
$discountCode = strtoupper ( trim (( string )( $_SESSION [ 'ac_discount_code' ] ? ? '' )));
$totals = $this -> buildCartTotals ( $items , $discountCode );
if ( $totals [ 'discount_code' ] === '' ) {
unset ( $_SESSION [ 'ac_discount_code' ]);
} else {
$_SESSION [ 'ac_discount_code' ] = $totals [ 'discount_code' ];
}
return new Response ( $this -> view -> render ( 'site/cart.php' , [
'title' => 'Cart' ,
'items' => $items ,
'totals' => $totals ,
]));
}
public function cartRemove () : Response
{
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$key = trim (( string )( $_POST [ 'key' ] ? ? '' ));
$returnUrl = trim (( string )( $_POST [ 'return_url' ] ? ? '/cart' ));
if ( $key !== '' && isset ( $_SESSION [ 'ac_cart' ]) && is_array ( $_SESSION [ 'ac_cart' ])) {
unset ( $_SESSION [ 'ac_cart' ][ $key ]);
}
return new Response ( '' , 302 , [ 'Location' => $this -> safeReturnUrl ( $returnUrl )]);
}
public function checkoutIndex () : Response
{
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$cart = $_SESSION [ 'ac_cart' ] ? ? [];
if ( ! is_array ( $cart )) {
$cart = [];
}
$items = array_values ( array_filter ( $cart , static function ( $item ) : bool {
return is_array ( $item );
}));
2026-03-05 14:18:20 +00:00
$db = Database :: get ();
if ( $db instanceof PDO ) {
$filtered = [];
foreach ( $items as $item ) {
$itemType = ( string )( $item [ 'item_type' ] ? ? 'track' );
$itemId = ( int )( $item [ 'item_id' ] ? ? 0 );
if ( $itemId > 0 && $this -> isItemReleased ( $db , $itemType , $itemId )) {
$filtered [] = $item ;
continue ;
}
$key = ( string )( $item [ 'key' ] ? ? '' );
if ( $key !== '' && isset ( $_SESSION [ 'ac_cart' ][ $key ])) {
unset ( $_SESSION [ 'ac_cart' ][ $key ]);
}
}
$items = $filtered ;
}
2026-03-04 20:46:11 +00:00
$discountCode = strtoupper ( trim (( string )( $_SESSION [ 'ac_discount_code' ] ? ? '' )));
$totals = $this -> buildCartTotals ( $items , $discountCode );
if ( $totals [ 'discount_code' ] === '' ) {
unset ( $_SESSION [ 'ac_discount_code' ]);
} else {
$_SESSION [ 'ac_discount_code' ] = $totals [ 'discount_code' ];
}
$total = ( float ) $totals [ 'amount' ];
$currency = ( string ) $totals [ 'currency' ];
$downloadLinks = [];
$downloadNotice = '' ;
$success = ( string )( $_GET [ 'success' ] ? ? '' );
$orderNo = ( string )( $_GET [ 'order_no' ] ? ? '' );
if ( $success !== '' && $orderNo !== '' ) {
if ( $db instanceof PDO ) {
try {
$orderStmt = $db -> prepare ( " SELECT id, status, email FROM ac_store_orders WHERE order_no = :order_no LIMIT 1 " );
$orderStmt -> execute ([ ':order_no' => $orderNo ]);
$orderRow = $orderStmt -> fetch ( PDO :: FETCH_ASSOC );
if ( $orderRow ) {
$orderId = ( int )( $orderRow [ 'id' ] ? ? 0 );
$orderStatus = ( string )( $orderRow [ 'status' ] ? ? '' );
if ( $orderId > 0 ) {
$tokenStmt = $db -> prepare ( "
SELECT t . token , f . file_name
FROM ac_store_download_tokens t
JOIN ac_store_files f ON f . id = t . file_id
WHERE t . order_id = : order_id
ORDER BY t . id DESC
" );
$tokenStmt -> execute ([ ':order_id' => $orderId ]);
$rows = $tokenStmt -> fetchAll ( PDO :: FETCH_ASSOC );
foreach ( $rows as $row ) {
$token = trim (( string )( $row [ 'token' ] ? ? '' ));
if ( $token === '' ) {
continue ;
}
$downloadLinks [] = [
'label' => ( string )( $row [ 'file_name' ] ? ? 'Download' ),
'url' => '/store/download?token=' . rawurlencode ( $token ),
];
}
if ( ! $downloadLinks ) {
$downloadNotice = $orderStatus === 'paid'
? 'No downloadable files are attached to this order yet.'
: 'Download links will appear here once payment is confirmed.' ;
}
}
}
} catch ( Throwable $e ) {
$downloadNotice = 'Unable to load download links right now.' ;
}
}
}
return new Response ( $this -> view -> render ( 'site/checkout.php' , [
'title' => 'Checkout' ,
'items' => $items ,
'total' => $total ,
'subtotal' => ( float ) $totals [ 'subtotal' ],
'discount_amount' => ( float ) $totals [ 'discount_amount' ],
'discount_code' => ( string ) $totals [ 'discount_code' ],
'currency' => $currency ,
'success' => $success ,
'order_no' => $orderNo ,
'error' => ( string )( $_GET [ 'error' ] ? ? '' ),
'download_links' => $downloadLinks ,
'download_notice' => $downloadNotice ,
'download_limit' => ( int ) Settings :: get ( 'store_download_limit' , '5' ),
'download_expiry_days' => ( int ) Settings :: get ( 'store_download_expiry_days' , '30' ),
]));
}
public function checkoutSandbox () : Response
{
return $this -> checkoutPlace ();
}
public function checkoutPlace () : Response
{
if ( ! isset ( $_POST [ 'accept_terms' ])) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=Please+accept+the+terms+to+continue' ]);
}
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$cart = $_SESSION [ 'ac_cart' ] ? ? [];
if ( ! is_array ( $cart ) || ! $cart ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout' ]);
}
$items = array_values ( array_filter ( $cart , static function ( $item ) : bool {
return is_array ( $item );
}));
if ( ! $items ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout' ]);
}
$discountCode = strtoupper ( trim (( string )( $_SESSION [ 'ac_discount_code' ] ? ? '' )));
$totals = $this -> buildCartTotals ( $items , $discountCode );
$currency = ( string ) $totals [ 'currency' ];
$subtotal = ( float ) $totals [ 'subtotal' ];
$discountAmount = ( float ) $totals [ 'discount_amount' ];
$total = ( float ) $totals [ 'amount' ];
$appliedDiscountCode = ( string ) $totals [ 'discount_code' ];
$testMode = $this -> isEnabledSetting ( Settings :: get ( 'store_test_mode' , '1' ));
$paypalEnabled = $this -> isEnabledSetting ( Settings :: get ( 'store_paypal_enabled' , '0' ));
$orderPrefix = $this -> sanitizeOrderPrefix (( string ) Settings :: get ( 'store_order_prefix' , 'AC-ORD' ));
$orderNo = $orderPrefix . '-' . date ( 'YmdHis' ) . '-' . random_int ( 100 , 999 );
$email = trim (( string )( $_POST [ 'email' ] ? ? '' ));
if ( ! filter_var ( $email , FILTER_VALIDATE_EMAIL )) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=Please+enter+a+valid+email+address' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/checkout' ]);
}
2026-03-05 14:18:20 +00:00
$validItems = [];
$removed = 0 ;
foreach ( $items as $item ) {
$itemType = ( string )( $item [ 'item_type' ] ? ? 'track' );
$itemId = ( int )( $item [ 'item_id' ] ? ? 0 );
if ( $itemId > 0 && $this -> isItemReleased ( $db , $itemType , $itemId )) {
$validItems [] = $item ;
continue ;
}
$removed ++ ;
$key = ( string )( $item [ 'key' ] ? ? '' );
if ( $key !== '' && isset ( $_SESSION [ 'ac_cart' ][ $key ])) {
unset ( $_SESSION [ 'ac_cart' ][ $key ]);
}
}
if ( ! $validItems ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=Selected+items+are+not+yet+released' ]);
}
if ( $removed > 0 ) {
$_SESSION [ 'ac_site_notice' ] = [
'type' => 'info' ,
'text' => 'Some unreleased items were removed from your cart.' ,
];
}
$items = $validItems ;
2026-03-04 20:46:11 +00:00
$this -> ensureAnalyticsSchema ();
$customerIp = $this -> clientIp ();
$customerUserAgent = substr (( string )( $_SERVER [ 'HTTP_USER_AGENT' ] ? ? '' ), 0 , 255 );
try {
$customerId = $this -> upsertCustomerFromOrder ( $db , $email , $customerIp , $customerUserAgent , $total );
$db -> beginTransaction ();
$insOrder = $db -> prepare ( "
INSERT INTO ac_store_orders
( order_no , customer_id , email , status , currency , subtotal , total , discount_code , discount_amount , payment_provider , payment_ref , customer_ip , customer_user_agent , created_at , updated_at )
VALUES ( : order_no , : customer_id , : email , : status , : currency , : subtotal , : total , : discount_code , : discount_amount , : provider , : payment_ref , : customer_ip , : customer_user_agent , NOW (), NOW ())
" );
$insOrder -> execute ([
':order_no' => $orderNo ,
':customer_id' => $customerId > 0 ? $customerId : null ,
':email' => $email ,
':status' => $paypalEnabled ? 'pending' : ( $testMode ? 'paid' : 'pending' ),
':currency' => $currency ,
':subtotal' => $subtotal ,
':total' => $total ,
':discount_code' => $appliedDiscountCode !== '' ? $appliedDiscountCode : null ,
':discount_amount' => $discountAmount > 0 ? $discountAmount : null ,
':provider' => $paypalEnabled ? 'paypal' : ( $testMode ? 'test' : 'checkout' ),
':payment_ref' => $paypalEnabled ? null : ( $testMode ? 'test' : null ),
':customer_ip' => $customerIp ,
':customer_user_agent' => $customerUserAgent !== '' ? $customerUserAgent : null ,
]);
$orderId = ( int ) $db -> lastInsertId ();
$insItem = $db -> prepare ( "
INSERT INTO ac_store_order_items
( order_id , item_type , item_id , title_snapshot , unit_price_snapshot , currency_snapshot , qty , line_total , created_at )
VALUES ( : order_id , : item_type , : item_id , : title , : price , : currency , : qty , : line_total , NOW ())
" );
foreach ( $items as $item ) {
$qty = max ( 1 , ( int )( $item [ 'qty' ] ? ? 1 ));
$price = ( float )( $item [ 'price' ] ? ? 0 );
$insItem -> execute ([
':order_id' => $orderId ,
':item_type' => ( string )( $item [ 'item_type' ] ? ? 'track' ),
':item_id' => ( int )( $item [ 'item_id' ] ? ? 0 ),
':title' => ( string )( $item [ 'title' ] ? ? 'Item' ),
':price' => $price ,
':currency' => ( string )( $item [ 'currency' ] ? ? $currency ),
':qty' => $qty ,
':line_total' => ( $price * $qty ),
]);
}
$db -> commit ();
if ( $total <= 0.0 ) {
try {
$upd = $db -> prepare ( "
UPDATE ac_store_orders
SET status = 'paid' , payment_provider = 'discount' , payment_ref = : payment_ref , updated_at = NOW ()
WHERE id = : id
" );
$upd -> execute ([
':payment_ref' => 'discount-zero-total' ,
':id' => $orderId ,
]);
} catch ( Throwable $e ) {
}
$_SESSION [ 'ac_last_order_no' ] = $orderNo ;
$downloadLinksHtml = $this -> provisionDownloadTokens ( $db , $orderId , $email , 'paid' );
$this -> sendOrderEmail ( $email , $orderNo , $currency , 0.0 , $items , 'paid' , $downloadLinksHtml );
if ( $appliedDiscountCode !== '' ) {
$this -> bumpDiscountUsage ( $db , $appliedDiscountCode );
}
$this -> rebuildSalesChartCache ();
$_SESSION [ 'ac_cart' ] = [];
unset ( $_SESSION [ 'ac_discount_code' ]);
return new Response ( '' , 302 , [ 'Location' => '/checkout?success=1&order_no=' . rawurlencode ( $orderNo )]);
}
if ( $paypalEnabled ) {
$clientId = trim (( string ) Settings :: get ( 'store_paypal_client_id' , '' ));
$secret = trim (( string ) Settings :: get ( 'store_paypal_secret' , '' ));
if ( $clientId === '' || $secret === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=PayPal+is+enabled+but+credentials+are+missing' ]);
}
$returnUrl = $this -> baseUrl () . '/checkout/paypal/return' ;
$cancelUrl = $this -> baseUrl () . '/checkout/paypal/cancel' ;
$create = $this -> paypalCreateOrder (
$clientId ,
$secret ,
$testMode ,
$currency ,
$total ,
$orderNo ,
$returnUrl ,
$cancelUrl
);
if ( ! ( $create [ 'ok' ] ? ? false )) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=' . rawurlencode (( string )( $create [ 'error' ] ? ? 'Unable to start PayPal checkout' ))]);
}
$paypalOrderId = ( string )( $create [ 'order_id' ] ? ? '' );
$approvalUrl = ( string )( $create [ 'approval_url' ] ? ? '' );
if ( $paypalOrderId === '' || $approvalUrl === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=PayPal+checkout+did+not+return+approval+url' ]);
}
try {
$upd = $db -> prepare ( "
UPDATE ac_store_orders
SET payment_provider = 'paypal' , payment_ref = : payment_ref , updated_at = NOW ()
WHERE id = : id
" );
$upd -> execute ([
':payment_ref' => $paypalOrderId ,
':id' => $orderId ,
]);
} catch ( Throwable $e ) {
}
return new Response ( '' , 302 , [ 'Location' => $approvalUrl ]);
}
$_SESSION [ 'ac_last_order_no' ] = $orderNo ;
$status = $testMode ? 'paid' : 'pending' ;
$downloadLinksHtml = $this -> provisionDownloadTokens ( $db , $orderId , $email , $status );
$this -> sendOrderEmail ( $email , $orderNo , $currency , $total , $items , $status , $downloadLinksHtml );
if ( $testMode ) {
if ( $appliedDiscountCode !== '' ) {
$this -> bumpDiscountUsage ( $db , $appliedDiscountCode );
}
$this -> rebuildSalesChartCache ();
$_SESSION [ 'ac_cart' ] = [];
unset ( $_SESSION [ 'ac_discount_code' ]);
return new Response ( '' , 302 , [ 'Location' => '/checkout?success=1&order_no=' . rawurlencode ( $orderNo )]);
}
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=No+live+payment+gateway+is+enabled' ]);
} catch ( Throwable $e ) {
if ( $db -> inTransaction ()) {
$db -> rollBack ();
}
return new Response ( '' , 302 , [ 'Location' => '/checkout' ]);
}
}
public function checkoutPaypalReturn () : Response
{
$paypalOrderId = trim (( string )( $_GET [ 'token' ] ? ? '' ));
if ( $paypalOrderId === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=Missing+PayPal+order+token' ]);
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=Database+unavailable' ]);
}
$orderStmt = $db -> prepare ( "
SELECT id , order_no , email , status , currency , total , discount_code
FROM ac_store_orders
WHERE payment_provider = 'paypal' AND payment_ref = : payment_ref
LIMIT 1
" );
$orderStmt -> execute ([ ':payment_ref' => $paypalOrderId ]);
$order = $orderStmt -> fetch ( PDO :: FETCH_ASSOC );
if ( ! $order ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=Order+not+found+for+PayPal+payment' ]);
}
$orderId = ( int )( $order [ 'id' ] ? ? 0 );
$orderNo = ( string )( $order [ 'order_no' ] ? ? '' );
$email = ( string )( $order [ 'email' ] ? ? '' );
$status = ( string )( $order [ 'status' ] ? ? 'pending' );
if ( $orderId <= 0 || $orderNo === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=Invalid+order+record' ]);
}
if ( $status === 'paid' ) {
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$_SESSION [ 'ac_last_order_no' ] = $orderNo ;
$_SESSION [ 'ac_cart' ] = [];
unset ( $_SESSION [ 'ac_discount_code' ]);
return new Response ( '' , 302 , [ 'Location' => '/checkout?success=1&order_no=' . rawurlencode ( $orderNo )]);
}
$clientId = trim (( string ) Settings :: get ( 'store_paypal_client_id' , '' ));
$secret = trim (( string ) Settings :: get ( 'store_paypal_secret' , '' ));
if ( $clientId === '' || $secret === '' ) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=PayPal+credentials+missing' ]);
}
$capture = $this -> paypalCaptureOrder (
$clientId ,
$secret ,
$this -> isEnabledSetting ( Settings :: get ( 'store_test_mode' , '1' )),
$paypalOrderId
);
if ( ! ( $capture [ 'ok' ] ? ? false )) {
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=' . rawurlencode (( string )( $capture [ 'error' ] ? ? 'PayPal capture failed' ))]);
}
try {
$captureRef = trim (( string )( $capture [ 'capture_id' ] ? ? '' ));
$upd = $db -> prepare ( "
UPDATE ac_store_orders
SET status = 'paid' , payment_ref = : payment_ref , updated_at = NOW ()
WHERE id = : id
" );
$upd -> execute ([
':id' => $orderId ,
':payment_ref' => $captureRef !== '' ? $captureRef : $paypalOrderId ,
]);
} catch ( Throwable $e ) {
}
$itemsForEmail = $this -> orderItemsForEmail ( $db , $orderId );
$downloadLinksHtml = $this -> provisionDownloadTokens ( $db , $orderId , $email , 'paid' );
$discountCode = trim (( string )( $order [ 'discount_code' ] ? ? '' ));
if ( $discountCode !== '' ) {
$this -> bumpDiscountUsage ( $db , $discountCode );
}
$this -> rebuildSalesChartCache ();
$this -> sendOrderEmail (
$email ,
$orderNo ,
( string )( $order [ 'currency' ] ? ? 'GBP' ),
( float )( $order [ 'total' ] ? ? 0 ),
$itemsForEmail ,
'paid' ,
$downloadLinksHtml
);
if ( session_status () !== PHP_SESSION_ACTIVE ) {
session_start ();
}
$_SESSION [ 'ac_last_order_no' ] = $orderNo ;
$_SESSION [ 'ac_cart' ] = [];
unset ( $_SESSION [ 'ac_discount_code' ]);
return new Response ( '' , 302 , [ 'Location' => '/checkout?success=1&order_no=' . rawurlencode ( $orderNo )]);
}
public function checkoutPaypalCancel () : Response
{
return new Response ( '' , 302 , [ 'Location' => '/checkout?error=PayPal+checkout+was+cancelled' ]);
}
public function download () : Response
{
$token = trim (( string )( $_GET [ 'token' ] ? ? '' ));
if ( $token === '' ) {
return new Response ( 'Invalid download token.' , 400 );
}
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return new Response ( 'Download service unavailable.' , 500 );
}
$this -> ensureAnalyticsSchema ();
try {
$stmt = $db -> prepare ( "
SELECT t . id , t . order_id , t . order_item_id , t . file_id , t . email , t . download_limit , t . downloads_used , t . expires_at ,
f . file_url , f . file_name , f . mime_type , o . status AS order_status
FROM ac_store_download_tokens t
JOIN ac_store_files f ON f . id = t . file_id
JOIN ac_store_orders o ON o . id = t . order_id
WHERE t . token = : token
LIMIT 1
" );
$stmt -> execute ([ ':token' => $token ]);
$row = $stmt -> fetch ( PDO :: FETCH_ASSOC );
if ( ! $row ) {
return new Response ( 'Download link is invalid.' , 404 );
}
$orderStatus = strtolower ( trim (( string )( $row [ 'order_status' ] ? ? '' )));
if ( $orderStatus !== 'paid' ) {
return new Response ( 'Downloads are no longer available for this order.' , 410 );
}
$limit = ( int )( $row [ 'download_limit' ] ? ? 0 );
$used = ( int )( $row [ 'downloads_used' ] ? ? 0 );
if ( $limit > 0 && $used >= $limit ) {
return new Response ( 'Download limit reached.' , 410 );
}
$expiresAt = ( string )( $row [ 'expires_at' ] ? ? '' );
if ( $expiresAt !== '' ) {
try {
if ( new \DateTimeImmutable ( 'now' ) > new \DateTimeImmutable ( $expiresAt )) {
return new Response ( 'Download link expired.' , 410 );
}
} catch ( Throwable $e ) {
}
}
$relative = ltrim (( string )( $row [ 'file_url' ] ? ? '' ), '/' );
if ( $relative === '' || str_contains ( $relative , '..' )) {
return new Response ( 'Invalid file path.' , 400 );
}
$root = rtrim (( string ) Settings :: get ( 'store_private_root' , $this -> privateRoot ()), '/' );
$path = $root . '/' . $relative ;
if ( ! is_file ( $path ) || ! is_readable ( $path )) {
return new Response ( 'File not found.' , 404 );
}
$upd = $db -> prepare ( " UPDATE ac_store_download_tokens SET downloads_used = downloads_used + 1 WHERE id = :id " );
$upd -> execute ([ ':id' => ( int ) $row [ 'id' ]]);
try {
$evt = $db -> prepare ( "
INSERT INTO ac_store_download_events
( token_id , order_id , order_item_id , file_id , email , ip_address , user_agent , downloaded_at )
VALUES ( : token_id , : order_id , : order_item_id , : file_id , : email , : ip_address , : user_agent , NOW ())
" );
$evt -> execute ([
':token_id' => ( int )( $row [ 'id' ] ? ? 0 ),
':order_id' => ( int )( $row [ 'order_id' ] ? ? 0 ),
':order_item_id' => ( int )( $row [ 'order_item_id' ] ? ? 0 ),
':file_id' => ( int )( $row [ 'file_id' ] ? ? 0 ),
':email' => ( string )( $row [ 'email' ] ? ? '' ),
':ip_address' => $this -> clientIp (),
':user_agent' => substr (( string )( $_SERVER [ 'HTTP_USER_AGENT' ] ? ? '' ), 0 , 255 ),
]);
} catch ( Throwable $e ) {
}
$fileName = ( string )( $row [ 'file_name' ] ? ? basename ( $path ));
$mime = ( string )( $row [ 'mime_type' ] ? ? '' );
if ( $mime === '' ) {
$mime = 'application/octet-stream' ;
}
header ( 'Content-Type: ' . $mime );
header ( 'Content-Length: ' . ( string ) filesize ( $path ));
header ( 'Content-Disposition: attachment; filename="' . str_replace ( '"' , '' , $fileName ) . '"' );
readfile ( $path );
exit ;
} catch ( Throwable $e ) {
return new Response ( 'Download failed.' , 500 );
}
}
private function guard () : ? Response
{
if ( ! Auth :: check ()) {
return new Response ( '' , 302 , [ 'Location' => '/admin/login' ]);
}
if ( ! Auth :: hasRole ([ 'admin' , 'manager' ])) {
return new Response ( '' , 302 , [ 'Location' => '/admin' ]);
}
return null ;
}
private function ensureAnalyticsSchema () : void
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return ;
}
$this -> ensureSalesChartSchema ();
try {
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_download_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
token_id INT UNSIGNED NOT NULL ,
order_id INT UNSIGNED NOT NULL ,
order_item_id INT UNSIGNED NOT NULL ,
file_id INT UNSIGNED NOT NULL ,
email VARCHAR ( 190 ) NULL ,
ip_address VARCHAR ( 64 ) NULL ,
user_agent VARCHAR ( 255 ) NULL ,
downloaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
KEY idx_store_download_events_order ( order_id ),
KEY idx_store_download_events_item ( order_item_id ),
KEY idx_store_download_events_ip ( ip_address )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_login_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
email VARCHAR ( 190 ) NOT NULL ,
token_hash CHAR ( 64 ) NOT NULL UNIQUE ,
expires_at DATETIME NOT NULL ,
used_at DATETIME NULL ,
request_ip VARCHAR ( 64 ) NULL ,
request_user_agent VARCHAR ( 255 ) NULL ,
used_ip VARCHAR ( 64 ) NULL ,
used_user_agent VARCHAR ( 255 ) NULL ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
KEY idx_store_login_tokens_email ( email ),
KEY idx_store_login_tokens_expires ( expires_at )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_customers ADD COLUMN last_order_ip VARCHAR(64) NULL AFTER is_active " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_customers ADD COLUMN last_order_user_agent VARCHAR(255) NULL AFTER last_order_ip " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_customers ADD COLUMN last_seen_at DATETIME NULL AFTER last_order_user_agent " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_customers ADD COLUMN orders_count INT UNSIGNED NOT NULL DEFAULT 0 AFTER last_seen_at " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_customers ADD COLUMN total_spent DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER orders_count " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN customer_ip VARCHAR(64) NULL AFTER payment_ref " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN customer_user_agent VARCHAR(255) NULL AFTER customer_ip " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN discount_code VARCHAR(64) NULL AFTER total " );
} catch ( Throwable $e ) {
}
try {
$db -> exec ( " ALTER TABLE ac_store_orders ADD COLUMN discount_amount DECIMAL(10,2) NULL AFTER discount_code " );
} catch ( Throwable $e ) {
}
}
private function tablesReady () : bool
{
$db = Database :: get ();
if ( ! $db instanceof PDO ) {
return false ;
}
try {
$db -> query ( " SELECT 1 FROM ac_store_release_products LIMIT 1 " );
$db -> query ( " SELECT 1 FROM ac_store_track_products LIMIT 1 " );
$db -> query ( " SELECT 1 FROM ac_store_files LIMIT 1 " );
$db -> query ( " SELECT 1 FROM ac_store_orders LIMIT 1 " );
return true ;
} catch ( Throwable $e ) {
return false ;
}
}
private function privateRoot () : string
{
return '/home/audiocore.site/private_downloads' ;
}
private function ensurePrivateRoot ( string $path ) : bool
{
$path = rtrim ( $path , '/' );
if ( $path === '' ) {
return false ;
}
if ( ! is_dir ( $path ) && ! mkdir ( $path , 0755 , true )) {
return false ;
}
if ( ! is_writable ( $path )) {
return false ;
}
$tracks = $path . '/tracks' ;
if ( ! is_dir ( $tracks ) && ! mkdir ( $tracks , 0755 , true )) {
return false ;
}
return is_writable ( $tracks );
}
private function privateRootReady () : bool
{
$path = Settings :: get ( 'store_private_root' , $this -> privateRoot ());
$path = rtrim ( $path , '/' );
if ( $path === '' || ! is_dir ( $path ) || ! is_writable ( $path )) {
return false ;
}
$tracks = $path . '/tracks' ;
return is_dir ( $tracks ) && is_writable ( $tracks );
}
private function settingsPayload () : array
{
$cronKey = trim (( string ) Settings :: get ( 'store_sales_chart_cron_key' , '' ));
if ( $cronKey === '' ) {
try {
$cronKey = bin2hex ( random_bytes ( 24 ));
Settings :: set ( 'store_sales_chart_cron_key' , $cronKey );
} catch ( Throwable $e ) {
$cronKey = '' ;
}
}
return [
'store_currency' => Settings :: get ( 'store_currency' , 'GBP' ),
'store_private_root' => Settings :: get ( 'store_private_root' , $this -> privateRoot ()),
'store_download_limit' => Settings :: get ( 'store_download_limit' , '5' ),
'store_download_expiry_days' => Settings :: get ( 'store_download_expiry_days' , '30' ),
'store_order_prefix' => Settings :: get ( 'store_order_prefix' , 'AC-ORD' ),
2026-03-05 15:27:58 +00:00
'store_timezone' => Settings :: get ( 'store_timezone' , 'UTC' ),
2026-03-04 20:46:11 +00:00
'store_test_mode' => Settings :: get ( 'store_test_mode' , '1' ),
'store_stripe_enabled' => Settings :: get ( 'store_stripe_enabled' , '0' ),
'store_stripe_public_key' => Settings :: get ( 'store_stripe_public_key' , '' ),
'store_stripe_secret_key' => Settings :: get ( 'store_stripe_secret_key' , '' ),
'store_paypal_enabled' => Settings :: get ( 'store_paypal_enabled' , '0' ),
'store_paypal_client_id' => Settings :: get ( 'store_paypal_client_id' , '' ),
'store_paypal_secret' => Settings :: get ( 'store_paypal_secret' , '' ),
'store_email_logo_url' => Settings :: get ( 'store_email_logo_url' , '' ),
'store_order_email_subject' => Settings :: get ( 'store_order_email_subject' , 'Your AudioCore order {{order_no}}' ),
'store_order_email_html' => Settings :: get ( 'store_order_email_html' , $this -> defaultOrderEmailHtml ()),
'store_sales_chart_default_scope' => Settings :: get ( 'store_sales_chart_default_scope' , 'tracks' ),
'store_sales_chart_default_window' => Settings :: get ( 'store_sales_chart_default_window' , 'latest' ),
'store_sales_chart_limit' => Settings :: get ( 'store_sales_chart_limit' , '10' ),
'store_sales_chart_latest_hours' => Settings :: get ( 'store_sales_chart_latest_hours' , '24' ),
'store_sales_chart_refresh_minutes' => Settings :: get ( 'store_sales_chart_refresh_minutes' , '180' ),
'store_sales_chart_cron_key' => $cronKey ,
];
}
2026-03-05 15:27:58 +00:00
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 ) {
}
}
2026-03-04 20:46:11 +00:00
private function ensureSalesChartSchema () : void
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return ;
}
try {
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_sales_chart_cache (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
chart_scope ENUM ( 'tracks' , 'releases' ) NOT NULL ,
chart_window ENUM ( 'latest' , 'weekly' , 'all_time' ) NOT NULL ,
rank_no INT UNSIGNED NOT NULL ,
item_key VARCHAR ( 190 ) NOT NULL ,
item_label VARCHAR ( 255 ) NOT NULL ,
units INT UNSIGNED NOT NULL DEFAULT 0 ,
revenue DECIMAL ( 12 , 2 ) NOT NULL DEFAULT 0.00 ,
snapshot_from DATETIME NULL ,
snapshot_to DATETIME NULL ,
updated_at DATETIME NOT NULL ,
UNIQUE KEY uniq_sales_chart_rank ( chart_scope , chart_window , rank_no ),
KEY idx_sales_chart_item ( chart_scope , chart_window , item_key )
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
} catch ( Throwable $e ) {
}
}
public function rebuildSalesChartCache () : bool
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return false ;
}
$this -> ensureSalesChartSchema ();
$latestHours = max ( 1 , min ( 168 , ( int ) Settings :: get ( 'store_sales_chart_latest_hours' , '24' )));
$now = new \DateTimeImmutable ( 'now' );
$ranges = [
'latest' => [
'from' => $now -> modify ( '-' . $latestHours . ' hours' ) -> format ( 'Y-m-d H:i:s' ),
'to' => $now -> format ( 'Y-m-d H:i:s' ),
],
'weekly' => [
'from' => $now -> modify ( '-7 days' ) -> format ( 'Y-m-d H:i:s' ),
'to' => $now -> format ( 'Y-m-d H:i:s' ),
],
'all_time' => [
'from' => null ,
'to' => $now -> format ( 'Y-m-d H:i:s' ),
],
];
$maxRows = max ( 10 , min ( 100 , ( int ) Settings :: get ( 'store_sales_chart_limit' , '10' ) * 2 ));
try {
$db -> beginTransaction ();
$delete = $db -> prepare ( " DELETE FROM ac_store_sales_chart_cache WHERE chart_scope = :scope AND chart_window = :window " );
$insert = $db -> prepare ( "
INSERT INTO ac_store_sales_chart_cache
( chart_scope , chart_window , rank_no , item_key , item_label , units , revenue , snapshot_from , snapshot_to , updated_at )
VALUES ( : chart_scope , : chart_window , : rank_no , : item_key , : item_label , : units , : revenue , : snapshot_from , : snapshot_to , : updated_at )
" );
foreach ([ 'tracks' , 'releases' ] as $scope ) {
foreach ( $ranges as $window => $range ) {
$delete -> execute ([ ':scope' => $scope , ':window' => $window ]);
$rows = $scope === 'tracks'
? $this -> salesChartTrackRows ( $db , $range [ 'from' ], $maxRows )
: $this -> salesChartReleaseRows ( $db , $range [ 'from' ], $maxRows );
$rank = 1 ;
foreach ( $rows as $row ) {
$itemKey = trim (( string )( $row [ 'item_key' ] ? ? '' ));
$itemLabel = trim (( string )( $row [ 'item_label' ] ? ? '' ));
if ( $itemLabel === '' ) {
continue ;
}
if ( $itemKey === '' ) {
$itemKey = strtolower ( preg_replace ( '/[^a-z0-9]+/i' , '-' , $itemLabel ) ? ? '' );
}
$insert -> execute ([
':chart_scope' => $scope ,
':chart_window' => $window ,
':rank_no' => $rank ,
':item_key' => substr ( $itemKey , 0 , 190 ),
':item_label' => substr ( $itemLabel , 0 , 255 ),
':units' => max ( 0 , ( int )( $row [ 'units' ] ? ? 0 )),
':revenue' => round (( float )( $row [ 'revenue' ] ? ? 0 ), 2 ),
':snapshot_from' => $range [ 'from' ],
':snapshot_to' => $range [ 'to' ],
':updated_at' => $now -> format ( 'Y-m-d H:i:s' ),
]);
$rank ++ ;
}
}
}
$db -> commit ();
return true ;
} catch ( Throwable $e ) {
if ( $db -> inTransaction ()) {
$db -> rollBack ();
}
return false ;
}
}
private function salesChartTrackRows ( PDO $db , ? string $from , int $limit ) : array
{
$sql = "
SELECT
CONCAT ( 'track:' , CAST ( oi . item_id AS CHAR )) AS item_key ,
COALESCE ( NULLIF ( MAX ( rt . title ), '' ), MAX ( oi . title_snapshot )) AS item_label ,
SUM ( oi . qty ) AS units ,
SUM ( oi . line_total ) AS revenue
FROM ac_store_order_items oi
JOIN ac_store_orders o ON o . id = oi . order_id
LEFT JOIN ac_release_tracks rt ON oi . item_type = 'track' AND oi . item_id = rt . id
WHERE o . status = 'paid'
AND oi . item_type = 'track'
" ;
if ( $from !== null ) {
$sql .= " AND o.created_at >= :from " ;
}
$sql .= "
GROUP BY oi . item_id
ORDER BY units DESC , revenue DESC , item_label ASC
LIMIT : lim
" ;
$stmt = $db -> prepare ( $sql );
if ( $from !== null ) {
$stmt -> bindValue ( ':from' , $from , PDO :: PARAM_STR );
}
$stmt -> bindValue ( ':lim' , $limit , PDO :: PARAM_INT );
$stmt -> execute ();
return $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
}
private function salesChartReleaseRows ( PDO $db , ? string $from , int $limit ) : array
{
$sql = "
SELECT
CONCAT ( 'release:' , CAST ( COALESCE ( ri . release_id , r . id , oi . item_id ) AS CHAR )) AS item_key ,
COALESCE ( NULLIF ( MAX ( r . title ), '' ), NULLIF ( MAX ( rr . title ), '' ), MAX ( oi . title_snapshot )) AS item_label ,
SUM ( oi . qty ) AS units ,
SUM ( oi . line_total ) AS revenue
FROM ac_store_order_items oi
JOIN ac_store_orders o ON o . id = oi . order_id
LEFT JOIN ac_release_tracks rt ON oi . item_type = 'track' AND oi . item_id = rt . id
LEFT JOIN ac_releases rr ON oi . item_type = 'release' AND oi . item_id = rr . id
LEFT JOIN ac_releases r ON rt . release_id = r . id
LEFT JOIN (
SELECT id , id AS release_id FROM ac_releases
) ri ON oi . item_type = 'release' AND oi . item_id = ri . id
WHERE o . status = 'paid'
" ;
if ( $from !== null ) {
$sql .= " AND o.created_at >= :from " ;
}
$sql .= "
GROUP BY COALESCE ( ri . release_id , r . id , oi . item_id )
ORDER BY units DESC , revenue DESC , item_label ASC
LIMIT : lim
" ;
$stmt = $db -> prepare ( $sql );
if ( $from !== null ) {
$stmt -> bindValue ( ':from' , $from , PDO :: PARAM_STR );
}
$stmt -> bindValue ( ':lim' , $limit , PDO :: PARAM_INT );
$stmt -> execute ();
return $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
}
private function salesChartRows ( string $scope , string $window , int $limit ) : array
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return [];
}
try {
$stmt = $db -> prepare ( "
SELECT rank_no , item_label , units , revenue , snapshot_from , snapshot_to , updated_at
FROM ac_store_sales_chart_cache
WHERE chart_scope = : scope
AND chart_window = : window
ORDER BY rank_no ASC
LIMIT : lim
" );
$stmt -> bindValue ( ':scope' , $scope , PDO :: PARAM_STR );
$stmt -> bindValue ( ':window' , $window , PDO :: PARAM_STR );
$stmt -> bindValue ( ':lim' , $limit , PDO :: PARAM_INT );
$stmt -> execute ();
return $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
} catch ( Throwable $e ) {
return [];
}
}
private function salesChartLastRebuildAt () : ? string
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return null ;
}
try {
$stmt = $db -> query ( " SELECT MAX(updated_at) AS updated_at FROM ac_store_sales_chart_cache " );
$row = $stmt ? $stmt -> fetch ( PDO :: FETCH_ASSOC ) : null ;
$value = trim (( string )( $row [ 'updated_at' ] ? ? '' ));
return $value !== '' ? $value : null ;
} catch ( Throwable $e ) {
return null ;
}
}
private function salesChartCronUrl () : string
{
$base = $this -> baseUrl ();
$key = trim (( string ) Settings :: get ( 'store_sales_chart_cron_key' , '' ));
if ( $base === '' || $key === '' ) {
return '' ;
}
return $base . '/store/sales-chart/rebuild?key=' . rawurlencode ( $key );
}
private function salesChartCronCommand () : string
{
$url = $this -> salesChartCronUrl ();
$minutes = max ( 5 , min ( 1440 , ( int ) Settings :: get ( 'store_sales_chart_refresh_minutes' , '180' )));
$step = max ( 1 , ( int ) floor ( $minutes / 60 ));
$prefix = $minutes < 60 ? '*/' . $minutes . ' * * * *' : '0 */' . $step . ' * * *' ;
if ( $url === '' ) {
return '' ;
}
return $prefix . " /usr/bin/curl -fsS ' " . $url . " ' >/dev/null 2>&1 " ;
}
private function ensureDiscountSchema () : void
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return ;
}
try {
$db -> exec ( "
CREATE TABLE IF NOT EXISTS ac_store_discount_codes (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY ,
code VARCHAR ( 64 ) NOT NULL UNIQUE ,
discount_type ENUM ( 'percent' , 'fixed' ) NOT NULL DEFAULT 'percent' ,
discount_value DECIMAL ( 10 , 2 ) NOT NULL DEFAULT 0.00 ,
max_uses INT UNSIGNED NOT NULL DEFAULT 0 ,
used_count INT UNSIGNED NOT NULL DEFAULT 0 ,
expires_at DATETIME NULL ,
is_active TINYINT ( 1 ) NOT NULL DEFAULT 1 ,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 ;
" );
} catch ( Throwable $e ) {
}
}
private function adminDiscountRows () : array
{
$db = Database :: get ();
if ( ! ( $db instanceof PDO )) {
return [];
}
try {
$stmt = $db -> query ( "
SELECT id , code , discount_type , discount_value , max_uses , used_count , expires_at , is_active , created_at
FROM ac_store_discount_codes
ORDER BY created_at DESC
LIMIT 300
" );
return $stmt ? ( $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : []) : [];
} catch ( Throwable $e ) {
return [];
}
}
private function loadActiveDiscount ( PDO $db , string $code ) : ? array
{
try {
$stmt = $db -> prepare ( "
SELECT id , code , discount_type , discount_value , max_uses , used_count , expires_at , is_active
FROM ac_store_discount_codes
WHERE code = : code
LIMIT 1
" );
$stmt -> execute ([ ':code' => strtoupper ( trim ( $code ))]);
$row = $stmt -> fetch ( PDO :: FETCH_ASSOC );
if ( ! $row ) {
return null ;
}
if (( int )( $row [ 'is_active' ] ? ? 0 ) !== 1 ) {
return null ;
}
$maxUses = ( int )( $row [ 'max_uses' ] ? ? 0 );
$used = ( int )( $row [ 'used_count' ] ? ? 0 );
if ( $maxUses > 0 && $used >= $maxUses ) {
return null ;
}
$expires = trim (( string )( $row [ 'expires_at' ] ? ? '' ));
if ( $expires !== '' ) {
try {
if ( new \DateTimeImmutable ( 'now' ) > new \DateTimeImmutable ( $expires )) {
return null ;
}
} catch ( Throwable $e ) {
return null ;
}
}
return $row ;
} catch ( Throwable $e ) {
return null ;
}
}
private function buildCartTotals ( array $items , string $discountCode = '' ) : array
{
$totals = [
'count' => 0 ,
'subtotal' => 0.0 ,
'discount_amount' => 0.0 ,
'amount' => 0.0 ,
'currency' => Settings :: get ( 'store_currency' , 'GBP' ),
'discount_code' => '' ,
];
foreach ( $items as $item ) {
if ( ! is_array ( $item )) {
continue ;
}
$qty = max ( 1 , ( int )( $item [ 'qty' ] ? ? 1 ));
$price = ( float )( $item [ 'price' ] ? ? 0 );
$totals [ 'count' ] += $qty ;
$totals [ 'subtotal' ] += ( $price * $qty );
if ( ! empty ( $item [ 'currency' ])) {
$totals [ 'currency' ] = ( string ) $item [ 'currency' ];
}
}
$discountCode = strtoupper ( trim ( $discountCode ));
if ( $discountCode !== '' && $totals [ 'subtotal' ] > 0 ) {
$db = Database :: get ();
if ( $db instanceof PDO ) {
$this -> ensureDiscountSchema ();
$discount = $this -> loadActiveDiscount ( $db , $discountCode );
if ( $discount ) {
$discountType = ( string )( $discount [ 'discount_type' ] ? ? 'percent' );
$discountValue = ( float )( $discount [ 'discount_value' ] ? ? 0 );
if ( $discountType === 'fixed' ) {
$totals [ 'discount_amount' ] = min ( $totals [ 'subtotal' ], max ( 0 , round ( $discountValue , 2 )));
} else {
$percent = min ( 100 , max ( 0 , $discountValue ));
$totals [ 'discount_amount' ] = min ( $totals [ 'subtotal' ], round ( $totals [ 'subtotal' ] * ( $percent / 100 ), 2 ));
}
$totals [ 'discount_code' ] = ( string )( $discount [ 'code' ] ? ? '' );
}
}
}
$totals [ 'amount' ] = max ( 0 , round ( $totals [ 'subtotal' ] - $totals [ 'discount_amount' ], 2 ));
return $totals ;
}
private function sendOrderEmail ( string $to , string $orderNo , string $currency , float $total , array $items , string $status , string $downloadLinksHtml ) : void
{
if ( ! filter_var ( $to , FILTER_VALIDATE_EMAIL )) {
return ;
}
$subjectTpl = ( string ) Settings :: get ( 'store_order_email_subject' , 'Your AudioCore order {{order_no}}' );
$htmlTpl = ( string ) Settings :: get ( 'store_order_email_html' , $this -> defaultOrderEmailHtml ());
$itemsHtml = $this -> renderItemsHtml ( $items , $currency );
$siteName = ( string ) Settings :: get ( 'site_title' , Settings :: get ( 'footer_text' , 'AudioCore' ));
$logoUrl = trim (( string ) Settings :: get ( 'store_email_logo_url' , '' ));
$logoHtml = $logoUrl !== ''
? '<img src="' . htmlspecialchars ( $logoUrl , ENT_QUOTES , 'UTF-8' ) . '" alt="' . htmlspecialchars ( $siteName , ENT_QUOTES , 'UTF-8' ) . '" style="max-height:60px; width:auto;">'
: '' ;
if ( $downloadLinksHtml === '' ) {
$downloadLinksHtml = $status === 'paid'
? '<p>Your download links will be available in your account/downloads area.</p>'
: '<p>Download links are sent once payment is confirmed.</p>' ;
}
$map = [
'{{site_name}}' => htmlspecialchars ( $siteName , ENT_QUOTES , 'UTF-8' ),
'{{order_no}}' => htmlspecialchars ( $orderNo , ENT_QUOTES , 'UTF-8' ),
'{{customer_email}}' => htmlspecialchars ( $to , ENT_QUOTES , 'UTF-8' ),
'{{currency}}' => htmlspecialchars ( $currency , ENT_QUOTES , 'UTF-8' ),
'{{total}}' => number_format ( $total , 2 ),
'{{status}}' => htmlspecialchars ( $status , ENT_QUOTES , 'UTF-8' ),
'{{logo_url}}' => htmlspecialchars ( $logoUrl , ENT_QUOTES , 'UTF-8' ),
'{{logo_html}}' => $logoHtml ,
'{{items_html}}' => $itemsHtml ,
'{{download_links_html}}' => $downloadLinksHtml ,
];
$subject = strtr ( $subjectTpl , $map );
$html = strtr ( $htmlTpl , $map );
$mailSettings = [
'smtp_host' => Settings :: get ( 'smtp_host' , '' ),
'smtp_port' => Settings :: get ( 'smtp_port' , '587' ),
'smtp_user' => Settings :: get ( 'smtp_user' , '' ),
'smtp_pass' => Settings :: get ( 'smtp_pass' , '' ),
'smtp_encryption' => Settings :: get ( 'smtp_encryption' , 'tls' ),
'smtp_from_email' => Settings :: get ( 'smtp_from_email' , '' ),
'smtp_from_name' => Settings :: get ( 'smtp_from_name' , 'AudioCore' ),
];
Mailer :: send ( $to , $subject , $html , $mailSettings );
}
private function sanitizeOrderPrefix ( string $prefix ) : string
{
$prefix = strtoupper ( trim ( $prefix ));
$prefix = preg_replace ( '/[^A-Z0-9-]+/' , '-' , $prefix ) ? ? 'AC-ORD' ;
$prefix = trim ( $prefix , '-' );
if ( $prefix === '' ) {
return 'AC-ORD' ;
}
return substr ( $prefix , 0 , 20 );
}
private function provisionDownloadTokens ( PDO $db , int $orderId , string $email , string $status ) : string
{
if ( $status !== 'paid' ) {
return '<p>Download links are sent once payment is confirmed.</p>' ;
}
$downloadLimit = max ( 1 , ( int ) Settings :: get ( 'store_download_limit' , '5' ));
$expiryDays = max ( 1 , ( int ) Settings :: get ( 'store_download_expiry_days' , '30' ));
$expiresAt = ( new \DateTimeImmutable ( 'now' )) -> modify ( '+' . $expiryDays . ' days' ) -> format ( 'Y-m-d H:i:s' );
try {
$itemStmt = $db -> prepare ( " SELECT id, item_type, item_id, title_snapshot FROM ac_store_order_items WHERE order_id = :order_id ORDER BY id ASC " );
$itemStmt -> execute ([ ':order_id' => $orderId ]);
$orderItems = $itemStmt -> fetchAll ( PDO :: FETCH_ASSOC );
if ( ! $orderItems ) {
return '<p>No downloadable items in this order.</p>' ;
}
$tokenStmt = $db -> prepare ( "
INSERT INTO ac_store_download_tokens
( order_id , order_item_id , file_id , email , token , download_limit , downloads_used , expires_at , created_at )
VALUES ( : order_id , : order_item_id , : file_id , : email , : token , : download_limit , 0 , : expires_at , NOW ())
" );
$links = [];
foreach ( $orderItems as $item ) {
$type = ( string )( $item [ 'item_type' ] ? ? '' );
$itemId = ( int )( $item [ 'item_id' ] ? ? 0 );
$orderItemId = ( int )( $item [ 'id' ] ? ? 0 );
if ( $itemId <= 0 || $orderItemId <= 0 ) {
continue ;
}
$files = [];
if ( $type === 'release' ) {
$trackIdsStmt = $db -> prepare ( "
SELECT t . id
FROM ac_release_tracks t
JOIN ac_store_track_products sp ON sp . release_track_id = t . id
WHERE t . release_id = : release_id AND sp . is_enabled = 1
ORDER BY t . track_no ASC , t . id ASC
" );
$trackIdsStmt -> execute ([ ':release_id' => $itemId ]);
$trackIds = array_map ( static fn ( $r ) => ( int )( $r [ 'id' ] ? ? 0 ), $trackIdsStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : []);
$trackIds = array_values ( array_filter ( $trackIds , static fn ( $id ) => $id > 0 ));
if ( $trackIds ) {
$placeholders = implode ( ',' , array_fill ( 0 , count ( $trackIds ), '?' ));
$fileStmt = $db -> prepare ( "
SELECT id , file_name
FROM ac_store_files
WHERE scope_type = 'track' AND scope_id IN ( $placeholders ) AND is_active = 1
ORDER BY id DESC
" );
$fileStmt -> execute ( $trackIds );
$files = $fileStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
}
} else {
$fileStmt = $db -> prepare ( "
SELECT id , file_name
FROM ac_store_files
WHERE scope_type = 'track' AND scope_id = : scope_id AND is_active = 1
ORDER BY id DESC
" );
$fileStmt -> execute ([ ':scope_id' => $itemId ]);
$files = $fileStmt -> fetchAll ( PDO :: FETCH_ASSOC ) ? : [];
}
foreach ( $files as $file ) {
$fileId = ( int )( $file [ 'id' ] ? ? 0 );
if ( $fileId <= 0 ) {
continue ;
}
$token = bin2hex ( random_bytes ( 24 ));
$tokenStmt -> execute ([
':order_id' => $orderId ,
':order_item_id' => $orderItemId ,
':file_id' => $fileId ,
':email' => $email ,
':token' => $token ,
':download_limit' => $downloadLimit ,
':expires_at' => $expiresAt ,
]);
$label = ( string )( $file [ 'file_name' ] ? ? $item [ 'title_snapshot' ] ? ? 'Download' );
$links [] = [
'url' => $this -> baseUrl () . '/store/download?token=' . rawurlencode ( $token ),
'label' => $label ,
];
}
}
if ( ! $links ) {
return '<p>No downloadable files attached yet.</p>' ;
}
$rows = [];
foreach ( $links as $link ) {
$rows [] = '<li style="margin:0 0 8px;"><a href="' . htmlspecialchars ( $link [ 'url' ], ENT_QUOTES , 'UTF-8' ) . '">' . htmlspecialchars ( $link [ 'label' ], ENT_QUOTES , 'UTF-8' ) . '</a></li>' ;
}
return '<div><h3 style="margin:0 0 8px;">Your Downloads</h3><ul style="padding-left:18px;margin:0;">' . implode ( '' , $rows ) . '</ul></div>' ;
} catch ( Throwable $e ) {
return '<p>Download links could not be generated yet.</p>' ;
}
}
private function renderItemsHtml ( array $items , string $defaultCurrency ) : string
{
$rows = [];
foreach ( $items as $item ) {
if ( ! is_array ( $item )) {
continue ;
}
$title = htmlspecialchars (( string )( $item [ 'title' ] ? ? 'Item' ), ENT_QUOTES , 'UTF-8' );
$qty = max ( 1 , ( int )( $item [ 'qty' ] ? ? 1 ));
$price = ( float )( $item [ 'price' ] ? ? 0 );
$currency = htmlspecialchars (( string )( $item [ 'currency' ] ? ? $defaultCurrency ), ENT_QUOTES , 'UTF-8' );
$line = number_format ( $price * $qty , 2 );
$rows [] = '<tr>'
. '<td style="padding:8px;border-bottom:1px solid #ddd;">' . $title . '</td>'
. '<td style="padding:8px;border-bottom:1px solid #ddd;text-align:right;">' . $currency . ' ' . $line . '</td>'
. '</tr>' ;
}
if ( ! $rows ) {
return '<p>No items.</p>' ;
}
return '<table style="width:100%;border-collapse:collapse;">' . implode ( '' , $rows ) . '</table>' ;
}
private function defaultOrderEmailHtml () : string
{
return '{{logo_html}}'
. '<h2>{{site_name}} - Order {{order_no}}</h2>'
. '<p>Status: <strong>{{status}}</strong></p>'
. '<p>Email: {{customer_email}}</p>'
. '{{items_html}}'
. '{{download_links_html}}'
. '<p style="margin-top:12px;"><strong>Total: {{currency}} {{total}}</strong></p>' ;
}
private function safeReturnUrl ( string $url ) : string
{
if ( $url === '' || $url [ 0 ] !== '/' ) {
return '/releases' ;
}
if ( str_starts_with ( $url , '//' )) {
return '/releases' ;
}
return $url ;
}
private function baseUrl () : string
{
$https = ( ! empty ( $_SERVER [ 'HTTPS' ]) && $_SERVER [ 'HTTPS' ] !== 'off' ) || (( string )( $_SERVER [ 'SERVER_PORT' ] ? ? '' ) === '443' );
$scheme = $https ? 'https' : 'http' ;
$host = ( string )( $_SERVER [ 'HTTP_HOST' ] ? ? '' );
if ( $host === '' ) {
return '' ;
}
return $scheme . '://' . $host ;
}
2026-03-05 14:18:20 +00:00
private function isItemReleased ( PDO $db , string $itemType , int $itemId ) : bool
{
if ( $itemId <= 0 ) {
return false ;
}
try {
if ( $itemType === 'release' ) {
$stmt = $db -> prepare ( "
SELECT 1
FROM ac_releases
WHERE id = : id
AND is_published = 1
AND ( release_date IS NULL OR release_date <= : today )
LIMIT 1
" );
$stmt -> execute ([
':id' => $itemId ,
':today' => date ( 'Y-m-d' ),
]);
return ( bool ) $stmt -> fetch ( PDO :: FETCH_ASSOC );
}
$stmt = $db -> prepare ( "
SELECT 1
FROM ac_release_tracks t
JOIN ac_releases r ON r . id = t . release_id
WHERE t . id = : id
AND r . is_published = 1
AND ( r . release_date IS NULL OR r . release_date <= : today )
LIMIT 1
" );
$stmt -> execute ([
':id' => $itemId ,
':today' => date ( 'Y-m-d' ),
]);
return ( bool ) $stmt -> fetch ( PDO :: FETCH_ASSOC );
} catch ( Throwable $e ) {
return false ;
}
}
2026-03-04 20:46:11 +00:00
private function logMailDebug ( string $ref , array $payload ) : void
{
try {
$dir = __DIR__ . '/../../storage/logs' ;
if ( ! is_dir ( $dir )) {
@ mkdir ( $dir , 0755 , true );
}
$file = $dir . '/store_mail.log' ;
$line = '[' . date ( 'c' ) . '] ' . $ref . ' ' . json_encode ( $payload , JSON_UNESCAPED_SLASHES ) . PHP_EOL ;
@ file_put_contents ( $file , $line , FILE_APPEND );
} catch ( Throwable $e ) {
}
}
private function clientIp () : string
{
$candidates = [
( string )( $_SERVER [ 'HTTP_CF_CONNECTING_IP' ] ? ? '' ),
( string )( $_SERVER [ 'HTTP_X_FORWARDED_FOR' ] ? ? '' ),
( string )( $_SERVER [ 'REMOTE_ADDR' ] ? ? '' ),
];
foreach ( $candidates as $candidate ) {
if ( $candidate === '' ) {
continue ;
}
$first = trim ( explode ( ',' , $candidate )[ 0 ] ? ? '' );
if ( $first !== '' && filter_var ( $first , FILTER_VALIDATE_IP )) {
return $first ;
}
}
return '' ;
}
private function loadCustomerIpHistory ( PDO $db ) : array
{
$history = [];
try {
$stmt = $db -> query ( "
SELECT o . email , o . customer_ip AS ip_address , MAX ( o . created_at ) AS last_seen
FROM ac_store_orders o
WHERE o . customer_ip IS NOT NULL AND o . customer_ip <> ''
GROUP BY o . email , o . customer_ip
" );
$rows = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
foreach ( $rows as $row ) {
$email = strtolower ( trim (( string )( $row [ 'email' ] ? ? '' )));
$ip = trim (( string )( $row [ 'ip_address' ] ? ? '' ));
$lastSeen = ( string )( $row [ 'last_seen' ] ? ? '' );
if ( $email === '' || $ip === '' ) {
continue ;
}
$history [ $email ][ $ip ] = $lastSeen ;
}
} catch ( Throwable $e ) {
}
try {
$stmt = $db -> query ( "
SELECT t . email , e . ip_address , MAX ( e . downloaded_at ) AS last_seen
FROM ac_store_download_events e
JOIN ac_store_download_tokens t ON t . id = e . token_id
WHERE e . ip_address IS NOT NULL AND e . ip_address <> ''
GROUP BY t . email , e . ip_address
" );
$rows = $stmt ? $stmt -> fetchAll ( PDO :: FETCH_ASSOC ) : [];
foreach ( $rows as $row ) {
$email = strtolower ( trim (( string )( $row [ 'email' ] ? ? '' )));
$ip = trim (( string )( $row [ 'ip_address' ] ? ? '' ));
$lastSeen = ( string )( $row [ 'last_seen' ] ? ? '' );
if ( $email === '' || $ip === '' ) {
continue ;
}
$existing = $history [ $email ][ $ip ] ? ? '' ;
if ( $existing === '' || ( $lastSeen !== '' && strcmp ( $lastSeen , $existing ) > 0 )) {
$history [ $email ][ $ip ] = $lastSeen ;
}
}
} catch ( Throwable $e ) {
}
$result = [];
foreach ( $history as $email => $ips ) {
arsort ( $ips );
$entries = [];
foreach ( $ips as $ip => $lastSeen ) {
$entries [] = [
'ip' => $ip ,
'last_seen' => $lastSeen ,
];
if ( count ( $entries ) >= 5 ) {
break ;
}
}
$result [ $email ] = $entries ;
}
return $result ;
}
private function upsertCustomerFromOrder ( PDO $db , string $email , string $ip , string $userAgent , float $orderTotal ) : int
{
$email = strtolower ( trim ( $email ));
if ( ! filter_var ( $email , FILTER_VALIDATE_EMAIL )) {
return 0 ;
}
try {
$sel = $db -> prepare ( " SELECT id, orders_count, total_spent FROM ac_store_customers WHERE email = :email LIMIT 1 " );
$sel -> execute ([ ':email' => $email ]);
$row = $sel -> fetch ( PDO :: FETCH_ASSOC );
if ( $row ) {
$customerId = ( int )( $row [ 'id' ] ? ? 0 );
$ordersCount = ( int )( $row [ 'orders_count' ] ? ? 0 );
$totalSpent = ( float )( $row [ 'total_spent' ] ? ? 0 );
$upd = $db -> prepare ( "
UPDATE ac_store_customers
SET last_order_ip = : ip ,
last_order_user_agent = : ua ,
last_seen_at = NOW (),
orders_count = : orders_count ,
total_spent = : total_spent ,
updated_at = NOW ()
WHERE id = : id
" );
$upd -> execute ([
':ip' => $ip !== '' ? $ip : null ,
':ua' => $userAgent !== '' ? $userAgent : null ,
':orders_count' => $ordersCount + 1 ,
':total_spent' => $totalSpent + $orderTotal ,
':id' => $customerId ,
]);
return $customerId ;
}
$ins = $db -> prepare ( "
INSERT INTO ac_store_customers
( name , email , password_hash , is_active , last_order_ip , last_order_user_agent , last_seen_at , orders_count , total_spent , created_at , updated_at )
VALUES ( NULL , : email , NULL , 1 , : ip , : ua , NOW (), 1 , : total_spent , NOW (), NOW ())
" );
$ins -> execute ([
':email' => $email ,
':ip' => $ip !== '' ? $ip : null ,
':ua' => $userAgent !== '' ? $userAgent : null ,
':total_spent' => $orderTotal ,
]);
return ( int ) $db -> lastInsertId ();
} catch ( Throwable $e ) {
return 0 ;
}
}
private function bumpDiscountUsage ( PDO $db , string $code ) : void
{
$code = strtoupper ( trim ( $code ));
if ( $code === '' ) {
return ;
}
try {
$stmt = $db -> prepare ( "
UPDATE ac_store_discount_codes
SET used_count = used_count + 1 , updated_at = NOW ()
WHERE code = : code
" );
$stmt -> execute ([ ':code' => $code ]);
} catch ( Throwable $e ) {
}
}
private function isEnabledSetting ( $value ) : bool
{
if ( is_bool ( $value )) {
return $value ;
}
if ( is_int ( $value ) || is_float ( $value )) {
return ( int ) $value === 1 ;
}
$normalized = strtolower ( trim (( string ) $value ));
return in_array ( $normalized , [ '1' , 'true' , 'yes' , 'on' ], true );
}
private function paypalTokenProbe ( string $clientId , string $secret , bool $sandbox ) : array
{
$base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com' ;
$url = $base . '/v1/oauth2/token' ;
$headers = [
'Authorization: Basic ' . base64_encode ( $clientId . ':' . $secret ),
'Content-Type: application/x-www-form-urlencoded' ,
'Accept: application/json' ,
];
$body = 'grant_type=client_credentials' ;
if ( function_exists ( 'curl_init' )) {
$ch = curl_init ( $url );
if ( $ch === false ) {
return [ 'ok' => false , 'error' => 'Unable to initialize cURL' ];
}
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_POST , true );
curl_setopt ( $ch , CURLOPT_POSTFIELDS , $body );
curl_setopt ( $ch , CURLOPT_HTTPHEADER , $headers );
curl_setopt ( $ch , CURLOPT_TIMEOUT , 15 );
$response = ( string ) curl_exec ( $ch );
$httpCode = ( int ) curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
$curlErr = curl_error ( $ch );
curl_close ( $ch );
if ( $curlErr !== '' ) {
return [ 'ok' => false , 'error' => 'PayPal test failed: ' . $curlErr ];
}
$decoded = json_decode ( $response , true );
if ( $httpCode >= 200 && $httpCode < 300 && is_array ( $decoded ) && ! empty ( $decoded [ 'access_token' ])) {
return [ 'ok' => true ];
}
$err = is_array ( $decoded ) ? ( string )( $decoded [ 'error_description' ] ? ? $decoded [ 'error' ] ? ? '' ) : '' ;
return [ 'ok' => false , 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ( $err !== '' ? ': ' . $err : '' )];
}
$context = stream_context_create ([
'http' => [
'method' => 'POST' ,
'header' => implode ( " \r \n " , $headers ),
'content' => $body ,
'timeout' => 15 ,
'ignore_errors' => true ,
],
]);
$response = @ file_get_contents ( $url , false , $context );
if ( $response === false ) {
return [ 'ok' => false , 'error' => 'PayPal test failed: network error' ];
}
$statusLine = '' ;
if ( ! empty ( $http_response_header [ 0 ])) {
$statusLine = ( string ) $http_response_header [ 0 ];
}
preg_match ( '/\s(\d{3})\s/' , $statusLine , $m );
$httpCode = isset ( $m [ 1 ]) ? ( int ) $m [ 1 ] : 0 ;
$decoded = json_decode ( $response , true );
if ( $httpCode >= 200 && $httpCode < 300 && is_array ( $decoded ) && ! empty ( $decoded [ 'access_token' ])) {
return [ 'ok' => true ];
}
$err = is_array ( $decoded ) ? ( string )( $decoded [ 'error_description' ] ? ? $decoded [ 'error' ] ? ? '' ) : '' ;
return [ 'ok' => false , 'error' => 'PayPal credentials rejected (' . $httpCode . ')' . ( $err !== '' ? ': ' . $err : '' )];
}
private function paypalCreateOrder (
string $clientId ,
string $secret ,
bool $sandbox ,
string $currency ,
float $total ,
string $orderNo ,
string $returnUrl ,
string $cancelUrl
) : array {
$token = $this -> paypalAccessToken ( $clientId , $secret , $sandbox );
if ( ! ( $token [ 'ok' ] ? ? false )) {
return $token ;
}
$base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com' ;
$payload = [
'intent' => 'CAPTURE' ,
'purchase_units' => [[
'reference_id' => $orderNo ,
'description' => 'AudioCore order ' . $orderNo ,
'custom_id' => $orderNo ,
'amount' => [
'currency_code' => $currency ,
'value' => number_format ( $total , 2 , '.' , '' ),
],
]],
'application_context' => [
'return_url' => $returnUrl ,
'cancel_url' => $cancelUrl ,
'shipping_preference' => 'NO_SHIPPING' ,
'user_action' => 'PAY_NOW' ,
],
];
$res = $this -> paypalJsonRequest (
$base . '/v2/checkout/orders' ,
'POST' ,
$payload ,
( string )( $token [ 'access_token' ] ? ? '' )
);
if ( ! ( $res [ 'ok' ] ? ? false )) {
return $res ;
}
$body = is_array ( $res [ 'body' ] ? ? null ) ? $res [ 'body' ] : [];
$orderId = ( string )( $body [ 'id' ] ? ? '' );
$approvalUrl = '' ;
foreach (( array )( $body [ 'links' ] ? ? []) as $link ) {
if (( string )( $link [ 'rel' ] ? ? '' ) === 'approve' ) {
$approvalUrl = ( string )( $link [ 'href' ] ? ? '' );
break ;
}
}
if ( $orderId === '' || $approvalUrl === '' ) {
return [ 'ok' => false , 'error' => 'PayPal create order response incomplete' ];
}
return [
'ok' => true ,
'order_id' => $orderId ,
'approval_url' => $approvalUrl ,
];
}
private function paypalCaptureOrder ( string $clientId , string $secret , bool $sandbox , string $paypalOrderId ) : array
{
$token = $this -> paypalAccessToken ( $clientId , $secret , $sandbox );
if ( ! ( $token [ 'ok' ] ? ? false )) {
return $token ;
}
$base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com' ;
$res = $this -> paypalJsonRequest (
$base . '/v2/checkout/orders/' . rawurlencode ( $paypalOrderId ) . '/capture' ,
'POST' ,
new \stdClass (),
( string )( $token [ 'access_token' ] ? ? '' )
);
if ( ! ( $res [ 'ok' ] ? ? false )) {
return $res ;
}
$body = is_array ( $res [ 'body' ] ? ? null ) ? $res [ 'body' ] : [];
$status = ( string )( $body [ 'status' ] ? ? '' );
if ( $status !== 'COMPLETED' ) {
return [ 'ok' => false , 'error' => 'PayPal capture status: ' . ( $status !== '' ? $status : 'unknown' )];
}
$captureId = '' ;
$purchaseUnits = ( array )( $body [ 'purchase_units' ] ? ? []);
if ( ! empty ( $purchaseUnits [ 0 ][ 'payments' ][ 'captures' ][ 0 ][ 'id' ])) {
$captureId = ( string ) $purchaseUnits [ 0 ][ 'payments' ][ 'captures' ][ 0 ][ 'id' ];
}
return [ 'ok' => true , 'capture_id' => $captureId ];
}
private function paypalRefundCapture (
string $clientId ,
string $secret ,
bool $sandbox ,
string $captureId ,
string $currency ,
float $total
) : array {
$token = $this -> paypalAccessToken ( $clientId , $secret , $sandbox );
if ( ! ( $token [ 'ok' ] ? ? false )) {
return $token ;
}
$base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com' ;
$payload = [
'amount' => [
'currency_code' => $currency ,
'value' => number_format ( $total , 2 , '.' , '' ),
],
];
$res = $this -> paypalJsonRequest (
$base . '/v2/payments/captures/' . rawurlencode ( $captureId ) . '/refund' ,
'POST' ,
$payload ,
( string )( $token [ 'access_token' ] ? ? '' )
);
if ( ! ( $res [ 'ok' ] ? ? false )) {
return $res ;
}
$body = is_array ( $res [ 'body' ] ? ? null ) ? $res [ 'body' ] : [];
$status = strtoupper (( string )( $body [ 'status' ] ? ? '' ));
if ( ! in_array ( $status , [ 'COMPLETED' , 'PENDING' ], true )) {
return [ 'ok' => false , 'error' => 'PayPal refund status: ' . ( $status !== '' ? $status : 'unknown' )];
}
return [ 'ok' => true ];
}
private function paypalAccessToken ( string $clientId , string $secret , bool $sandbox ) : array
{
$base = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com' ;
$url = $base . '/v1/oauth2/token' ;
$headers = [
'Authorization: Basic ' . base64_encode ( $clientId . ':' . $secret ),
'Content-Type: application/x-www-form-urlencoded' ,
'Accept: application/json' ,
];
$body = 'grant_type=client_credentials' ;
if ( function_exists ( 'curl_init' )) {
$ch = curl_init ( $url );
if ( $ch === false ) {
return [ 'ok' => false , 'error' => 'Unable to initialize cURL' ];
}
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_POST , true );
curl_setopt ( $ch , CURLOPT_POSTFIELDS , $body );
curl_setopt ( $ch , CURLOPT_HTTPHEADER , $headers );
curl_setopt ( $ch , CURLOPT_TIMEOUT , 20 );
$response = ( string ) curl_exec ( $ch );
$httpCode = ( int ) curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
$curlErr = curl_error ( $ch );
curl_close ( $ch );
if ( $curlErr !== '' ) {
return [ 'ok' => false , 'error' => 'PayPal auth failed: ' . $curlErr ];
}
$decoded = json_decode ( $response , true );
if ( $httpCode >= 200 && $httpCode < 300 && is_array ( $decoded ) && ! empty ( $decoded [ 'access_token' ])) {
return [ 'ok' => true , 'access_token' => ( string ) $decoded [ 'access_token' ]];
}
$err = is_array ( $decoded ) ? ( string )( $decoded [ 'error_description' ] ? ? $decoded [ 'error' ] ? ? '' ) : '' ;
return [ 'ok' => false , 'error' => 'PayPal auth rejected (' . $httpCode . ')' . ( $err !== '' ? ': ' . $err : '' )];
}
return [ 'ok' => false , 'error' => 'cURL is required for PayPal checkout' ];
}
private function paypalJsonRequest ( string $url , string $method , $payload , string $accessToken ) : array
{
if ( ! function_exists ( 'curl_init' )) {
return [ 'ok' => false , 'error' => 'cURL is required for PayPal checkout' ];
}
$ch = curl_init ( $url );
if ( $ch === false ) {
return [ 'ok' => false , 'error' => 'Unable to initialize cURL' ];
}
$json = json_encode ( $payload , JSON_UNESCAPED_SLASHES );
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_CUSTOMREQUEST , strtoupper ( $method ));
curl_setopt ( $ch , CURLOPT_POSTFIELDS , $json );
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [
'Authorization: Bearer ' . $accessToken ,
'Content-Type: application/json' ,
'Accept: application/json' ,
]);
curl_setopt ( $ch , CURLOPT_TIMEOUT , 25 );
$response = ( string ) curl_exec ( $ch );
$httpCode = ( int ) curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
$curlErr = curl_error ( $ch );
curl_close ( $ch );
if ( $curlErr !== '' ) {
return [ 'ok' => false , 'error' => 'PayPal request failed: ' . $curlErr ];
}
$decoded = json_decode ( $response , true );
if ( $httpCode >= 200 && $httpCode < 300 ) {
return [ 'ok' => true , 'body' => is_array ( $decoded ) ? $decoded : []];
}
$err = is_array ( $decoded ) ? ( string )( $decoded [ 'message' ] ? ? $decoded [ 'name' ] ? ? '' ) : '' ;
return [ 'ok' => false , 'error' => 'PayPal API error (' . $httpCode . ')' . ( $err !== '' ? ': ' . $err : '' )];
}
private function orderItemsForEmail ( PDO $db , int $orderId ) : array
{
try {
$stmt = $db -> prepare ( "
SELECT title_snapshot AS title , unit_price_snapshot AS price , qty , currency_snapshot AS currency
FROM ac_store_order_items
WHERE order_id = : order_id
ORDER BY id ASC
" );
$stmt -> execute ([ ':order_id' => $orderId ]);
$rows = $stmt -> fetchAll ( PDO :: FETCH_ASSOC );
return is_array ( $rows ) ? $rows : [];
} catch ( Throwable $e ) {
return [];
}
}
}