![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/domains/soundstudiopro.com/public_html/webhooks/ |
<?php
// Stripe Webhook Handler
// Handles incoming webhook events from Stripe
// Set content type to JSON
header('Content-Type: application/json');
// SECURITY: Check if Stripe library is available - REQUIRED for verification
if (!class_exists('Stripe\Stripe')) {
// CRITICAL: Do NOT accept webhooks without verification
error_log("SECURITY: Stripe library not available - rejecting webhook without verification");
http_response_code(503);
echo json_encode(['error' => 'Service unavailable - Stripe library required']);
exit();
}
// SECURITY: Load Stripe credentials from config file or environment variables
// Priority: 1. Environment variables, 2. Config file, 3. Fallback (for backward compatibility)
$webhook_secret = $_ENV['STRIPE_WEBHOOK_SECRET'] ?? getenv('STRIPE_WEBHOOK_SECRET') ?: null;
$stripe_secret_key = $_ENV['STRIPE_SECRET_KEY'] ?? getenv('STRIPE_SECRET_KEY') ?: null;
// Try to load from config file if not in environment
if (!$webhook_secret || !$stripe_secret_key) {
$stripe_config_file = __DIR__ . '/../config/stripe.env.php';
if (file_exists($stripe_config_file)) {
require_once $stripe_config_file;
$webhook_secret = defined('STRIPE_WEBHOOK_SECRET') ? STRIPE_WEBHOOK_SECRET : $webhook_secret;
$stripe_secret_key = defined('STRIPE_SECRET_KEY') ? STRIPE_SECRET_KEY : $stripe_secret_key;
}
}
// SECURITY: Fail if secrets are not configured (no unsafe fallbacks)
if (!$webhook_secret) {
error_log("CRITICAL: STRIPE_WEBHOOK_SECRET not configured. Check environment variables or config/stripe.env.php");
http_response_code(500);
echo json_encode(['error' => 'Webhook configuration error']);
exit();
}
if (!$stripe_secret_key) {
error_log("CRITICAL: STRIPE_SECRET_KEY not configured. Check environment variables or config/stripe.env.php");
http_response_code(500);
echo json_encode(['error' => 'Webhook configuration error']);
exit();
}
// SECURITY: Optional IP whitelist check (defense in depth)
// Stripe webhook IPs: https://stripe.com/docs/ips
$stripe_ips = [
'3.18.12.63', '3.130.192.231', '13.235.14.237', '13.235.122.149', '18.211.135.69',
'35.154.171.200', '52.15.183.38', '54.187.174.169', '54.187.205.235', '54.187.216.72',
'54.241.31.99', '54.241.31.102', '54.241.34.107', '104.45.136.0/24', '104.45.137.0/24'
];
$client_ip = $_SERVER['REMOTE_ADDR'] ?? '';
$is_stripe_ip = false;
foreach ($stripe_ips as $stripe_ip) {
if (strpos($stripe_ip, '/') !== false) {
// CIDR notation - simple check (for production, use proper CIDR matching)
$ip_parts = explode('/', $stripe_ip);
$network = ip2long($ip_parts[0]);
$mask = ~((1 << (32 - $ip_parts[1])) - 1);
$client_long = ip2long($client_ip);
if (($client_long & $mask) === ($network & $mask)) {
$is_stripe_ip = true;
break;
}
} else {
if ($client_ip === $stripe_ip) {
$is_stripe_ip = true;
break;
}
}
}
// Log IP check (but don't block - signature verification is primary security)
if (!$is_stripe_ip && !empty($client_ip)) {
error_log("WARNING: Webhook request from non-Stripe IP: {$client_ip} (signature verification will be performed)");
}
// Get the raw POST data
$payload = file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
// SECURITY: Require signature header
if (empty($sig_header)) {
error_log("SECURITY: Webhook request missing signature header from IP: {$client_ip}");
http_response_code(400);
echo json_encode(['error' => 'Missing signature header']);
exit();
}
// Verify webhook signature
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sig_header, $webhook_secret
);
} catch(\UnexpectedValueException $e) {
// Invalid payload
http_response_code(400);
echo json_encode(['error' => 'Invalid payload']);
exit();
} catch(\Stripe\Exception\SignatureVerificationException $e) {
// Invalid signature
http_response_code(400);
echo json_encode(['error' => 'Invalid signature']);
exit();
}
// Log the webhook event
$log_data = [
'timestamp' => date('Y-m-d H:i:s'),
'event_type' => $event->type,
'event_id' => $event->id,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
];
// Write to log file
$log_file = __DIR__ . '/../logs/stripe_webhooks.log';
$log_entry = json_encode($log_data) . "\n";
file_put_contents($log_file, $log_entry, FILE_APPEND | LOCK_EX);
// Handle the event
switch ($event->type) {
case 'payment_intent.succeeded':
$paymentIntent = $event->data->object;
// Log successful payment
$success_log = [
'timestamp' => date('Y-m-d H:i:s'),
'event' => 'payment_intent.succeeded',
'payment_intent_id' => $paymentIntent->id,
'amount' => $paymentIntent->amount / 100, // Convert from cents
'currency' => $paymentIntent->currency,
'customer_id' => $paymentIntent->customer ?? 'unknown',
'metadata' => $paymentIntent->metadata ?? []
];
// Write to success log
$success_log_file = __DIR__ . '/../logs/stripe_success.log';
file_put_contents($success_log_file, json_encode($success_log) . "\n", FILE_APPEND | LOCK_EX);
// Update your database or trigger other actions
try {
handleSuccessfulPayment($paymentIntent);
} catch (Exception $e) {
// Log webhook processing error but still return 200 to Stripe
// (we'll retry via the retry queue)
error_log("Webhook processing error for payment_intent.succeeded: " . $e->getMessage());
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'event' => 'payment_intent.succeeded_processing_error',
'payment_intent_id' => $paymentIntent->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
];
$error_log_file = __DIR__ . '/../logs/stripe_webhook_errors.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
}
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event->data->object;
// Log failed payment
$failure_log = [
'timestamp' => date('Y-m-d H:i:s'),
'event' => 'payment_intent.payment_failed',
'payment_intent_id' => $paymentIntent->id,
'amount' => $paymentIntent->amount / 100,
'currency' => $paymentIntent->currency,
'customer_id' => $paymentIntent->customer ?? 'unknown',
'last_payment_error' => $paymentIntent->last_payment_error ?? null
];
// Write to failure log
$failure_log_file = __DIR__ . '/../logs/stripe_failures.log';
file_put_contents($failure_log_file, json_encode($failure_log) . "\n", FILE_APPEND | LOCK_EX);
// Handle failed payment
handleFailedPayment($paymentIntent);
break;
case 'customer.subscription.created':
$subscription = $event->data->object;
try {
handleSubscriptionCreated($subscription);
http_response_code(200);
echo json_encode(['status' => 'success']);
} catch (Exception $e) {
error_log("Webhook error (customer.subscription.created): " . $e->getMessage());
// Return 500 for transient errors (Stripe will retry)
// Return 400 for permanent errors (Stripe won't retry)
if (strpos($e->getMessage(), 'User not found') !== false) {
// Permanent error - don't retry
http_response_code(400);
echo json_encode(['error' => 'User not found']);
} else {
// Transient error - Stripe will retry
http_response_code(500);
echo json_encode(['error' => 'Processing error', 'message' => $e->getMessage()]);
}
}
break;
case 'customer.subscription.updated':
$subscription = $event->data->object;
try {
handleSubscriptionUpdated($subscription);
http_response_code(200);
echo json_encode(['status' => 'success']);
} catch (Exception $e) {
error_log("Webhook error (customer.subscription.updated): " . $e->getMessage());
// Return 500 for transient errors (Stripe will retry)
if (strpos($e->getMessage(), 'User not found') !== false) {
http_response_code(400);
echo json_encode(['error' => 'User not found']);
} else {
http_response_code(500);
echo json_encode(['error' => 'Processing error', 'message' => $e->getMessage()]);
}
}
break;
case 'customer.subscription.deleted':
$subscription = $event->data->object;
handleSubscriptionDeleted($subscription);
break;
case 'invoice.payment_succeeded':
$invoice = $event->data->object;
handleInvoicePaymentSucceeded($invoice);
break;
case 'invoice.payment_failed':
$invoice = $event->data->object;
handleInvoicePaymentFailed($invoice);
break;
case 'checkout.session.completed':
// Backup handler - subscription should already be recorded in subscription_success.php
// But this ensures we catch it if the user doesn't complete the redirect
$session = $event->data->object;
if ($session->mode === 'subscription' && !empty($session->subscription)) {
// Fetch subscription and record it (same logic as subscription_success.php)
try {
require_once __DIR__ . '/../config/database.php';
$pdo = getDBConnection();
// Get subscription from Stripe
$stripe_secret = getStripeSecretKey();
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.stripe.com/v1/subscriptions/' . urlencode($session->subscription));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $stripe_secret]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code === 200) {
$subscription = json_decode($response, true);
// Use existing handleSubscriptionCreated function
$subscription_obj = json_decode(json_encode($subscription)); // Convert to object
handleSubscriptionCreated($subscription_obj);
} else {
error_log("Failed to fetch subscription in checkout.session.completed: HTTP {$http_code}");
}
} catch (Exception $e) {
error_log("Error in checkout.session.completed handler: " . $e->getMessage());
// Don't return error - this is a backup handler, primary recording happens in subscription_success.php
}
}
break;
default:
// Unexpected event type
$unknown_log = [
'timestamp' => date('Y-m-d H:i:s'),
'event' => 'unknown_event_type',
'event_type' => $event->type,
'event_id' => $event->id
];
$unknown_log_file = __DIR__ . '/../logs/stripe_unknown.log';
file_put_contents($unknown_log_file, json_encode($unknown_log) . "\n", FILE_APPEND | LOCK_EX);
break;
}
// Return success response
http_response_code(200);
echo json_encode(['status' => 'success']);
// Helper function to get Stripe secret key (loads from config/env if not already loaded)
function getStripeSecretKey() {
static $cached_key = null;
if ($cached_key !== null) {
return $cached_key;
}
// Try environment variables first
$key = $_ENV['STRIPE_SECRET_KEY'] ?? getenv('STRIPE_SECRET_KEY') ?: null;
// Try config file if not in environment
if (!$key) {
$stripe_config_file = __DIR__ . '/../config/stripe.env.php';
if (file_exists($stripe_config_file)) {
require_once $stripe_config_file;
$key = defined('STRIPE_SECRET_KEY') ? STRIPE_SECRET_KEY : null;
}
}
// SECURITY: Fail if key is not configured (no unsafe fallbacks)
if (!$key) {
error_log("CRITICAL: STRIPE_SECRET_KEY not configured in getStripeSecretKey()");
throw new Exception("Stripe secret key not configured");
}
$cached_key = $key;
return $key;
}
// Helper functions
function handleSuccessfulPayment($paymentIntent) {
// Extract metadata for credit purchases
// Stripe metadata is an object, convert to array for easier access
$metadata_obj = $paymentIntent->metadata ?? (object)[];
$metadata = [];
foreach ($metadata_obj as $key => $value) {
$metadata[$key] = $value;
}
$user_id = $metadata['user_id'] ?? null;
$credits = $metadata['credits'] ?? $metadata['total_credits'] ?? 0;
$package = $metadata['package'] ?? 'unknown';
$subscription_period = $metadata['subscription_period'] ?? '30_days';
$purchase_type = $metadata['purchase_type'] ?? 'credit_package';
$track_id = $metadata['track_id'] ?? null;
$payment_type = $metadata['payment_type'] ?? 'unknown';
$has_tracks = $metadata['has_tracks'] ?? false;
// Log the action
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSuccessfulPayment',
'payment_intent_id' => $paymentIntent->id,
'amount' => $paymentIntent->amount / 100,
'currency' => $paymentIntent->currency,
'user_id' => $user_id,
'credits' => $credits,
'package' => $package,
'purchase_type' => $purchase_type,
'track_id' => $track_id,
'payment_type' => $payment_type,
'has_tracks' => $has_tracks
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
// Handle mixed cart checkout
if ($payment_type === 'mixed_cart_checkout' && $user_id) {
$result = processMixedCartPayment($paymentIntent, $metadata);
if (!$result['success']) {
// Log failure and schedule retry
schedulePurchaseRetry($paymentIntent->id, 'mixed_cart', $metadata);
}
} else if ($purchase_type === 'event_ticket' && isset($metadata['event_id'])) {
// Handle event ticket purchase
try {
processEventTicketPurchase($user_id, $metadata['event_id'], $paymentIntent);
} catch (Exception $e) {
error_log("Event ticket purchase failed: " . $e->getMessage());
schedulePurchaseRetry($paymentIntent->id, 'event_ticket', $metadata);
}
} else if ($purchase_type === 'track_purchase' && $track_id) {
// Handle individual track purchase with retry logic
$result = processTrackPurchaseWithRetry($user_id, $track_id, $credits, $paymentIntent->id);
if (!$result['success']) {
// Schedule retry for later
schedulePurchaseRetry($paymentIntent->id, 'track', ['user_id' => $user_id, 'track_id' => $track_id, 'credits_used' => $credits]);
}
} else if ($user_id && $credits > 0) {
// Handle credit package purchase
try {
addCreditsToUser($user_id, $credits, $package, $subscription_period, $paymentIntent->id);
} catch (Exception $e) {
error_log("Credit purchase failed: " . $e->getMessage());
schedulePurchaseRetry($paymentIntent->id, 'credits', $metadata);
}
}
}
/**
* Process track purchase with automatic retry on failure
* Retries up to 3 times with exponential backoff
*/
function processTrackPurchaseWithRetry($user_id, $track_id, $credits_used, $payment_intent_id, $max_retries = 3) {
$attempt = 0;
$last_error = null;
while ($attempt < $max_retries) {
$attempt++;
$result = processTrackPurchase($user_id, $track_id, $credits_used, $payment_intent_id, true);
if ($result['success']) {
if ($attempt > 1) {
// Log successful retry
$retry_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'track_purchase_retry_success',
'user_id' => $user_id,
'track_id' => $track_id,
'payment_intent_id' => $payment_intent_id,
'attempt' => $attempt
];
$log_file = __DIR__ . '/../logs/track_purchase_retries.log';
file_put_contents($log_file, json_encode($retry_log) . "\n", FILE_APPEND | LOCK_EX);
}
return $result;
}
$last_error = $result['message'];
// If it's a non-retryable error (track not found, already purchased), don't retry
if (strpos($result['message'], 'not found') !== false ||
strpos($result['message'], 'already') !== false) {
return $result;
}
// Wait before retry (exponential backoff: 1s, 2s, 4s)
if ($attempt < $max_retries) {
$wait_time = pow(2, $attempt - 1);
usleep($wait_time * 1000000); // Convert to microseconds
}
}
// All retries failed
$failure_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'track_purchase_all_retries_failed',
'user_id' => $user_id,
'track_id' => $track_id,
'payment_intent_id' => $payment_intent_id,
'attempts' => $attempt,
'last_error' => $last_error
];
$log_file = __DIR__ . '/../logs/track_purchase_retries.log';
file_put_contents($log_file, json_encode($failure_log) . "\n", FILE_APPEND | LOCK_EX);
return ['success' => false, 'message' => "Failed after $attempt attempts: $last_error", 'purchase_id' => null];
}
/**
* Schedule a purchase retry for later processing
* This creates a record that can be processed by a cron job
*/
function schedulePurchaseRetry($payment_intent_id, $purchase_type, $metadata) {
$retry_file = __DIR__ . '/../logs/purchase_retry_queue.log';
$retry_entry = [
'timestamp' => date('Y-m-d H:i:s'),
'payment_intent_id' => $payment_intent_id,
'purchase_type' => $purchase_type,
'metadata' => $metadata,
'retry_count' => 0,
'max_retries' => 5,
'next_retry_at' => date('Y-m-d H:i:s', strtotime('+5 minutes'))
];
file_put_contents($retry_file, json_encode($retry_entry) . "\n", FILE_APPEND | LOCK_EX);
}
// Add credits to user account
function addCreditsToUser($user_id, $credits, $package, $subscription_period, $payment_intent_id) {
require_once __DIR__ . '/../config/database.php';
try {
$pdo = getDBConnection();
// Premium credits (500) do NOT expire - set expiration to NULL
// Other packages (starter, pro) expire in 30 days
$is_premium = ($package === 'premium');
$expiration_date = $is_premium ? null : date('Y-m-d H:i:s', strtotime('+30 days'));
// Get payment amount from payment intent if available
$amount = 0;
if (isset($payment_intent_id)) {
// Try to get amount from payment intent metadata or calculate from package
$package_prices = [
'starter' => 19.99,
'pro' => 59.00,
'premium' => 129.00
];
$amount = $package_prices[$package] ?? 0;
}
// Start transaction
$pdo->beginTransaction();
// CRITICAL: Check if user has an active subscription - if so, preserve their subscription plan
// Credits and subscription plans are INDEPENDENT - adding credits should NEVER change subscription plan
require_once __DIR__ . '/../utils/subscription_helpers.php';
// Get current user plan FIRST
$current_user = $pdo->prepare("SELECT plan FROM users WHERE id = ?");
$current_user->execute([$user_id]);
$user_data = $current_user->fetch(PDO::FETCH_ASSOC);
$current_plan = $user_data['plan'] ?? null;
// Check for active subscription
$has_active_subscription = false;
$subscription_plan = null;
try {
$has_active_subscription = hasActiveSubscription($user_id);
if ($has_active_subscription) {
$subscription_plan = $has_active_subscription['plan_name'] ?? null;
}
} catch (Exception $e) {
error_log("addCreditsToUser: Error checking subscription for user {$user_id}: " . $e->getMessage());
}
// Also check user_subscriptions table directly as backup
if (!$subscription_plan) {
try {
$sub_stmt = $pdo->prepare("
SELECT plan_name FROM user_subscriptions
WHERE user_id = ? AND status IN ('active', 'trialing')
AND current_period_end > NOW()
ORDER BY created_at DESC LIMIT 1
");
$sub_stmt->execute([$user_id]);
$sub_result = $sub_stmt->fetch(PDO::FETCH_ASSOC);
if ($sub_result && !empty($sub_result['plan_name'])) {
$subscription_plan = $sub_result['plan_name'];
$has_active_subscription = true;
}
} catch (Exception $e) {
error_log("addCreditsToUser: Error checking user_subscriptions for user {$user_id}: " . $e->getMessage());
}
}
// DEFENSIVE: If user has a subscription plan (essential, pro, etc.), NEVER change it
// Only subscription plans: essential, pro, premium, enterprise, free
// Credit packages: starter, pro, premium (these are DIFFERENT from subscription plans)
$subscription_plans = ['essential', 'pro', 'premium', 'enterprise', 'free'];
$is_subscription_plan = in_array(strtolower($current_plan ?? ''), $subscription_plans);
// Determine what plan to use
if ($has_active_subscription && $subscription_plan) {
// User has active subscription - ALWAYS preserve subscription plan
$plan_name = $subscription_plan;
error_log("addCreditsToUser: User {$user_id} has active subscription '{$plan_name}', preserving plan instead of changing to '{$package}'");
} elseif ($is_subscription_plan && $current_plan) {
// User's current plan is a subscription plan - preserve it even if subscription check failed
$plan_name = $current_plan;
error_log("addCreditsToUser: User {$user_id} has subscription plan '{$current_plan}', preserving it instead of changing to '{$package}' (subscription check may have failed)");
} else {
// No subscription found - only then update plan to package (for credit-only users)
$plan_name = $package;
error_log("addCreditsToUser: User {$user_id} has no active subscription, setting plan to package '{$package}'");
}
// Premium credits (500) do NOT expire - set expiration to NULL
// Other packages (starter, pro) expire in 30 days
// According to terms: Credits include commercial licensing rights for content created using those credits
// Rights are permanent - once content is created with credits, commercial rights do not expire
if ($is_premium) {
// Premium credits don't expire - only update credits and plan, don't touch subscription_expires
$stmt = $pdo->prepare("
UPDATE users
SET credits = credits + ?,
plan = ?
WHERE id = ?
");
$stmt->execute([$credits, $plan_name, $user_id]);
} else {
// Starter and Pro packages expire in 30 days
$stmt = $pdo->prepare("
UPDATE users
SET credits = credits + ?,
plan = ?,
subscription_expires = ?
WHERE id = ?
");
$stmt->execute([$credits, $plan_name, $expiration_date, $user_id]);
}
// Log the purchase in credit_purchases table
// Premium: expires_at = far future date (2099-12-31) since column doesn't allow NULL
// Others: expires_at = 30 days from now
// If expiration_date is NULL (premium), use far future date instead
if ($expiration_date === null) {
$expiration_date = '2099-12-31 23:59:59'; // Far future date for premium (never expires)
}
$stmt = $pdo->prepare("
INSERT INTO credit_purchases
(user_id, package, credits, amount, payment_intent_id, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, NOW())
");
$stmt->execute([$user_id, $package, $credits, $amount, $payment_intent_id, $expiration_date]);
// Commit transaction
$pdo->commit();
// Log the credit addition
// According to terms: Credits include commercial licensing rights for content created using those credits
// Rights are permanent - once content is created with credits, commercial rights do not expire
$credit_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'add_credits_to_user',
'user_id' => $user_id,
'credits_added' => $credits,
'package' => $package,
'plan_updated_to' => $plan_name,
'subscription_period' => $subscription_period,
'expiration_date' => $expiration_date,
'never_expires' => $is_premium,
'commercial_rights' => 'Credits include commercial licensing rights per terms section 5.2',
'payment_intent_id' => $payment_intent_id,
'status' => 'success'
];
$credit_log_file = __DIR__ . '/../logs/user_credits.log';
file_put_contents($credit_log_file, json_encode($credit_log) . "\n", FILE_APPEND | LOCK_EX);
} catch (Exception $e) {
// Rollback on error
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
// Log error
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'add_credits_to_user_error',
'user_id' => $user_id,
'package' => $package,
'credits' => $credits,
'error' => $e->getMessage(),
'payment_intent_id' => $payment_intent_id
];
$error_log_file = __DIR__ . '/../logs/user_credits_errors.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
// Re-throw to allow caller to handle
throw $e;
}
}
// Process track purchase after successful payment (with invoice email)
// Returns: ['success' => bool, 'message' => string, 'purchase_id' => int|null]
function processTrackPurchase($user_id, $track_id, $credits_used, $payment_intent_id, $send_invoice = true) {
require_once __DIR__ . '/../config/database.php';
// Log function entry
$entry_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processTrackPurchase_start',
'user_id' => $user_id,
'track_id' => $track_id,
'credits_used' => $credits_used,
'payment_intent_id' => $payment_intent_id,
'send_invoice' => $send_invoice
];
$entry_log_file = __DIR__ . '/../logs/track_purchase_detailed.log';
file_put_contents($entry_log_file, json_encode($entry_log) . "\n", FILE_APPEND | LOCK_EX);
try {
$pdo = getDBConnection();
// IDEMPOTENCY CHECK: If purchase already exists, return success (webhook might be called twice)
$stmt = $pdo->prepare("
SELECT id FROM track_purchases
WHERE user_id = ? AND track_id = ? AND stripe_payment_intent_id = ?
");
$stmt->execute([$user_id, $track_id, $payment_intent_id]);
$existing_purchase = $stmt->fetch();
if ($existing_purchase) {
$idempotency_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processTrackPurchase_already_purchased_idempotent',
'user_id' => $user_id,
'track_id' => $track_id,
'existing_purchase_id' => $existing_purchase['id'],
'payment_intent_id' => $payment_intent_id,
'note' => 'Purchase already exists - idempotent success'
];
file_put_contents($entry_log_file, json_encode($idempotency_log) . "\n", FILE_APPEND | LOCK_EX);
return ['success' => true, 'message' => 'Purchase already exists (idempotent)', 'purchase_id' => $existing_purchase['id']];
}
// Get track information
$stmt = $pdo->prepare("
SELECT
mt.id,
mt.title,
mt.audio_url,
mt.price,
mt.user_id as artist_id,
u.name as artist_name
FROM music_tracks mt
JOIN users u ON mt.user_id = u.id
WHERE mt.id = ? AND mt.status = 'complete'
");
$stmt->execute([$track_id]);
$track = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$track) {
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processTrackPurchase_track_not_found',
'user_id' => $user_id,
'track_id' => $track_id,
'payment_intent_id' => $payment_intent_id
];
file_put_contents($entry_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
return ['success' => false, 'message' => 'Track not found', 'purchase_id' => null];
}
// Log track found
$track_found_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processTrackPurchase_track_found',
'user_id' => $user_id,
'track_id' => $track_id,
'track_title' => $track['title'],
'track_price' => $track['price'],
'artist_id' => $track['artist_id'],
'payment_intent_id' => $payment_intent_id
];
file_put_contents($entry_log_file, json_encode($track_found_log) . "\n", FILE_APPEND | LOCK_EX);
// Start transaction
$pdo->beginTransaction();
try {
// Determine revenue recipient (free user tracks go to platform, paid tracks go to artist)
$is_free_user_track = false;
$artist_stmt = $pdo->prepare("SELECT plan FROM users WHERE id = ?");
$artist_stmt->execute([$track['artist_id']]);
$artist = $artist_stmt->fetch(PDO::FETCH_ASSOC);
if ($artist && strtolower($artist['plan']) === 'free') {
$is_free_user_track = true;
}
$revenue_recipient = $is_free_user_track ? 'platform' : 'artist';
$recipient_id = $is_free_user_track ? 1 : $track['artist_id'];
// Record sale
$stmt = $pdo->prepare("
INSERT INTO sales (
track_id, buyer_id, artist_id, amount, quantity,
revenue_recipient, recipient_id, is_free_user_track,
created_at
) VALUES (?, ?, ?, ?, 1, ?, ?, ?, NOW())
");
$stmt->execute([
$track_id,
$user_id,
$track['artist_id'],
$track['price'],
$revenue_recipient,
$recipient_id,
$is_free_user_track ? 1 : 0
]);
// Record the purchase
$stmt = $pdo->prepare("
INSERT INTO track_purchases (user_id, track_id, price_paid, credits_used, payment_method, stripe_payment_intent_id)
VALUES (?, ?, ?, ?, 'stripe', ?)
");
$stmt->execute([$user_id, $track_id, $track['price'], $credits_used, $payment_intent_id]);
$purchase_id = $pdo->lastInsertId();
// Log purchase record created
$purchase_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processTrackPurchase_purchase_recorded',
'user_id' => $user_id,
'track_id' => $track_id,
'purchase_id' => $purchase_id,
'price_paid' => $track['price'],
'payment_intent_id' => $payment_intent_id
];
file_put_contents($entry_log_file, json_encode($purchase_log) . "\n", FILE_APPEND | LOCK_EX);
// Add to user's library
$stmt = $pdo->prepare("
INSERT IGNORE INTO user_library (user_id, track_id, purchase_date)
VALUES (?, ?, NOW())
");
$stmt->execute([$user_id, $track_id]);
// Log library addition
$library_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processTrackPurchase_library_added',
'user_id' => $user_id,
'track_id' => $track_id,
'payment_intent_id' => $payment_intent_id
];
file_put_contents($entry_log_file, json_encode($library_log) . "\n", FILE_APPEND | LOCK_EX);
// Record credit transaction only if credits were used
if ($credits_used > 0) {
$stmt = $pdo->prepare("
INSERT INTO credit_transactions (user_id, amount, type, description, stripe_payment_intent_id)
VALUES (?, ?, 'usage', ?, ?)
");
$stmt->execute([
$user_id,
-$credits_used,
"Purchased track: {$track['title']}",
$payment_intent_id
]);
}
// Commit transaction
$pdo->commit();
// Notify artist of the purchase
require_once __DIR__ . '/../utils/artist_notifications.php';
notifyArtistOfTrackPurchase(
$track['artist_id'],
$track_id,
$user_id,
$track['price'],
$purchase_id
);
// Log transaction committed
$commit_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processTrackPurchase_transaction_committed',
'user_id' => $user_id,
'track_id' => $track_id,
'purchase_id' => $purchase_id,
'payment_intent_id' => $payment_intent_id
];
file_put_contents($entry_log_file, json_encode($commit_log) . "\n", FILE_APPEND | LOCK_EX);
// Send invoice email if requested
$email_sent = false;
if ($send_invoice) {
// Get user information for invoice
$user_stmt = $pdo->prepare("SELECT name, email FROM users WHERE id = ?");
$user_stmt->execute([$user_id]);
$user = $user_stmt->fetch(PDO::FETCH_ASSOC);
// Get billing address from payment intent metadata if available
$billing_address = [];
try {
// Try to get billing address from Stripe payment intent
$stripe_secret = getStripeSecretKey();
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.stripe.com/v1/payment_intents/' . $payment_intent_id);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $stripe_secret]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$stripe_response = curl_exec($ch);
curl_close($ch);
if ($stripe_response) {
$stripe_data = json_decode($stripe_response, true);
if (isset($stripe_data['charges']['data'][0]['billing_details'])) {
$billing = $stripe_data['charges']['data'][0]['billing_details'];
$billing_address = [
'billing_first_name' => explode(' ', $billing['name'] ?? '')[0] ?? '',
'billing_last_name' => explode(' ', $billing['name'] ?? '', 2)[1] ?? '',
'billing_email' => $billing['email'] ?? $user['email'],
'billing_address' => $billing['address']['line1'] ?? '',
'billing_city' => $billing['address']['city'] ?? '',
'billing_state' => $billing['address']['state'] ?? '',
'billing_zip' => $billing['address']['postal_code'] ?? '',
'billing_country' => $billing['address']['country'] ?? ''
];
}
}
} catch (Exception $e) {
error_log("Could not fetch billing address from Stripe: " . $e->getMessage());
}
// Send invoice email
if ($user) {
require_once __DIR__ . '/../config/email.php';
$invoice_data = generateInvoiceEmail($user['name'], $user['email'], [
'purchase_id' => $purchase_id,
'track_id' => $track_id,
'track_ids' => [$track_id],
'price_paid' => $track['price'],
'total_amount' => $track['price'],
'payment_method' => 'stripe',
'payment_intent_id' => $payment_intent_id,
'purchase_date' => date('Y-m-d H:i:s'),
'billing_address' => $billing_address
]);
$email_sent = sendEmail(
$user['email'],
$user['name'],
$invoice_data['subject'],
$invoice_data['html'],
$invoice_data['text'],
'invoice',
$user_id,
$purchase_id
);
if ($email_sent) {
error_log("Invoice email sent successfully to: " . $user['email']);
} else {
error_log("Failed to send invoice email to: " . $user['email']);
}
}
}
// Log successful track purchase
$track_purchase_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'track_purchase_completed',
'user_id' => $user_id,
'track_id' => $track_id,
'track_title' => $track['title'],
'artist_name' => $track['artist_name'],
'credits_used' => $credits_used,
'payment_intent_id' => $payment_intent_id,
'invoice_sent' => $email_sent
];
$track_log_file = __DIR__ . '/../logs/track_purchases.log';
file_put_contents($track_log_file, json_encode($track_purchase_log) . "\n", FILE_APPEND | LOCK_EX);
return ['success' => true, 'message' => 'Purchase completed successfully', 'purchase_id' => $purchase_id];
} catch (Exception $e) {
$pdo->rollBack();
throw $e;
}
} catch (Exception $e) {
// Log error
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'track_purchase_error',
'user_id' => $user_id,
'track_id' => $track_id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'payment_intent_id' => $payment_intent_id
];
$error_log_file = __DIR__ . '/../logs/track_purchase_errors.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
// Return failure status so caller can retry
return ['success' => false, 'message' => $e->getMessage(), 'purchase_id' => null];
}
}
// Process mixed cart payment (credits + tracks)
function processMixedCartPayment($paymentIntent, $metadata) {
require_once __DIR__ . '/../config/database.php';
try {
$pdo = getDBConnection();
$user_id = $metadata['user_id'] ?? null;
// Parse cart_items from metadata - Stripe stores metadata as strings
$cart_items_json = $metadata['cart_items'] ?? '[]';
if (is_string($cart_items_json)) {
$cart_items = json_decode($cart_items_json, true);
} else {
$cart_items = $cart_items_json;
}
// If cart_items is still not an array, try to get it from the payment intent metadata directly
if (!is_array($cart_items) || empty($cart_items)) {
$cart_items_json = $paymentIntent->metadata->cart_items ?? '[]';
if (is_string($cart_items_json)) {
$cart_items = json_decode($cart_items_json, true);
} else {
$cart_items = $cart_items_json ?? [];
}
}
$total_credits = $metadata['total_credits'] ?? 0;
$subscription_period = $metadata['subscription_period'] ?? '30_days';
if (!$user_id) {
throw new Exception('User ID missing from payment metadata');
}
if (empty($cart_items) || !is_array($cart_items)) {
// Log warning but don't throw - might be a credit-only purchase
$warning_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processMixedCartPayment_warning',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'warning' => 'No cart items found in metadata',
'metadata' => $metadata
];
$warning_log_file = __DIR__ . '/../logs/mixed_cart_payments.log';
file_put_contents($warning_log_file, json_encode($warning_log) . "\n", FILE_APPEND | LOCK_EX);
// If there are credits to add, still process them
if ($total_credits > 0) {
// Try to get package from metadata
$package = $metadata['package'] ?? 'unknown';
addCreditsToUser($user_id, $total_credits, $package, $subscription_period, $paymentIntent->id);
}
return;
}
// Log mixed cart processing
$mixed_cart_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processMixedCartPayment',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'total_credits' => $total_credits,
'cart_items_count' => count($cart_items),
'cart_items' => $cart_items
];
$mixed_cart_log_file = __DIR__ . '/../logs/mixed_cart_payments.log';
file_put_contents($mixed_cart_log_file, json_encode($mixed_cart_log) . "\n", FILE_APPEND | LOCK_EX);
// Track purchased track IDs for invoice
$purchased_track_ids = [];
$total_track_amount = 0;
// Process each cart item with comprehensive logging
$processed_items = [];
$failed_items = [];
foreach ($cart_items as $index => $item) {
$item_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processing_cart_item',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'item_index' => $index,
'item' => $item
];
if (!isset($item['type'])) {
$item_log['status'] = 'failed';
$item_log['error'] = 'Item missing type field';
$failed_items[] = $item_log;
$log_file = __DIR__ . '/../logs/mixed_cart_item_errors.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
error_log("processMixedCartPayment: Item missing type: " . json_encode($item));
continue;
}
try {
// Handle both full format (type, credits, package) and minimal format (t, i, q, a)
$item_type = $item['type'] ?? $item['t'] ?? null;
if ($item_type === 'credit') {
// Add credits to user
// Handle both full and minimal formats
$package = $item['package'] ?? $item['i'] ?? null;
$credits = $item['credits'] ?? null;
$quantity = $item['quantity'] ?? $item['q'] ?? 1;
// If credits not provided, calculate from package
if (!$credits && $package) {
$package_credits_map = [
'starter' => 30,
'pro' => 150,
'premium' => 500
];
$credits = ($package_credits_map[$package] ?? 0) * $quantity;
}
if ($credits && $credits > 0 && $package) {
$item_log['action'] = 'processing_credit_item';
$item_log['package'] = $package;
$item_log['credits'] = $credits;
$item_log['quantity'] = $quantity;
$log_file = __DIR__ . '/../logs/mixed_cart_processing.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
addCreditsToUser($user_id, $credits, $package, $subscription_period, $paymentIntent->id);
$item_log['status'] = 'success';
$processed_items[] = $item_log;
} else {
$item_log['status'] = 'failed';
$item_log['error'] = 'Credit item missing credits or package. Package: ' . ($package ?? 'null') . ', Credits: ' . ($credits ?? 'null');
$failed_items[] = $item_log;
$log_file = __DIR__ . '/../logs/mixed_cart_item_errors.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
error_log("processMixedCartPayment: Credit item missing required fields: " . json_encode($item));
}
} elseif ($item['type'] === 'ticket' || (isset($item['t']) && $item['t'] === 'ticket')) {
// Process ticket purchase - handle both full and minimal formats
$event_id = $item['event_id'] ?? $item['i'] ?? null;
$quantity = $item['quantity'] ?? $item['q'] ?? 1;
if (!$event_id) {
$item_log['status'] = 'failed';
$item_log['error'] = 'Ticket item missing event_id';
$failed_items[] = $item_log;
continue;
}
$item_log['action'] = 'processing_ticket_item';
$item_log['event_id'] = $event_id;
$item_log['quantity'] = $quantity;
$log_file = __DIR__ . '/../logs/mixed_cart_processing.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
try {
// Process ticket purchase
processEventTicketPurchase($user_id, $event_id, $paymentIntent, $quantity);
$item_log['status'] = 'success';
$processed_items[] = $item_log;
} catch (Exception $e) {
$item_log['status'] = 'failed';
$item_log['error'] = $e->getMessage();
$failed_items[] = $item_log;
error_log("processMixedCartPayment: Ticket purchase failed: " . $e->getMessage());
}
} elseif ($item['type'] === 'track') {
// Process track purchase (but don't send invoice yet - we'll send one for all tracks)
if (isset($item['track_id'])) {
$item_log['action'] = 'processing_track_item';
$item_log['track_id'] = $item['track_id'];
$log_file = __DIR__ . '/../logs/mixed_cart_processing.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
// Log BEFORE attempting purchase
$before_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'before_track_purchase',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'track_id' => $item['track_id'],
'item' => $item
];
$before_log_file = __DIR__ . '/../logs/track_purchase_detailed.log';
file_put_contents($before_log_file, json_encode($before_log) . "\n", FILE_APPEND | LOCK_EX);
$credits_used = $item['credits_used'] ?? 0;
try {
// Process purchase without sending email (we'll send one invoice for all tracks)
$purchase_result = processTrackPurchase($user_id, $item['track_id'], $credits_used, $paymentIntent->id, false);
if ($purchase_result['success']) {
// Log AFTER successful purchase
$after_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'after_track_purchase_success',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'track_id' => $item['track_id'],
'status' => 'success',
'purchase_id' => $purchase_result['purchase_id'] ?? null
];
file_put_contents($before_log_file, json_encode($after_log) . "\n", FILE_APPEND | LOCK_EX);
$purchased_track_ids[] = $item['track_id'];
$item_log['status'] = 'success';
$processed_items[] = $item_log;
} else {
// Purchase failed
throw new Exception($purchase_result['message']);
}
// Get track price from database
$track_stmt = $pdo->prepare("SELECT price FROM music_tracks WHERE id = ?");
$track_stmt->execute([$item['track_id']]);
$track_price = $track_stmt->fetch(PDO::FETCH_ASSOC);
if ($track_price) {
$total_track_amount += floatval($track_price['price']);
}
} catch (Exception $e) {
// Log the exception
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'after_track_purchase_error',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'track_id' => $item['track_id'],
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'item' => $item
];
$error_log_file = __DIR__ . '/../logs/track_purchase_detailed_errors.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
$item_log['status'] = 'failed';
$item_log['error'] = $e->getMessage();
$failed_items[] = $item_log;
// Continue processing other items even if one fails
error_log("processMixedCartPayment: Failed to process track {$item['track_id']}: " . $e->getMessage());
}
} else {
$item_log['status'] = 'failed';
$item_log['error'] = 'Track item missing track_id';
$failed_items[] = $item_log;
$log_file = __DIR__ . '/../logs/mixed_cart_item_errors.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
error_log("processMixedCartPayment: Track item missing track_id: " . json_encode($item));
}
} else {
$item_log['status'] = 'failed';
$item_log['error'] = 'Unknown item type: ' . $item['type'];
$failed_items[] = $item_log;
$log_file = __DIR__ . '/../logs/mixed_cart_item_errors.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
}
} catch (Exception $e) {
$item_log['status'] = 'failed';
$item_log['error'] = $e->getMessage();
$item_log['trace'] = $e->getTraceAsString();
$failed_items[] = $item_log;
$log_file = __DIR__ . '/../logs/mixed_cart_item_errors.log';
file_put_contents($log_file, json_encode($item_log) . "\n", FILE_APPEND | LOCK_EX);
}
}
// Log final summary
$summary_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'processMixedCartPayment_summary',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'total_items' => count($cart_items),
'processed_count' => count($processed_items),
'failed_count' => count($failed_items),
'purchased_track_ids' => $purchased_track_ids,
'processed_items' => $processed_items,
'failed_items' => $failed_items
];
$summary_log_file = __DIR__ . '/../logs/mixed_cart_summary.log';
file_put_contents($summary_log_file, json_encode($summary_log) . "\n", FILE_APPEND | LOCK_EX);
// If there were failures, log an alert
if (!empty($failed_items)) {
$alert_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'ALERT_purchase_failures',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'failed_count' => count($failed_items),
'failed_items' => $failed_items,
'total_items' => count($cart_items),
'severity' => 'HIGH'
];
$alert_log_file = __DIR__ . '/../logs/purchase_failure_alerts.log';
file_put_contents($alert_log_file, json_encode($alert_log) . "\n", FILE_APPEND | LOCK_EX);
}
// VALIDATION: Verify purchases match cart after processing
try {
require_once __DIR__ . '/purchase_validation.php';
$validation = validatePurchase($paymentIntent->id, $user_id);
if (!$validation['valid']) {
// Log validation failure
$validation_alert = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'ALERT_purchase_validation_failed',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'issues' => $validation['issues'],
'expected_tracks' => $validation['expected'] ?? [],
'actual_tracks' => $validation['actual'] ?? [],
'severity' => 'CRITICAL'
];
$validation_log_file = __DIR__ . '/../logs/purchase_validation_failures.log';
file_put_contents($validation_log_file, json_encode($validation_alert) . "\n", FILE_APPEND | LOCK_EX);
// Also add to general alerts
$alert_log_file = __DIR__ . '/../logs/purchase_failure_alerts.log';
file_put_contents($alert_log_file, json_encode($validation_alert) . "\n", FILE_APPEND | LOCK_EX);
error_log("CRITICAL: Purchase validation failed for payment_intent_id: {$paymentIntent->id}. Issues: " . implode(', ', $validation['issues']));
} else {
// Log successful validation
$validation_success = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'purchase_validation_passed',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id
];
$validation_log_file = __DIR__ . '/../logs/purchase_validation_success.log';
file_put_contents($validation_log_file, json_encode($validation_success) . "\n", FILE_APPEND | LOCK_EX);
}
} catch (Exception $e) {
error_log("Warning: Purchase validation check failed: " . $e->getMessage());
}
// Send invoice email if tracks were purchased
if (!empty($purchased_track_ids)) {
try {
require_once __DIR__ . '/../config/email.php';
// Get user information
$user_stmt = $pdo->prepare("SELECT name, email FROM users WHERE id = ?");
$user_stmt->execute([$user_id]);
$user = $user_stmt->fetch(PDO::FETCH_ASSOC);
// Get billing address from payment intent metadata
$billing_address = [];
try {
$stripe_secret = getStripeSecretKey();
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://api.stripe.com/v1/payment_intents/' . $paymentIntent->id);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $stripe_secret]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$stripe_response = curl_exec($ch);
curl_close($ch);
if ($stripe_response) {
$stripe_data = json_decode($stripe_response, true);
if (isset($stripe_data['charges']['data'][0]['billing_details'])) {
$billing = $stripe_data['charges']['data'][0]['billing_details'];
$billing_address = [
'billing_first_name' => explode(' ', $billing['name'] ?? '')[0] ?? '',
'billing_last_name' => explode(' ', $billing['name'] ?? '', 2)[1] ?? '',
'billing_email' => $billing['email'] ?? ($user['email'] ?? ''),
'billing_address' => $billing['address']['line1'] ?? '',
'billing_city' => $billing['address']['city'] ?? '',
'billing_state' => $billing['address']['state'] ?? '',
'billing_zip' => $billing['address']['postal_code'] ?? '',
'billing_country' => $billing['address']['country'] ?? ''
];
}
// Also check metadata for billing info
if (empty($billing_address) && isset($stripe_data['metadata'])) {
$meta = $stripe_data['metadata'];
if (isset($meta['billing_email'])) {
$billing_address = [
'billing_first_name' => $meta['billing_name'] ?? '',
'billing_last_name' => '',
'billing_email' => $meta['billing_email'] ?? ($user['email'] ?? ''),
'billing_address' => $meta['billing_address'] ?? '',
'billing_city' => $meta['billing_city'] ?? '',
'billing_state' => $meta['billing_state'] ?? '',
'billing_zip' => $meta['billing_zip'] ?? '',
'billing_country' => $meta['billing_country'] ?? ''
];
}
}
}
} catch (Exception $e) {
error_log("Could not fetch billing address from Stripe: " . $e->getMessage());
}
if ($user) {
$invoice_data = generateInvoiceEmail($user['name'], $user['email'], [
'purchase_id' => null, // Multiple purchases
'track_ids' => $purchased_track_ids,
'price_paid' => $total_track_amount,
'total_amount' => $total_track_amount,
'payment_method' => 'stripe',
'payment_intent_id' => $paymentIntent->id,
'purchase_date' => date('Y-m-d H:i:s'),
'billing_address' => $billing_address
]);
$email_sent = sendEmail(
$user['email'],
$user['name'],
$invoice_data['subject'],
$invoice_data['html'],
$invoice_data['text'],
'invoice',
$user_id,
$paymentIntent->id
);
if ($email_sent) {
error_log("Invoice email sent successfully for mixed cart to: " . $user['email']);
} else {
error_log("Failed to send invoice email for mixed cart to: " . $user['email']);
}
}
} catch (Exception $e) {
error_log("Error sending invoice email for mixed cart: " . $e->getMessage());
}
}
// Log successful mixed cart processing
$success_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'mixed_cart_payment_success',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id,
'items_processed' => count($cart_items),
'tracks_purchased' => count($purchased_track_ids),
'invoice_sent' => !empty($purchased_track_ids) && ($email_sent ?? false)
];
file_put_contents($mixed_cart_log_file, json_encode($success_log) . "\n", FILE_APPEND | LOCK_EX);
// Return success status (check if any critical items failed)
$has_critical_failures = !empty($failed_items);
return [
'success' => !$has_critical_failures,
'message' => $has_critical_failures ? 'Some items failed to process' : 'All items processed successfully',
'processed_count' => count($processed_items),
'failed_count' => count($failed_items)
];
} catch (Exception $e) {
// Log mixed cart error
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'mixed_cart_payment_error',
'payment_intent_id' => $paymentIntent->id,
'user_id' => $user_id ?? 'unknown',
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
];
$error_log_file = __DIR__ . '/../logs/mixed_cart_errors.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
return ['success' => false, 'message' => $e->getMessage(), 'processed_count' => 0, 'failed_count' => 0];
}
}
function handleFailedPayment($paymentIntent) {
// Update your database with failed payment
// Send failure notification
// Update user subscription status
// Example database update
/*
$db = new PDO('mysql:host=localhost;dbname=your_db', 'username', 'password');
$stmt = $db->prepare("UPDATE payments SET status = 'failed', failure_reason = ?, processed_at = NOW() WHERE stripe_payment_intent_id = ?");
$stmt->execute([$paymentIntent->last_payment_error->message ?? 'Unknown error', $paymentIntent->id]);
*/
// Log the action
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleFailedPayment',
'payment_intent_id' => $paymentIntent->id,
'failure_reason' => $paymentIntent->last_payment_error->message ?? 'Unknown error'
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
}
function handleSubscriptionCreated($subscription) {
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../utils/subscription_helpers.php';
require_once __DIR__ . '/../includes/grace_period.php';
require_once __DIR__ . '/../config/subscription_plans.php';
// Load plans config once at function start
$plans_config = require __DIR__ . '/../config/subscription_plans.php';
try {
$pdo = getDBConnection();
// Get user by Stripe customer ID
$customer_id = $subscription->customer;
$stmt = $pdo->prepare("SELECT id FROM users WHERE stripe_customer_id = ?");
$stmt->execute([$customer_id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
// Try to get user from metadata
$metadata = $subscription->metadata ?? (object)[];
$user_id = $metadata->user_id ?? null;
if (!$user_id) {
throw new Exception("User not found for customer: {$customer_id}");
}
} else {
$user_id = $user['id'];
}
// Determine plan name from subscription items (improved detection)
$plan_name = 'essential'; // Default
$price_id = null;
// Primary: Get plan from price ID (most reliable)
if (isset($subscription->items->data[0]->price->id)) {
$price_id = $subscription->items->data[0]->price->id;
// Match price ID to plan in config
foreach ($plans_config as $plan_key => $plan_data) {
if (isset($plan_data['stripe_price_id']) && $plan_data['stripe_price_id'] === $price_id) {
$plan_name = $plan_key;
break;
}
}
}
// Fallback 1: Check price metadata
if ($plan_name === 'essential' && isset($subscription->items->data[0]->price->metadata->plan_name)) {
$plan_name = $subscription->items->data[0]->price->metadata->plan_name;
}
// Fallback 2: Check subscription metadata
if ($plan_name === 'essential' && isset($subscription->metadata->plan)) {
$plan_name = $subscription->metadata->plan;
}
// Log warning if plan not found (for admin alert)
if ($plan_name === 'essential' && $price_id) {
error_log("WARNING: Plan not found for price_id: {$price_id} (subscription: {$subscription->id}, user: {$user_id}). Defaulting to 'essential'. Admin should verify.");
}
$track_limit = 5; // Default
if (isset($plans_config[$plan_name])) {
$track_limit = $plans_config[$plan_name]['tracks_per_month'];
}
// HIGH PRIORITY FIX: Check for existing active subscriptions and cancel old ones
// This prevents users from having multiple active subscriptions
$existing_active_stmt = $pdo->prepare("
SELECT id, stripe_subscription_id, status, created_at
FROM user_subscriptions
WHERE user_id = ? AND status IN ('active', 'trialing')
AND stripe_subscription_id != ?
ORDER BY created_at DESC
");
$existing_active_stmt->execute([$user_id, $subscription->id]);
$all_active_subs = $existing_active_stmt->fetchAll(PDO::FETCH_ASSOC);
if (!empty($all_active_subs)) {
error_log("User {$user_id} has multiple active subscriptions. Canceling old ones. New subscription: {$subscription->id}");
// Cancel old subscriptions in Stripe
$stripe_secret = getStripeSecretKey();
foreach ($all_active_subs as $old_sub) {
$old_stripe_id = $old_sub['stripe_subscription_id'];
try {
// Cancel old subscription in Stripe
$cancel_ch = curl_init();
curl_setopt($cancel_ch, CURLOPT_URL, "https://api.stripe.com/v1/subscriptions/{$old_stripe_id}");
curl_setopt($cancel_ch, CURLOPT_HTTPHEADER, ['Authorization: Bearer ' . $stripe_secret]);
curl_setopt($cancel_ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($cancel_ch, CURLOPT_POST, true);
curl_setopt($cancel_ch, CURLOPT_POSTFIELDS, http_build_query(['cancel_at_period_end' => 'false'])); // Cancel immediately
$cancel_response = curl_exec($cancel_ch);
$cancel_http = curl_getinfo($cancel_ch, CURLINFO_HTTP_CODE);
curl_close($cancel_ch);
if ($cancel_http === 200) {
error_log("Canceled old subscription {$old_stripe_id} for user {$user_id} (new subscription: {$subscription->id})");
} else {
error_log("Failed to cancel old subscription {$old_stripe_id} in Stripe: HTTP {$cancel_http}");
}
} catch (Exception $cancel_e) {
error_log("Error canceling old subscription {$old_stripe_id}: " . $cancel_e->getMessage());
}
}
}
$pdo->beginTransaction();
// Create or update subscription record
$stmt = $pdo->prepare("
INSERT INTO user_subscriptions (
user_id, stripe_subscription_id, stripe_customer_id, plan_name, status,
current_period_start, current_period_end, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
status = VALUES(status),
current_period_start = VALUES(current_period_start),
current_period_end = VALUES(current_period_end),
updated_at = NOW()
");
$period_start = date('Y-m-d H:i:s', $subscription->current_period_start);
$period_end = date('Y-m-d H:i:s', $subscription->current_period_end);
$stmt->execute([
$user_id,
$subscription->id,
$customer_id,
$plan_name,
$subscription->status,
$period_start,
$period_end
]);
// Update user plan and save Stripe customer ID if not already set
$stmt = $pdo->prepare("UPDATE users SET plan = ?, stripe_customer_id = COALESCE(stripe_customer_id, ?) WHERE id = ?");
$stmt->execute([$plan_name, $customer_id, $user_id]);
// Initialize monthly track usage for current subscription period
$period_start = date('Y-m-d H:i:s', $subscription->current_period_start);
$year_month = date('Y-m', $subscription->current_period_start); // For backward compatibility
// Get subscription ID
$sub_stmt = $pdo->prepare("SELECT id FROM user_subscriptions WHERE stripe_subscription_id = ?");
$sub_stmt->execute([$subscription->id]);
$sub_record = $sub_stmt->fetch(PDO::FETCH_ASSOC);
$subscription_id = $sub_record['id'] ?? null;
$stmt = $pdo->prepare("
INSERT INTO monthly_track_usage (
user_id, subscription_id, subscription_period_start,
year_month, tracks_created, track_limit, reset_at
)
VALUES (?, ?, ?, ?, 0, ?, NOW())
ON DUPLICATE KEY UPDATE
track_limit = VALUES(track_limit),
reset_at = NOW()
");
$stmt->execute([
$user_id,
$subscription_id,
$period_start,
$year_month,
$track_limit
]);
// Mark old active subscriptions as canceled in database
if (!empty($all_active_subs)) {
foreach ($all_active_subs as $old_sub) {
$cancel_stmt = $pdo->prepare("UPDATE user_subscriptions SET status = 'canceled', updated_at = NOW() WHERE id = ?");
$cancel_stmt->execute([$old_sub['id']]);
}
error_log("Marked " . count($all_active_subs) . " old subscriptions as canceled in database for user {$user_id}");
}
$pdo->commit();
// ============================================================
// GRACE PERIOD CONVERSION
// If user upgrades within 30 days of account creation,
// convert their grace-period-eligible tracks to commercial rights
// ============================================================
$grace_conversion_result = null;
try {
$grace_conversion_result = convertGracePeriodTracks($user_id);
if ($grace_conversion_result['success'] && $grace_conversion_result['converted_count'] > 0) {
error_log("Grace period conversion: User $user_id upgraded within 30 days, converted {$grace_conversion_result['converted_count']} tracks to commercial rights");
}
} catch (Exception $grace_e) {
error_log("Grace period conversion warning (non-fatal): " . $grace_e->getMessage());
}
// Log success
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSubscriptionCreated',
'subscription_id' => $subscription->id,
'customer_id' => $customer_id,
'user_id' => $user_id,
'plan_name' => $plan_name,
'price_id' => $price_id,
'status' => $subscription->status,
'track_limit' => $track_limit,
'old_subscriptions_canceled' => count($all_active_subs ?? []),
'grace_period_conversion' => $grace_conversion_result
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
} catch (Exception $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSubscriptionCreated_error',
'subscription_id' => $subscription->id,
'error' => $e->getMessage()
];
$error_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
error_log("Subscription creation error: " . $e->getMessage());
}
}
function handleSubscriptionUpdated($subscription) {
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../includes/grace_period.php';
try {
$pdo = getDBConnection();
// Get existing subscription record to detect changes (BEFORE updating)
$existing_stmt = $pdo->prepare("SELECT id, user_id, plan_name, current_period_start FROM user_subscriptions WHERE stripe_subscription_id = ?");
$existing_stmt->execute([$subscription->id]);
$existing_sub = $existing_stmt->fetch(PDO::FETCH_ASSOC);
// If subscription doesn't exist, log and return (shouldn't happen but handle gracefully)
if (!$existing_sub) {
error_log("Warning: Subscription {$subscription->id} not found in database during update webhook");
return; // Exit early - subscription doesn't exist in our DB
}
// Store old values BEFORE updating
$old_plan_name = $existing_sub['plan_name'];
$old_period_start = $existing_sub['current_period_start'];
$user_id = $existing_sub['user_id'];
$subscription_db_id = $existing_sub['id'];
// Determine plan name from subscription price
$new_plan_name = 'essential'; // Default
if (!empty($subscription->items->data[0]->price->id)) {
$price_id = $subscription->items->data[0]->price->id;
require_once __DIR__ . '/../config/subscription_plans.php';
$plans_config = require __DIR__ . '/../config/subscription_plans.php';
foreach ($plans_config as $plan_key => $plan_data) {
if (isset($plan_data['stripe_price_id']) && $plan_data['stripe_price_id'] === $price_id) {
$new_plan_name = $plan_key;
break;
}
}
}
// Detect if plan changed (using OLD plan name)
$plan_changed = false;
if ($old_plan_name !== $new_plan_name) {
$plan_changed = true;
error_log("Plan change detected: {$old_plan_name} → {$new_plan_name} for subscription {$subscription->id} (user {$user_id})");
}
// Update subscription record
$stmt = $pdo->prepare("
UPDATE user_subscriptions
SET status = ?,
plan_name = ?,
current_period_start = ?,
current_period_end = ?,
cancel_at_period_end = ?,
updated_at = NOW()
WHERE stripe_subscription_id = ?
");
$period_start = date('Y-m-d H:i:s', $subscription->current_period_start);
$period_end = date('Y-m-d H:i:s', $subscription->current_period_end);
$cancel_at_period_end = $subscription->cancel_at_period_end ? 1 : 0;
$stmt->execute([
$subscription->status,
$new_plan_name,
$period_start,
$period_end,
$cancel_at_period_end,
$subscription->id
]);
// Update user plan based on subscription status
if ($user_id) {
if (in_array($subscription->status, ['active', 'trialing'])) {
// Active subscription - update to new plan
$user_update_stmt = $pdo->prepare("UPDATE users SET plan = ? WHERE id = ?");
$user_update_stmt->execute([$new_plan_name, $user_id]);
error_log("Updated user {$user_id} plan to {$new_plan_name} (subscription {$subscription->id})");
} elseif (in_array($subscription->status, ['canceled', 'past_due', 'unpaid'])) {
// Subscription canceled/refunded - set to 'free'
$user_update_stmt = $pdo->prepare("UPDATE users SET plan = 'free' WHERE id = ?");
$user_update_stmt->execute([$user_id]);
// Also update subscription plan_name to 'free' for consistency
$sub_update_stmt = $pdo->prepare("UPDATE user_subscriptions SET plan_name = 'free' WHERE id = ?");
$sub_update_stmt->execute([$subscription_db_id]);
error_log("Subscription canceled/refunded - Set user {$user_id} plan to 'free' (subscription {$subscription->id})");
}
}
// If subscription renewed (new billing period started), reset monthly usage
if ($subscription->status === 'active' && $user_id) {
// Check if billing period actually changed (renewal occurred)
$stripe_period_start = date('Y-m-d H:i:s', $subscription->current_period_start);
$stripe_timestamp = $subscription->current_period_start; // Unix timestamp from Stripe
// Convert old period start to comparable format
$db_period_start = $old_period_start;
$db_timestamp = null;
if (is_numeric($db_period_start)) {
$db_timestamp = $db_period_start;
$db_period_start = date('Y-m-d H:i:s', $db_period_start);
} elseif (is_string($db_period_start)) {
// Already in datetime format - convert to timestamp for comparison
$db_timestamp = strtotime($db_period_start);
} else {
$db_period_start = null;
}
// CRITICAL: Use timestamp comparison to avoid timezone/format issues
// Only reset if timestamps differ by more than 1 hour (to account for minor differences)
// This prevents false resets due to timezone or format mismatches
$period_changed = false;
if ($db_timestamp && $stripe_timestamp) {
$time_diff = abs($stripe_timestamp - $db_timestamp);
$period_changed = ($time_diff > 3600); // More than 1 hour difference = new period
// Log the comparison for debugging
error_log("Subscription period check for user {$user_id}: DB timestamp={$db_timestamp} (" . ($db_period_start ?: 'N/A') . "), Stripe timestamp={$stripe_timestamp} (" . $stripe_period_start . "), Time diff={$time_diff}s, Period changed=" . ($period_changed ? 'YES' : 'NO'));
// If period appears to have changed, check current usage before resetting
if ($period_changed) {
$current_usage_stmt = $pdo->prepare("
SELECT tracks_created, track_limit, subscription_period_start
FROM monthly_track_usage
WHERE user_id = ? AND subscription_id = ?
ORDER BY subscription_period_start DESC
LIMIT 1
");
$current_usage_stmt->execute([$user_id, $subscription_db_id]);
$current_usage = $current_usage_stmt->fetch(PDO::FETCH_ASSOC);
if ($current_usage && $current_usage['tracks_created'] > 0) {
error_log("WARNING: About to reset usage with {$current_usage['tracks_created']}/{$current_usage['track_limit']} tracks created. Old period: {$db_period_start}, New period: {$stripe_period_start}. This is expected on billing renewal.");
}
}
} else {
// If we can't compare, log warning but don't reset
error_log("WARNING: Cannot compare subscription periods for user {$user_id}. DB timestamp: " . ($db_timestamp ?: 'NULL') . ", Stripe timestamp: " . ($stripe_timestamp ?: 'NULL') . ". NOT resetting usage.");
}
// Get track limit from config (use new plan name if changed)
$plans_config = require __DIR__ . '/../config/subscription_plans.php';
$plan_name_to_use = $plan_changed ? $new_plan_name : $old_plan_name;
$track_limit = 5; // Default
if (isset($plans_config[$plan_name_to_use]) && isset($plans_config[$plan_name_to_use]['tracks_per_month'])) {
$track_limit = $plans_config[$plan_name_to_use]['tracks_per_month'];
}
if ($period_changed) {
// New billing period started - reset usage for this period
$new_period_start = $stripe_period_start;
$year_month = date('Y-m', $subscription->current_period_start); // For backward compatibility
// SAFEGUARD: Check if usage record already exists for this period
$check_existing = $pdo->prepare("
SELECT id, tracks_created, track_limit
FROM monthly_track_usage
WHERE user_id = ? AND subscription_period_start = ?
");
$check_existing->execute([$user_id, $new_period_start]);
$existing_usage = $check_existing->fetch(PDO::FETCH_ASSOC);
if ($existing_usage) {
// Record already exists - only update if tracks_created is 0 (shouldn't happen, but be safe)
if ($existing_usage['tracks_created'] > 0) {
error_log("WARNING: Usage record already exists for period {$new_period_start} with {$existing_usage['tracks_created']} tracks. NOT resetting to prevent data loss.");
} else {
// Update existing record (shouldn't happen, but handle gracefully)
$stmt = $pdo->prepare("
UPDATE monthly_track_usage
SET track_limit = ?,
reset_at = NOW(),
updated_at = NOW()
WHERE user_id = ? AND subscription_period_start = ?
");
$stmt->execute([$track_limit, $user_id, $new_period_start]);
error_log("Updated existing usage record for user {$user_id}, period: {$new_period_start}, limit: {$track_limit}");
}
} else {
// Create new usage record for new subscription period (resets to 0)
$stmt = $pdo->prepare("
INSERT INTO monthly_track_usage (
user_id, subscription_id, subscription_period_start,
year_month, tracks_created, track_limit, reset_at
)
VALUES (?, ?, ?, ?, 0, ?, NOW())
");
$stmt->execute([
$user_id,
$subscription_db_id,
$new_period_start,
$year_month,
$track_limit
]);
// Log the reset
error_log("Subscription renewed - Created new usage record for user {$user_id}, new period: {$new_period_start}, limit: {$track_limit}, plan: {$plan_name_to_use}");
}
} elseif ($plan_changed) {
// Plan changed but same period - update track limit for current period
$current_period_start = $stripe_period_start;
$year_month = date('Y-m', $subscription->current_period_start);
// Get current tracks_created to check if we need to cap it
$current_usage_stmt = $pdo->prepare("
SELECT tracks_created, track_limit FROM monthly_track_usage
WHERE user_id = ? AND subscription_period_start = ?
");
$current_usage_stmt->execute([$user_id, $current_period_start]);
$current_usage = $current_usage_stmt->fetch(PDO::FETCH_ASSOC);
$current_tracks_created = (int)($current_usage['tracks_created'] ?? 0);
$old_track_limit = (int)($current_usage['track_limit'] ?? 0);
$is_downgrade = $track_limit < $old_track_limit;
// CRITICAL: If downgrading and tracks_created exceeds new limit, cap it
$new_tracks_created = $current_tracks_created;
if ($is_downgrade && $current_tracks_created > $track_limit) {
$new_tracks_created = $track_limit;
error_log("SECURITY: User {$user_id} downgraded from {$old_plan_name} ({$old_track_limit} tracks) to {$new_plan_name} ({$track_limit} tracks). Capping tracks_created from {$current_tracks_created} to {$track_limit}");
}
// Update existing usage record with new limit (and capped tracks_created if downgrading)
$stmt = $pdo->prepare("
UPDATE monthly_track_usage
SET track_limit = ?,
tracks_created = ?,
updated_at = NOW()
WHERE user_id = ? AND subscription_period_start = ?
");
$stmt->execute([
$track_limit,
$new_tracks_created,
$user_id,
$current_period_start
]);
$action = $is_downgrade ? 'Downgraded' : 'Upgraded';
error_log("Plan {$action} - Updated track limit for user {$user_id}, plan: {$old_plan_name} → {$new_plan_name}, limit: {$old_track_limit} → {$track_limit}, tracks: {$current_tracks_created} → {$new_tracks_created}");
// ============================================================
// GRACE PERIOD CONVERSION (on upgrade only)
// If user upgrades from free within 30 days, convert eligible tracks
// ============================================================
if (!$is_downgrade && $old_plan_name === 'free' && $new_plan_name !== 'free') {
try {
$grace_conversion_result = convertGracePeriodTracks($user_id);
if ($grace_conversion_result['success'] && $grace_conversion_result['converted_count'] > 0) {
error_log("Grace period conversion on plan change: User $user_id converted {$grace_conversion_result['converted_count']} tracks to commercial rights");
}
} catch (Exception $grace_e) {
error_log("Grace period conversion warning (non-fatal): " . $grace_e->getMessage());
}
}
}
}
// Log update
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSubscriptionUpdated',
'subscription_id' => $subscription->id,
'status' => $subscription->status
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
} catch (Exception $e) {
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSubscriptionUpdated_error',
'subscription_id' => $subscription->id,
'error' => $e->getMessage()
];
$error_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
}
}
function handleSubscriptionDeleted($subscription) {
require_once __DIR__ . '/../config/database.php';
try {
$pdo = getDBConnection();
// Find user by Stripe customer ID
$customer_id = $subscription->customer;
$stmt = $pdo->prepare("SELECT id FROM users WHERE stripe_customer_id = ?");
$stmt->execute([$customer_id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user) {
$user_id = $user['id'];
// Update subscription status
$stmt = $pdo->prepare("
UPDATE user_subscriptions
SET status = 'canceled',
updated_at = NOW()
WHERE stripe_subscription_id = ?
");
$stmt->execute([$subscription->id]);
// Update user plan to 'free' when subscription is cancelled
$stmt = $pdo->prepare("
UPDATE users
SET plan = 'free'
WHERE id = ?
");
$stmt->execute([$user_id]);
// Log the action
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSubscriptionDeleted',
'subscription_id' => $subscription->id,
'customer_id' => $customer_id,
'user_id' => $user_id,
'status' => $subscription->status,
'plan_downgraded_to' => 'free'
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
} else {
// Log if user not found
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSubscriptionDeleted_user_not_found',
'subscription_id' => $subscription->id,
'customer_id' => $customer_id,
'status' => $subscription->status
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
}
} catch (Exception $e) {
// Log error
$error_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleSubscriptionDeleted_error',
'subscription_id' => $subscription->id,
'customer_id' => $subscription->customer,
'error' => $e->getMessage()
];
$error_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($error_log_file, json_encode($error_log) . "\n", FILE_APPEND | LOCK_EX);
}
}
function handleInvoicePaymentSucceeded($invoice) {
// Handle successful invoice payment
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleInvoicePaymentSucceeded',
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'amount_paid' => $invoice->amount_paid / 100
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
}
function handleInvoicePaymentFailed($invoice) {
// Handle failed invoice payment
$action_log = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'handleInvoicePaymentFailed',
'invoice_id' => $invoice->id,
'subscription_id' => $invoice->subscription,
'attempt_count' => $invoice->attempt_count
];
$action_log_file = __DIR__ . '/../logs/stripe_actions.log';
file_put_contents($action_log_file, json_encode($action_log) . "\n", FILE_APPEND | LOCK_EX);
}
// Process event ticket purchase
function processEventTicketPurchase($user_id, $event_id, $paymentIntent, $quantity = 1) {
require_once __DIR__ . '/../config/database.php';
try {
$pdo = getDBConnection();
$pdo->beginTransaction();
// Get event info
$stmt = $pdo->prepare("SELECT id, creator_id, ticket_price FROM events WHERE id = ?");
$stmt->execute([$event_id]);
$event = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$event) {
throw new Exception("Event not found");
}
// Get quantity from parameter or metadata (default to 1 if not set)
if ($quantity <= 0) {
$quantity = isset($paymentIntent->metadata->quantity) ? max(1, (int)$paymentIntent->metadata->quantity) : 1;
}
$unit_price = $event['ticket_price'];
$total_price = $unit_price * $quantity;
$ticket_ids = [];
$ticket_codes = [];
// Create multiple tickets
for ($i = 0; $i < $quantity; $i++) {
// Generate unique ticket code
$ticket_code = 'EVT-' . strtoupper(substr(md5($event_id . $user_id . time() . $i), 0, 12));
// Generate QR code data
$qr_data = json_encode([
'ticket_code' => $ticket_code,
'event_id' => $event_id,
'user_id' => $user_id,
'timestamp' => time()
]);
// Create ticket
$stmt = $pdo->prepare("
INSERT INTO event_tickets (
event_id, user_id, ticket_code, qr_code_data,
price_paid, payment_method, stripe_payment_intent_id, status
) VALUES (?, ?, ?, ?, ?, 'stripe', ?, 'confirmed')
");
$stmt->execute([
$event_id,
$user_id,
$ticket_code,
$qr_data,
$unit_price,
$paymentIntent->id
]);
$ticket_id = $pdo->lastInsertId();
$ticket_ids[] = $ticket_id;
$ticket_codes[] = $ticket_code;
// Record sale for each ticket
$stmt = $pdo->prepare("
INSERT INTO event_ticket_sales (
ticket_id, event_id, buyer_id, event_creator_id,
amount, revenue_recipient, recipient_id
) VALUES (?, ?, ?, ?, ?, 'event_creator', ?)
");
$stmt->execute([
$ticket_id,
$event_id,
$user_id,
$event['creator_id'],
$unit_price,
$event['creator_id']
]);
$sale_id = $pdo->lastInsertId();
// Notify organizer of the ticket sale (only once per purchase, not per ticket)
if ($i === 0) {
require_once __DIR__ . '/../utils/artist_notifications.php';
notifyOrganizerOfTicketSale(
$event['creator_id'],
$event_id,
$user_id,
$unit_price,
$quantity,
$sale_id
);
}
}
// Update event attendee count
$stmt = $pdo->prepare("
INSERT INTO event_attendees (event_id, user_id, status)
VALUES (?, ?, 'attending')
ON DUPLICATE KEY UPDATE status = 'attending'
");
$stmt->execute([$event_id, $user_id]);
$pdo->commit();
// Log success
$log_data = [
'timestamp' => date('Y-m-d H:i:s'),
'action' => 'event_ticket_purchased',
'ticket_ids' => $ticket_ids,
'ticket_codes' => $ticket_codes,
'quantity' => $quantity,
'total_price' => $total_price,
'event_id' => $event_id,
'user_id' => $user_id,
'payment_intent_id' => $paymentIntent->id
];
$log_file = __DIR__ . '/../logs/event_tickets.log';
file_put_contents($log_file, json_encode($log_data) . "\n", FILE_APPEND | LOCK_EX);
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log("Event Ticket Purchase Error: " . $e->getMessage());
}
}
?>