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