diff --git a/composer.json b/composer.json index 3968eaa..5d3a0cf 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ ], "require": { "php": ">=7.4", - "phpmailer/phpmailer": "^6.8" + "phpmailer/phpmailer": "^6.8", + "stripe/stripe-php": "^10.0 || ^11.0 || ^12.0 || ^13.0 || ^14.0", + "symfony/polyfill-mbstring": "^1.27" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 3e655f3..d2c47cd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa1767c4b73d4c64f1f4b5531b361ba5", + "content-hash": "a2f0f2a9d31ce45269c808c0c1b65daf", "packages": [ { "name": "phpmailer/phpmailer", @@ -86,6 +86,145 @@ } ], "time": "2024-11-24T18:04:13+00:00" + }, + { + "name": "stripe/stripe-php", + "version": "v14.10.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "7e1c4b5d2beadeaeddc42fd1f8a50fdb18b37f30" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/7e1c4b5d2beadeaeddc42fd1f8a50fdb18b37f30", + "reference": "7e1c4b5d2beadeaeddc42fd1f8a50fdb18b37f30", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v14.10.0" + }, + "time": "2024-06-13T21:04:47+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" } ], "packages-dev": [ diff --git a/quiztech-assessment-platform.php b/quiztech-assessment-platform.php index 5dd9979..a24c45b 100644 --- a/quiztech-assessment-platform.php +++ b/quiztech-assessment-platform.php @@ -293,3 +293,17 @@ function quiztech_configure_smtp( $phpmailer ) { } add_action( 'phpmailer_init', 'quiztech_configure_smtp' ); + + +/** + * Register REST API endpoints. + */ +function quiztech_register_rest_routes() { + register_rest_route( 'quiztech/v1', '/webhook/stripe', array( + 'methods' => 'POST', + 'callback' => '\Quiztech\AssessmentPlatform\Includes\quiztech_handle_payment_webhook', + 'permission_callback' => '__return_true' // Allow public access - Stripe needs to reach this + ) ); +} +add_action( 'rest_api_init', 'quiztech_register_rest_routes' ); + diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php index e715629..ba6fa3f 100644 --- a/src/Admin/SettingsPage.php +++ b/src/Admin/SettingsPage.php @@ -96,6 +96,21 @@ class SettingsPage { ] ); + // Stripe Webhook Secret Field + add_settings_field( + 'quiztech_stripe_webhook_secret', // Field ID + __( 'Stripe Webhook Secret', 'quiztech' ), // Field Title + [ $this, 'render_text_field' ], // Field render callback + $this->page_slug, // Page slug + 'quiztech_stripe_section', // Section ID + [ // Arguments for callback + 'id' => 'quiztech_stripe_webhook_secret', + 'option_name' => $this->option_name, + 'key' => 'stripe_webhook_secret', + 'type' => 'password', // Mask the input + 'description' => __( 'Enter your Stripe webhook signing secret (whsec_...). Required for processing payments.', 'quiztech' ) + ] + ); // --- SMTP Section --- add_settings_section( @@ -393,6 +408,11 @@ class SettingsPage { /** * Sanitizes the settings array before saving. + if ( isset( $input['stripe_webhook_secret'] ) ) { + // Basic sanitization, starts with whsec_ + $sanitized_input['stripe_webhook_secret'] = sanitize_text_field( $input['stripe_webhook_secret'] ); + } + * * @param array $input The raw input array from the form. * @return array The sanitized array. diff --git a/src/Gateways/StripeGateway.php b/src/Gateways/StripeGateway.php index 1efef1b..bb65975 100644 --- a/src/Gateways/StripeGateway.php +++ b/src/Gateways/StripeGateway.php @@ -1,6 +1,12 @@ ID); + + $success_url = add_query_arg( 'purchase_status', 'success', $base_url ); + $cancel_url = add_query_arg( 'purchase_status', 'cancelled', $base_url ); + + // Create a descriptive name for the line item + // Extract number from item_id like '10_credits' + preg_match('/^(\d+)_credits$/', $item_id, $matches); + $credit_amount_display = isset($matches[1]) ? $matches[1] : $item_id; + $item_name = sprintf( __( 'Quiztech Credits - %s Pack', 'quiztech' ), $credit_amount_display ); + + + $session = Session::create( [ + 'payment_method_types' => ['card'], + 'line_items' => [ + [ + 'price_data' => [ + 'currency' => strtolower($currency), // Stripe expects lowercase currency + 'product_data' => [ + 'name' => $item_name, + // 'description' => 'Optional description here', + ], + 'unit_amount' => $price_in_cents, + ], + 'quantity' => $quantity, + ], + ], + 'mode' => 'payment', + 'success_url' => $success_url, + 'cancel_url' => $cancel_url, + 'client_reference_id' => $user_id, // Pass user ID for reference + 'metadata' => $metadata, // Pass our custom metadata + ] ); + + // Redirect to Stripe Checkout + if ( isset( $session->url ) ) { + wp_safe_redirect( $session->url ); + exit; + } else { + return new WP_Error( 'stripe_session_error', __( 'Could not create Stripe Checkout session.', 'quiztech' ) ); + } + + } catch ( ApiErrorException $e ) { + error_log( 'Stripe API Error: ' . $e->getMessage() ); + return new WP_Error( 'stripe_api_error', sprintf( __( 'Payment gateway error: %s', 'quiztech' ), $e->getMessage() ) ); + } catch ( \Exception $e ) { + error_log( 'General Payment Error: ' . $e->getMessage() ); + return new WP_Error( 'payment_error', __( 'An unexpected error occurred during payment processing.', 'quiztech' ) ); + } } /** - * Handle incoming Stripe webhooks. + * Verify and construct the Stripe Webhook event. * - * @param array $payload The webhook payload (usually from file_get_contents('php://input')). + * @param string $payload Raw request body. + * @param string $signature Stripe-Signature header value. + * @param string $webhook_secret The webhook endpoint secret. + * @return \Stripe\Event|WP_Error Stripe Event object on success, WP_Error on failure. + */ + public function verifyWebhook( $payload, $signature, $webhook_secret ) { + try { + // Ensure Stripe library is loaded + if ( ! class_exists( '\Stripe\Webhook' ) ) { // Check with leading slash here is okay as it's a general check + throw new \Exception('Stripe PHP library Webhook class not found.'); + } + + $event = Webhook::constructEvent( + $payload, $signature, $webhook_secret + ); + return $event; + + } catch( \UnexpectedValueException $e ) { + // Invalid payload + return new WP_Error('stripe_webhook_payload_error', 'Invalid webhook payload: ' . $e->getMessage()); + } catch( \Stripe\Exception\SignatureVerificationException $e ) { + // Invalid signature + return new WP_Error('stripe_webhook_signature_error', 'Invalid webhook signature: ' . $e->getMessage()); + } catch ( \Exception $e ) { + // Other errors + return new WP_Error('stripe_webhook_error', 'Webhook processing error: ' . $e->getMessage()); + } + } + + /** + * Handle incoming Stripe webhooks. (DEPRECATED - Logic moved to quiztech_handle_payment_webhook) + * + * @param string $payload The webhook payload (usually from file_get_contents('php://input')). * @param string $signature The value of the Stripe-Signature header. * @return bool True if handled successfully, false otherwise. */ public function handleWebhook( $payload, $signature ) { - // Placeholder for Stripe webhook handling logic - // 1. Verify the webhook signature using the endpoint secret. - // 2. Parse the event object from the payload. - // 3. Handle specific event types (e.g., 'checkout.session.completed'). - // 4. If payment successful: - // - Extract relevant data (user_id from metadata, transaction_id, credits_purchased). - // - Call \Quiztech\AssessmentPlatform\Includes\quiztech_process_successful_payment(). - // 5. Return appropriate response to Stripe (e.g., 200 OK). - - \error_log('Stripe Webhook Handler Called - Placeholder'); - - // Example event handling structure: - // $event = null; - // try { - // $event = \Stripe\Webhook::constructEvent( - // $payload, $signature, 'YOUR_STRIPE_WEBHOOK_SECRET' - // ); - // } catch(\UnexpectedValueException $e) { - // // Invalid payload - // \error_log('Stripe Webhook Error: Invalid payload.'); - // return false; - // } catch(\Stripe\Exception\SignatureVerificationException $e) { - // // Invalid signature - // \error_log('Stripe Webhook Error: Invalid signature.'); - // return false; - // } - - // // Handle the event - // switch ($event->type) { - // case 'checkout.session.completed': - // $session = $event->data->object; - // // Extract data and call quiztech_process_successful_payment... - // break; - // // ... handle other event types - // default: - // \error_log('Received unknown Stripe event type ' . $event->type); - // } - - return true; // Placeholder + // This method is deprecated in favor of verifyWebhook called from the REST handler. + // Kept for potential future use or different webhook structures. + error_log('StripeGateway::handleWebhook called - This method is deprecated.'); + return false; // Indicate not handled here } } \ No newline at end of file diff --git a/src/Includes/payments.php b/src/Includes/payments.php index 7db2561..e36ffec 100644 --- a/src/Includes/payments.php +++ b/src/Includes/payments.php @@ -25,60 +25,151 @@ if ( ! \defined( 'WPINC' ) ) { * @return mixed Gateway-specific response (e.g., redirect URL, session ID) or WP_Error on failure. */ function quiztech_initiate_credit_purchase( $user_id, $item_id, $quantity = 1 ) { - // --- Implementation Required --- - // 1. Validate input: Ensure $user_id is valid, $item_id exists, $quantity is positive integer. - // 2. Get Item Details: Fetch price and credits amount for $item_id (requires defining how items are stored - e.g., CPT, options). - // 3. Get Active Gateway: Read plugin settings (e.g., get_option('quiztech_settings')) to find the configured gateway slug. - $active_gateway = 'stripe'; // Placeholder: Replace with dynamic logic reading settings. - - if ( 'stripe' === $active_gateway ) { - $gateway = new StripeGateway(); - // 4. Pass Metadata: Gather necessary data (user_id, item_id, quantity, credits_amount, price) - // and pass it appropriately to the gateway's initiatePayment method, likely for Stripe metadata. - $result = $gateway->initiatePayment( $user_id, $item_id, $quantity /*, $metadata_array */ ); // Example modification - return $result; // Return whatever the gateway method returns (e.g., session ID, URL, WP_Error) + // 1. Validate input + if ( ! $user_id || ! \get_user_by( 'ID', $user_id ) ) { + return new \WP_Error( 'invalid_user', \__( 'Invalid user ID provided.', 'quiztech' ) ); + } + if ( empty( $item_id ) ) { + return new \WP_Error( 'invalid_item', \__( 'No credit package specified.', 'quiztech' ) ); + } + $quantity = absint( $quantity ); + if ( $quantity <= 0 ) { + return new \WP_Error( 'invalid_quantity', \__( 'Quantity must be at least 1.', 'quiztech' ) ); } - // If no active or supported gateway found - return new \WP_Error( 'no_gateway', \__( 'No active or supported payment gateway configured.', 'quiztech' ) ); + // 2. Get Item Details (Using hardcoded packages for now, mirroring the theme) + // TODO: Move credit packages to a filterable array or WP options for better management. + $credit_packages = array( + '10_credits' => array( 'amount' => 10, 'price' => '10.00', 'currency' => 'USD' ), + '50_credits' => array( 'amount' => 50, 'price' => '45.00', 'currency' => 'USD' ), + '100_credits' => array( 'amount' => 100, 'price' => '80.00', 'currency' => 'USD' ), + ); + + if ( ! isset( $credit_packages[ $item_id ] ) ) { + return new \WP_Error( 'invalid_package', \__( 'Selected credit package does not exist.', 'quiztech' ) ); + } + + $package = $credit_packages[ $item_id ]; + $credit_amount = $package['amount']; + $price_decimal = $package['price']; + $currency = strtoupper( $package['currency'] ); // Ensure currency is uppercase + + // Convert price to cents for Stripe + $price_in_cents = (int) ( (float) $price_decimal * 100 ); + + // 3. Get Active Gateway + // TODO: Read active gateway from plugin settings (e.g., get_option('quiztech_settings')['payment_gateway']) + $active_gateway = 'stripe'; // Placeholder + + if ( 'stripe' === $active_gateway ) { + // Ensure StripeGateway class exists + if ( ! class_exists( '\Quiztech\AssessmentPlatform\Gateways\StripeGateway' ) ) { + return new \WP_Error( 'gateway_error', \__( 'Stripe gateway class not found.', 'quiztech' ) ); + } + $gateway = new StripeGateway(); + + // 4. Prepare Metadata for Stripe and Webhook + $metadata = [ + 'user_id' => $user_id, + 'item_id' => $item_id, + 'credit_amount' => $credit_amount, + 'price_paid' => $price_decimal, // Store original decimal price for reference + 'currency' => $currency, + 'quantity' => $quantity, + 'transaction_type'=> 'credit_purchase', // Identify the transaction type + ]; + + // 5. Call Gateway's initiatePayment method + $result = $gateway->initiatePayment( $user_id, $item_id, $quantity, $price_in_cents, $currency, $metadata ); + + // The gateway method should handle the redirect on success or return WP_Error + return $result; + } + + // If no active or supported gateway found + return new \WP_Error( 'no_gateway', \__( 'No active or supported payment gateway configured.', 'quiztech' ) ); } /** * Handle incoming payment gateway webhooks. - * This needs to be registered as a REST endpoint or admin-ajax handler. + * This needs to be registered as a REST endpoint. + * + * @param WP_REST_Request $request The request object. + * @return WP_REST_Response The response object. */ -function quiztech_handle_payment_webhook() { - // --- Implementation Required --- - // Identify the gateway. This typically requires unique endpoints per gateway. - // Example: Register '/webhook/stripe' and '/webhook/paypal'. The endpoint itself identifies the gateway. - $gateway_slug = 'stripe'; // Placeholder: Determine dynamically based on the requested endpoint. +function quiztech_handle_payment_webhook( \WP_REST_Request $request ) { + // We only handle Stripe for now, identified by the endpoint registration. + $gateway_slug = 'stripe'; - if ( 'stripe' === $gateway_slug ) { - $payload = @file_get_contents('php://input'); - $signature = isset($_SERVER['HTTP_STRIPE_SIGNATURE']) ? $_SERVER['HTTP_STRIPE_SIGNATURE'] : ''; + $payload = $request->get_body(); + $signature = $request->get_header( 'stripe_signature' ); // Stripe sends 'Stripe-Signature' - if ( empty($payload) || empty($signature) ) { - \wp_send_json_error( 'Missing payload or signature for Stripe webhook.', 400 ); - return; // Exit - } + if ( empty( $payload ) || empty( $signature ) ) { + \error_log( 'Quiztech Stripe Webhook Error: Missing payload or signature.' ); + return new \WP_REST_Response( [ 'error' => 'Missing payload or signature.' ], 400 ); + } - $gateway = new StripeGateway(); - $handled = $gateway->handleWebhook( $payload, $signature ); + // Retrieve the webhook secret from settings + $options = get_option( 'quiztech_settings' ); + $webhook_secret = isset( $options['stripe_webhook_secret'] ) ? trim( $options['stripe_webhook_secret'] ) : ''; - if ( $handled ) { - // The handleWebhook method should call quiztech_process_successful_payment internally if needed. - \wp_send_json_success( 'Webhook processed.', 200 ); - } else { - \wp_send_json_error( 'Stripe webhook verification or processing failed.', 400 ); - } - return; // Exit - } + if ( empty( $webhook_secret ) ) { + \error_log( 'Quiztech Stripe Webhook Error: Webhook secret is not configured in settings.' ); + // Return 500 because the server is misconfigured + return new \WP_REST_Response( [ 'error' => 'Webhook processing is not configured.' ], 500 ); + } - // Handle other gateways or errors if no matching gateway found - \wp_send_json_error( 'Invalid or unsupported webhook request.', 400 ); + // Ensure StripeGateway class exists + if ( ! class_exists( '\Quiztech\AssessmentPlatform\Gateways\StripeGateway' ) ) { + \error_log( 'Quiztech Stripe Webhook Error: StripeGateway class not found.' ); + return new \WP_REST_Response( [ 'error' => 'Internal server error.' ], 500 ); + } + + $gateway = new StripeGateway(); + $event = $gateway->verifyWebhook( $payload, $signature, $webhook_secret ); + + if ( is_wp_error( $event ) ) { + \error_log( 'Quiztech Stripe Webhook Error: Verification failed - ' . $event->get_error_message() ); + return new \WP_REST_Response( [ 'error' => $event->get_error_message() ], 400 ); + } + + // Handle the event type + if ( 'checkout.session.completed' === $event->type ) { + $session = $event->data->object; + + // Check payment status + if ( $session->payment_status === 'paid' ) { + // Extract metadata + $metadata = $session->metadata; + $user_id = isset( $metadata->user_id ) ? absint( $metadata->user_id ) : 0; + $credits_purchased = isset( $metadata->credit_amount ) ? absint( $metadata->credit_amount ) : 0; + $transaction_id = $session->payment_intent; // Use payment intent ID as unique transaction ID + + if ( $user_id && $credits_purchased && $transaction_id ) { + $processed = quiztech_process_successful_payment( $user_id, $credits_purchased, $transaction_id, $gateway_slug ); + if ( $processed ) { + return new \WP_REST_Response( [ 'status' => 'success' ], 200 ); + } else { + // Error logged within quiztech_process_successful_payment + return new \WP_REST_Response( [ 'error' => 'Failed to process payment internally.' ], 500 ); + } + } else { + \error_log( 'Quiztech Stripe Webhook Error: Missing required metadata (user_id, credit_amount) or transaction ID in checkout.session.completed event.' ); + return new \WP_REST_Response( [ 'error' => 'Missing required data in webhook.' ], 400 ); + } + } else { + // Log other payment statuses if needed (e.g., 'unpaid') + \error_log( 'Quiztech Stripe Webhook Info: Received checkout.session.completed event with status: ' . $session->payment_status ); + return new \WP_REST_Response( [ 'status' => 'ignored_payment_status' ], 200 ); + } + } else { + // Optionally handle other event types like 'payment_intent.succeeded' if needed + \error_log( 'Quiztech Stripe Webhook Info: Received unhandled event type: ' . $event->type ); + return new \WP_REST_Response( [ 'status' => 'unhandled_event_type' ], 200 ); + } } -// --- Implementation Required --- +// --- Implementation Required --- (Removed as REST registration is now in main plugin file) // Register the webhook handler(s) using WordPress REST API (recommended for webhooks). // Example (needs proper implementation, likely in a dedicated class or main plugin file): // add_action( 'rest_api_init', function () { @@ -100,35 +191,57 @@ function quiztech_handle_payment_webhook() { * @param string $gateway_slug Slug of the gateway (e.g., 'stripe'). * @return bool True on success, false on failure. */ -function quiztech_process_successful_payment( $user_id, $credits_purchased, $transaction_id, $gateway_slug ) { - if ( ! $user_id || ! $credits_purchased > 0 || empty( $transaction_id ) || empty( $gateway_slug ) ) { - // Log error: Invalid data received for payment processing. - return false; - } - - // --- Implementation Required --- - // Add checks to prevent processing the same transaction ID multiple times. - // This typically involves: - // 1. Storing processed transaction IDs (e.g., in post meta on the user_evaluation, a dedicated log table, or user meta). - // 2. Querying this storage before updating the balance. If the ID exists, return true (or log) without updating again. - // 3. Saving the transaction ID after successfully updating the balance. - - // Update the user's credit balance - $result = quiztech_update_user_credit_balance( - $user_id, - (int) $credits_purchased, - \sprintf( '%s_purchase_%s', $gateway_slug, $transaction_id ) - ); - - if ( false !== $result ) { - // Optional: Log successful transaction details somewhere (custom table?) - // Optional: Send purchase confirmation email to user - // do_action('quiztech_credits_purchased', $user_id, $credits_purchased, $transaction_id, $gateway_slug); - return true; - } else { - // Log error: Failed to update credit balance for user $user_id after successful payment $transaction_id. - return false; - } -} - -?> \ No newline at end of file + function quiztech_process_successful_payment( $user_id, $credits_purchased, $transaction_id, $gateway_slug ) { + // 1. Validate Input + if ( ! $user_id || ! is_numeric($user_id) || $user_id <= 0 ) { + error_log("Quiztech Error: Invalid user ID ({$user_id}) in quiztech_process_successful_payment."); + return false; + } + if ( ! $credits_purchased || ! is_numeric($credits_purchased) || $credits_purchased <= 0 ) { + error_log("Quiztech Error: Invalid credits purchased amount ({$credits_purchased}) for user {$user_id} in quiztech_process_successful_payment."); + return false; + } + if ( empty( $transaction_id ) ) { + error_log("Quiztech Error: Empty transaction ID for user {$user_id} in quiztech_process_successful_payment."); + return false; + } + if ( empty( $gateway_slug ) ) { + error_log("Quiztech Error: Empty gateway slug for user {$user_id}, transaction {$transaction_id} in quiztech_process_successful_payment."); + return false; + } + + // 2. Check for Duplicate Processing (using transients, valid for 1 hour) + // A more robust solution might involve a dedicated log table or checking payment intent status directly. + $transient_key = 'quiztech_txn_' . md5( $gateway_slug . '_' . $transaction_id ); + if ( get_transient( $transient_key ) ) { + // Already processed recently + error_log( "Quiztech Info: Attempted to re-process transaction ID {$transaction_id} for user {$user_id}. Already handled." ); + return true; // Return true to indicate it's "handled" (already done) + } + + // 3. Update the user's credit balance + $result = quiztech_update_user_credit_balance( + $user_id, + (int) $credits_purchased, + sprintf( '%s_purchase_%s', $gateway_slug, $transaction_id ) // Use global namespace sprintf + ); + + if ( false !== $result ) { + // 4. Set transient to prevent reprocessing on success + set_transient( $transient_key, true, HOUR_IN_SECONDS ); // Store for 1 hour + + // Optional: Log successful transaction details somewhere (custom table?) + error_log("Quiztech Info: Successfully processed payment {$transaction_id} for user {$user_id}. Credits added: {$credits_purchased}. New balance: {$result}."); + + // Optional: Send purchase confirmation email to user + // do_action('quiztech_credits_purchased', $user_id, $credits_purchased, $transaction_id, $gateway_slug); + + return true; + } else { + // Log error: Failed to update credit balance for user $user_id after successful payment $transaction_id. + error_log("Quiztech Error: Failed to update credit balance for user {$user_id} after successful payment {$transaction_id}."); + return false; + } + } + + ?> \ No newline at end of file