true, 'header_text' => __('PayPal', 'easy-invoice'), 'header_class' => 'mt-6 pt-6 border-t border-gray-200', 'fields' => [ 'easy_invoice_paypal_email' => [ 'label' => __('PayPal Email', 'easy-invoice'), 'type' => 'email', 'default' => '', 'col_span' => 'sm:col-span-6 md:col-span-3', 'description' => __('Enter your PayPal email address to receive payments.', 'easy-invoice') ], 'easy_invoice_paypal_mode' => [ 'label' => __('PayPal Mode', 'easy-invoice'), 'type' => 'select', 'default' => 'live', 'options' => ['live' => 'Live', 'sandbox' => 'Sandbox'], 'col_span' => 'sm:col-span-6 md:col-span-3', 'description' => __('Select Live for real transactions or Sandbox for testing.', 'easy-invoice') ], ], ]; } /** * Get gateway description * * @return string */ public function getDescription(): string { return 'Pay securely using your PayPal account'; } /** * Maybe render instructions for PayPal * * @param mixed $invoice * @param string $selected_gateway */ public function maybeRenderInstructions($invoice, $selected_gateway = '') { // Only render if this is the selected gateway if ($selected_gateway !== 'paypal') { return; } // For PayPal, we can show additional information or payment buttons // For now, we'll return empty to keep it simple // In the future, this could show PayPal Smart Buttons or other PayPal-specific content return; } /** * Check if payment gateway is available for the current invoice * * @return bool */ public function isAvailable(): bool { // Check if PayPal is generally enabled in plugin settings if (!parent::isEnabled()) { // Call the corrected parent::isEnabled() return false; } // Check if PayPal email is configured (settings are loaded in AbstractPaymentGateway::init()) if (empty($this->settings['email'])) { return false; } return true; } /** * Process payment * * @param float $amount * @param array $data * @return array */ public function processPayment(float $amount, array $data = []): array { $this->log('Processing PayPal payment for amount: ' . $amount . ' in mode: ' . ($this->settings['mode'] ?? 'live')); $this->log('PayPal payment data: ' . json_encode($data)); $paypal_email = $this->settings['email'] ?? ''; if (empty($paypal_email)) { return [ 'success' => false, 'message' => 'PayPal email not configured' ]; } // Trim whitespace from email $paypal_email = trim($paypal_email); // Validate PayPal email format if (!filter_var($paypal_email, FILTER_VALIDATE_EMAIL)) { return [ 'success' => false, 'message' => 'Invalid PayPal email format' ]; } // For sandbox mode, log the email being used if (isset($this->settings['mode']) && $this->settings['mode'] === 'sandbox') { $this->log('Using PayPal email in sandbox mode: ' . $paypal_email); } $paypal_url_base = 'https://www.paypal.com/cgi-bin/webscr'; if (isset($this->settings['mode']) && $this->settings['mode'] === 'sandbox') { $paypal_url_base = 'https://www.sandbox.paypal.com/cgi-bin/webscr'; } $this->log('PayPal mode: ' . ($this->settings['mode'] ?? 'live')); $this->log('PayPal URL base: ' . $paypal_url_base); $this->log('PayPal email: ' . $paypal_email); // Validate required data if (empty($data['invoice_id'])) { return [ 'success' => false, 'message' => 'Invoice ID is required' ]; } // Get invoice details for better PayPal integration $invoice_post = get_post($data['invoice_id']); if (!$invoice_post) { return [ 'success' => false, 'message' => 'Invoice not found' ]; } $invoice = new \EasyInvoice\Models\Invoice($invoice_post); // Get invoice number $invoice_number = $invoice->number ?? ''; if (empty($invoice_number)) { $invoice_number = get_post_meta($data['invoice_id'], '_easy_invoice_number', true); } if (empty($invoice_number)) { $invoice_number = 'INV-' . $data['invoice_id']; } // Get currency from invoice or global settings $invoice_currency = $invoice->getCurrencyCode(); if ($invoice_currency === 'global' || empty($invoice_currency)) { // Use global settings $currency = get_option('easy_invoice_currency_code', 'USD'); $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency); } else { // Use invoice-specific currency $currency = $invoice_currency; $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency); } // Ensure currency is a valid PayPal currency $valid_currencies = ['USD', 'EUR', 'GBP', 'CAD', 'AUD', 'JPY', 'NZD', 'CHF', 'HKD', 'SGD', 'SEK', 'DKK', 'PLN', 'NOK', 'HUF', 'CZK', 'ILS', 'MXN', 'BRL', 'MYR', 'PHP', 'TWD', 'THB', 'TRY']; if (!in_array($currency, $valid_currencies)) { $this->log('Invalid currency code: ' . $currency . ', defaulting to USD', 'warning'); $currency = 'USD'; $currency_symbol = '$'; } // Validate amount if ($amount <= 0) { $this->log('Invalid amount: ' . $amount, 'error'); return [ 'success' => false, 'message' => 'Invalid payment amount' ]; } // Ensure amount is numeric and positive $amount = floatval($amount); if ($amount <= 0) { $this->log('Amount is not positive after conversion: ' . $amount, 'error'); return [ 'success' => false, 'message' => 'Invalid payment amount' ]; } // Check if amount is within PayPal's acceptable range (0.01 to 999999.99) if ($amount < 0.01 || $amount > 999999.99) { $this->log('Amount outside PayPal range: ' . $amount, 'error'); return [ 'success' => false, 'message' => 'Payment amount must be between $0.01 and $999,999.99' ]; } // Ensure amount is properly formatted for PayPal $formatted_amount = sprintf('%.2f', $amount); $this->log('Original amount: ' . $amount); $this->log('Formatted amount: ' . $formatted_amount); $this->log('Invoice number: ' . $invoice_number); $this->log('Invoice total: ' . ($invoice->total ?? 0)); $this->log('Original currency: ' . ($data['currency'] ?? $invoice->getCurrencyCode() ?? 'USD')); $this->log('Final currency: ' . $currency); $this->log('Amount parameter: ' . $amount); $this->log('Formatted amount: ' . sprintf('%.2f', $amount)); $this->log('Amount type: ' . gettype($amount)); $this->log('Amount > 0: ' . ($amount > 0 ? 'true' : 'false')); $this->log('Amount is numeric: ' . (is_numeric($amount) ? 'true' : 'false')); // Get customer information $customer_id = $data['customer_id'] ?? $invoice->client_id ?? 0; $customer_name = ''; if ($customer_id) { $customer = get_post($customer_id); if ($customer) { $customer_name = $customer->post_title; } } // Create item description $item_description = !empty($data['description']) ? $data['description'] : sprintf( 'Invoice #%s', $invoice_number ); // Ensure item description is not empty and properly formatted $item_description = trim($item_description); if (empty($item_description)) { $item_description = 'Invoice Payment'; } // Limit description length for PayPal if (strlen($item_description) > 127) { $item_description = substr($item_description, 0, 124) . '...'; } // Ensure no trailing spaces $item_description = rtrim($item_description); $this->log('Item description: ' . $item_description); // Create return URLs $return_url = $data['return_url'] ?? home_url('/payment-success'); $cancel_url = $data['cancel_url'] ?? home_url('/payment-cancelled'); $notify_url = $data['notify_url'] ?? admin_url('admin-ajax.php?action=easy_invoice_payment_callback&gateway=paypal&invoice_id=' . $data['invoice_id']); // Create PayPal payment URL with enhanced parameters $paypal_params = [ 'cmd' => '_xclick', 'business' => $paypal_email, 'item_name' => $item_description, 'amount' => $formatted_amount, 'currency_code' => $currency, 'return' => $return_url, 'cancel_return' => $cancel_url ]; // Debug: Check if amount is actually in the parameters $this->log('Amount in parameters: ' . ($paypal_params['amount'] ?? 'NOT SET')); $this->log('Amount parameter type: ' . gettype($paypal_params['amount'])); $this->log('Amount parameter value: "' . $paypal_params['amount'] . '"'); $this->log('PayPal parameters before URL creation: ' . json_encode($paypal_params)); $this->log('PayPal business email: ' . $paypal_email); $this->log('PayPal item name: ' . $item_description); $this->log('PayPal item name length: ' . strlen($item_description)); $this->log('PayPal amount: ' . sprintf('%.2f', $amount)); $this->log('PayPal amount type: ' . gettype(sprintf('%.2f', $amount))); // Build URL manually to ensure proper encoding $query_string = http_build_query($paypal_params); $payment_url = $paypal_url_base . '?' . $query_string; // Test URL length $this->log('PayPal URL length: ' . strlen($payment_url)); if (strlen($payment_url) > 2048) { $this->log('WARNING: PayPal URL is very long: ' . strlen($payment_url) . ' characters', 'warning'); } $this->log('PayPal redirect URL created: ' . $payment_url); $this->log('PayPal payment parameters: ' . json_encode([ 'amount' => sprintf('%.2f', $amount), 'currency' => $currency, 'invoice_number' => $invoice_number, 'item_description' => $item_description, 'return_url' => $return_url, 'cancel_url' => $cancel_url ])); // Create initial payment record try { $payment_data = [ 'invoice_id' => $data['invoice_id'], 'amount' => $amount, 'payment_method' => 'paypal', 'payment_date' => current_time('mysql'), 'notes' => 'Payment initiated via PayPal', 'status' => 'pending', 'payment_type' => 'gateway', 'transaction_id' => 'PAYPAL-' . time() . '-' . $data['invoice_id'], 'currency' => $currency, 'currency_symbol' => $currency_symbol, 'gateway_response' => json_encode([ 'gateway' => 'paypal', 'mode' => $this->settings['mode'] ?? 'live', 'paypal_email' => $paypal_email, 'item_description' => $item_description, 'redirect_url' => $payment_url ]) ]; // Create payment record using WordPress post creation $payment_post_data = [ 'post_title' => sprintf('PayPal Payment for Invoice #%s', $invoice_number), 'post_type' => 'easy_invoice_payment', 'post_status' => 'publish', 'post_author' => 1, 'meta_input' => [ '_invoice_id' => $data['invoice_id'], '_amount' => $amount, '_payment_method' => 'paypal', '_status' => 'pending', '_transaction_id' => 'PAYPAL-' . time() . '-' . $data['invoice_id'], '_payment_date' => current_time('mysql'), '_notes' => 'Payment initiated via PayPal', '_payment_type' => 'gateway', '_currency' => $currency, '_currency_symbol' => $currency_symbol, '_gateway_response' => json_encode([ 'gateway' => 'paypal', 'mode' => $this->settings['mode'] ?? 'live', 'paypal_email' => $paypal_email, 'item_description' => $item_description, 'redirect_url' => $payment_url ]) ] ]; $payment_post_id = wp_insert_post($payment_post_data); if ($payment_post_id && !is_wp_error($payment_post_id)) { $this->log('Initial payment record created with ID: ' . $payment_post_id); } else { $this->log('Failed to create payment record: ' . (is_wp_error($payment_post_id) ? $payment_post_id->get_error_message() : 'Unknown error'), 'error'); } } catch (\Exception $e) { $this->log('Error creating initial payment record: ' . $e->getMessage(), 'error'); } $this->log('PayPal payment URL created successfully: ' . $payment_url); return [ 'success' => true, 'redirect_url' => $payment_url, 'message' => 'Redirecting to PayPal to complete your payment...' ]; } /** * Handle payment callback * * @param array $data * @return array */ public function handleCallback(array $data): array { $this->log('Received PayPal callback/IPN'); $raw_post_data = file_get_contents('php://input'); $this->log('Raw IPN data: ' . $raw_post_data); // Use the raw POST data for verification, not the $data array which might be processed already if (empty($raw_post_data)) { $this->log('No raw POST data received for IPN.', 'error'); return [ 'success' => false, 'message' => 'Invalid callback data (no raw post)' ]; } // Determine PayPal verification URL based on mode $paypal_verify_url = 'https://ipnpb.paypal.com/cgi-bin/webscr'; // Live IPN verification URL if (isset($this->settings['mode']) && $this->settings['mode'] === 'sandbox') { $paypal_verify_url = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'; // Sandbox IPN verification URL $this->log('Using Sandbox PayPal IPN verification URL.'); } else { $this->log('Using Live PayPal IPN verification URL.'); } // Prepare data for verification by prepending cmd=_notify-validate $params = 'cmd=_notify-validate&' . $raw_post_data; $response = wp_remote_post($paypal_verify_url, [ 'method' => 'POST', 'timeout' => 45, 'sslverify' => true, // Should be true in production 'body' => $params, 'headers' => [ 'Connection' => 'Close' ] ]); if (is_wp_error($response)) { $this->log('IPN verification request failed: ' . $response->get_error_message(), 'error'); return [ 'success' => false, 'message' => 'IPN verification request failed: ' . $response->get_error_message() ]; } $verification_result = wp_remote_retrieve_body($response); $this->log('IPN Verification response: ' . $verification_result); if (strcmp(trim($verification_result), 'VERIFIED') !== 0) { $this->log('IPN verification failed. Result: ' . $verification_result, 'error'); return [ 'success' => false, 'message' => 'IPN verification failed' ]; } $this->log('IPN VERIFIED.'); // IPN is verified, now parse the raw post data into an array // The $data array passed to this function might already be this, // but it's safer to parse from raw_post_data after verification. parse_str($raw_post_data, $payload); if (empty($payload)) { $this->log('Failed to parse IPN payload after verification.', 'error'); return [ 'success' => false, 'message' => 'Failed to parse IPN payload' ]; } // Check #1: Receiver Email (ensure payment went to your account) $configured_paypal_email = $this->settings['email'] ?? ''; $ipn_receiver_email = $payload['receiver_email'] ?? $payload['business'] ?? ''; if (empty($configured_paypal_email) || strtolower(trim($ipn_receiver_email)) !== strtolower(trim($configured_paypal_email))) { $this->log('IPN Receiver Email mismatch. Configured: ' . $configured_paypal_email . ' | Received: ' . $ipn_receiver_email, 'error'); return [ 'success' => false, 'message' => 'IPN Receiver Email mismatch' ]; } $this->log('Receiver email verified: ' . $ipn_receiver_email); // Check #2: Payment status (already in place, but now more reliable after verification) if (($payload['payment_status'] ?? '') !== 'Completed') { $this->log('Payment status not Completed. Status: ' . ($payload['payment_status'] ?? 'N/A'), 'warning'); return [ 'success' => false, 'message' => 'Payment not completed. Status: ' . ($payload['payment_status'] ?? 'N/A') ]; } $this->log('Payment status VERIFIED as Completed.'); // (Optional but recommended: check txn_id for duplicates, mc_gross and mc_currency against invoice) // Get invoice ID from custom data $custom_data = json_decode($payload['custom'] ?? '{}', true); $invoice_id = $custom_data['invoice_id'] ?? 0; if (!$invoice_id) { return [ 'success' => false, 'message' => 'Invalid invoice ID' ]; } // Update invoice status $invoice_post = get_post($invoice_id); // Fetch WP_Post object first if (!$invoice_post) { return [ 'success' => false, 'message' => 'Invoice not found' ]; } $invoice = new \EasyInvoice\Models\Invoice($invoice_post); // Pass WP_Post to constructor // Store payment details as meta $paypal_payment_date = $payload['payment_date'] ?? current_time('mysql'); $paypal_txn_id = $payload['txn_id'] ?? ''; $payment_amount = floatval($payload['mc_gross'] ?? 0); $invoice->setMeta('_easy_invoice_payment_method', 'paypal'); $invoice->setMeta('_easy_invoice_payment_date', $paypal_payment_date); $invoice->setMeta('_easy_invoice_transaction_id', $paypal_txn_id); // Update the overall payment status (as seen in PaymentController logic) $invoice->setMeta('_payment_status', 'completed'); // Update invoice status to paid only if total payments are sufficient // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'paypal'); // Update the WordPress post status to publish (if not already, or if 'paid' isn't a WP status) $current_wp_status = $invoice->getPost()->post_status; if ($current_wp_status !== 'publish' && $current_wp_status !== 'paid') { // Assuming 'paid' could be a custom registered status wp_update_post(['ID' => $invoice_id, 'post_status' => 'publish']); } // Store payment details for the hook $invoice->setMeta('_payment_method', 'paypal'); $invoice->setMeta('_transaction_id', $paypal_txn_id); return [ 'success' => true, 'message' => 'Payment processed successfully' ]; } }