PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.2
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.2
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Gateways / PayPalGateway.php

PayPalGateway.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.3.2, at includes/Gateways/PayPalGateway.php

561 lines 22.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace EasyInvoice\Gateways;
3
4 use EasyInvoice\Abstracts\AbstractPaymentGateway;
5 use EasyInvoice\Models\Payment;
6 use EasyInvoice\Traits\PaymentCalculationTrait;
7
8 class PayPalGateway extends AbstractPaymentGateway {
9 use PaymentCalculationTrait;
10 /**
11 * Constructor
12 */
13 public function __construct() {
14 parent::__construct();
15
16 // Hook to render instructions when needed
17 add_action('easy_invoice_payment_gateways_after', [$this, 'maybeRenderInstructions'], 10, 2);
18 }
19
20 /**
21 * Get gateway name
22 *
23 * @return string
24 */
25 public function getName(): string {
26 return 'paypal';
27 }
28
29 /**
30 * Get gateway title
31 *
32 * @return string
33 */
34 public function getTitle(): string {
35 return 'PayPal';
36 }
37
38 /**
39 * Get gateway icon
40 *
41 * @return string
42 */
43 public function getIcon(): string {
44 return EASY_INVOICE_ASSETS_URL . 'images/paypal-icon.svg';
45 }
46
47 /**
48 * Get gateway settings configuration for the settings page
49 *
50 * @return array The settings configuration array
51 */
52 public function getSettingsConfig(): array {
53 return [
54 'show_header' => true,
55 'header_text' => __('PayPal', 'easy-invoice'),
56 'header_class' => 'mt-6 pt-6 border-t border-gray-200',
57 'fields' => [
58 'easy_invoice_paypal_email' => [
59 'label' => __('PayPal Email', 'easy-invoice'),
60 'type' => 'email',
61 'default' => '',
62 'col_span' => 'sm:col-span-6 md:col-span-3',
63 'description' => __('Enter your PayPal email address to receive payments.', 'easy-invoice')
64 ],
65 'easy_invoice_paypal_mode' => [
66 'label' => __('PayPal Mode', 'easy-invoice'),
67 'type' => 'select',
68 'default' => 'live',
69 'options' => ['live' => 'Live', 'sandbox' => 'Sandbox'],
70 'col_span' => 'sm:col-span-6 md:col-span-3',
71 'description' => __('Select Live for real transactions or Sandbox for testing.', 'easy-invoice')
72 ],
73 ],
74 ];
75 }
76
77 /**
78 * Get gateway description
79 *
80 * @return string
81 */
82 public function getDescription(): string {
83 return 'Pay securely using your PayPal account';
84 }
85
86 /**
87 * Maybe render instructions for PayPal
88 *
89 * @param mixed $invoice
90 * @param string $selected_gateway
91 */
92 public function maybeRenderInstructions($invoice, $selected_gateway = '') {
93 // Only render if this is the selected gateway
94 if ($selected_gateway !== 'paypal') {
95 return;
96 }
97
98 // For PayPal, we can show additional information or payment buttons
99 // For now, we'll return empty to keep it simple
100 // In the future, this could show PayPal Smart Buttons or other PayPal-specific content
101 return;
102 }
103
104 /**
105 * Check if payment gateway is available for the current invoice
106 *
107 * @return bool
108 */
109 public function isAvailable(): bool {
110 // Check if PayPal is generally enabled in plugin settings
111 if (!parent::isEnabled()) { // Call the corrected parent::isEnabled()
112 return false;
113 }
114
115 // Check if PayPal email is configured (settings are loaded in AbstractPaymentGateway::init())
116 if (empty($this->settings['email'])) {
117 return false;
118 }
119
120 return true;
121 }
122
123 /**
124 * Process payment
125 *
126 * @param float $amount
127 * @param array $data
128 * @return array
129 */
130 public function processPayment(float $amount, array $data = []): array {
131 $this->log('Processing PayPal payment for amount: ' . $amount . ' in mode: ' . ($this->settings['mode'] ?? 'live'));
132 $this->log('PayPal payment data: ' . json_encode($data));
133
134 $paypal_email = $this->settings['email'] ?? '';
135 if (empty($paypal_email)) {
136 return [
137 'success' => false,
138 'message' => 'PayPal email not configured'
139 ];
140 }
141
142 // Trim whitespace from email
143 $paypal_email = trim($paypal_email);
144
145 // Validate PayPal email format
146 if (!filter_var($paypal_email, FILTER_VALIDATE_EMAIL)) {
147 return [
148 'success' => false,
149 'message' => 'Invalid PayPal email format'
150 ];
151 }
152
153
154
155 // For sandbox mode, log the email being used
156 if (isset($this->settings['mode']) && $this->settings['mode'] === 'sandbox') {
157 $this->log('Using PayPal email in sandbox mode: ' . $paypal_email);
158 }
159
160 $paypal_url_base = 'https://www.paypal.com/cgi-bin/webscr';
161 if (isset($this->settings['mode']) && $this->settings['mode'] === 'sandbox') {
162 $paypal_url_base = 'https://www.sandbox.paypal.com/cgi-bin/webscr';
163 }
164
165 $this->log('PayPal mode: ' . ($this->settings['mode'] ?? 'live'));
166 $this->log('PayPal URL base: ' . $paypal_url_base);
167 $this->log('PayPal email: ' . $paypal_email);
168
169 // Validate required data
170 if (empty($data['invoice_id'])) {
171 return [
172 'success' => false,
173 'message' => 'Invoice ID is required'
174 ];
175 }
176
177 // Get invoice details for better PayPal integration
178 $invoice_post = get_post($data['invoice_id']);
179 if (!$invoice_post) {
180 return [
181 'success' => false,
182 'message' => 'Invoice not found'
183 ];
184 }
185
186 $invoice = new \EasyInvoice\Models\Invoice($invoice_post);
187
188 // Get invoice number
189 $invoice_number = $invoice->number ?? '';
190 if (empty($invoice_number)) {
191 $invoice_number = get_post_meta($data['invoice_id'], '_easy_invoice_number', true);
192 }
193 if (empty($invoice_number)) {
194 $invoice_number = 'INV-' . $data['invoice_id'];
195 }
196
197 // Get currency from invoice or global settings
198 $invoice_currency = $invoice->getCurrencyCode();
199 if ($invoice_currency === 'global' || empty($invoice_currency)) {
200 // Use global settings
201 $currency = get_option('easy_invoice_currency_code', 'USD');
202 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency);
203 } else {
204 // Use invoice-specific currency
205 $currency = $invoice_currency;
206 $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency);
207 }
208
209 // Ensure currency is a valid PayPal currency
210 $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'];
211 if (!in_array($currency, $valid_currencies)) {
212 $this->log('Invalid currency code: ' . $currency . ', defaulting to USD', 'warning');
213 $currency = 'USD';
214 $currency_symbol = '$';
215 }
216
217 // Validate amount
218 if ($amount <= 0) {
219 $this->log('Invalid amount: ' . $amount, 'error');
220 return [
221 'success' => false,
222 'message' => 'Invalid payment amount'
223 ];
224 }
225
226 // Ensure amount is numeric and positive
227 $amount = floatval($amount);
228 if ($amount <= 0) {
229 $this->log('Amount is not positive after conversion: ' . $amount, 'error');
230 return [
231 'success' => false,
232 'message' => 'Invalid payment amount'
233 ];
234 }
235
236 // Check if amount is within PayPal's acceptable range (0.01 to 999999.99)
237 if ($amount < 0.01 || $amount > 999999.99) {
238 $this->log('Amount outside PayPal range: ' . $amount, 'error');
239 return [
240 'success' => false,
241 'message' => 'Payment amount must be between $0.01 and $999,999.99'
242 ];
243 }
244
245 // Ensure amount is properly formatted for PayPal
246 $formatted_amount = sprintf('%.2f', $amount);
247 $this->log('Original amount: ' . $amount);
248 $this->log('Formatted amount: ' . $formatted_amount);
249
250 $this->log('Invoice number: ' . $invoice_number);
251 $this->log('Invoice total: ' . ($invoice->total ?? 0));
252 $this->log('Original currency: ' . ($data['currency'] ?? $invoice->getCurrencyCode() ?? 'USD'));
253 $this->log('Final currency: ' . $currency);
254 $this->log('Amount parameter: ' . $amount);
255 $this->log('Formatted amount: ' . sprintf('%.2f', $amount));
256 $this->log('Amount type: ' . gettype($amount));
257 $this->log('Amount > 0: ' . ($amount > 0 ? 'true' : 'false'));
258 $this->log('Amount is numeric: ' . (is_numeric($amount) ? 'true' : 'false'));
259
260 // Get customer information
261 $customer_id = $data['customer_id'] ?? $invoice->client_id ?? 0;
262 $customer_name = '';
263 if ($customer_id) {
264 $customer = get_post($customer_id);
265 if ($customer) {
266 $customer_name = $customer->post_title;
267 }
268 }
269
270 // Create item description
271 $item_description = !empty($data['description']) ? $data['description'] : sprintf(
272 'Invoice #%s',
273 $invoice_number
274 );
275
276 // Ensure item description is not empty and properly formatted
277 $item_description = trim($item_description);
278 if (empty($item_description)) {
279 $item_description = 'Invoice Payment';
280 }
281
282 // Limit description length for PayPal
283 if (strlen($item_description) > 127) {
284 $item_description = substr($item_description, 0, 124) . '...';
285 }
286
287 // Ensure no trailing spaces
288 $item_description = rtrim($item_description);
289
290 $this->log('Item description: ' . $item_description);
291
292 // Create return URLs
293 $return_url = $data['return_url'] ?? home_url('/payment-success');
294 $cancel_url = $data['cancel_url'] ?? home_url('/payment-cancelled');
295 $notify_url = $data['notify_url'] ?? admin_url('admin-ajax.php?action=easy_invoice_payment_callback&gateway=paypal&invoice_id=' . $data['invoice_id']);
296
297 // Create PayPal payment URL with enhanced parameters
298 $paypal_params = [
299 'cmd' => '_xclick',
300 'business' => $paypal_email,
301 'item_name' => $item_description,
302 'amount' => $formatted_amount,
303 'currency_code' => $currency,
304 'return' => $return_url,
305 'cancel_return' => $cancel_url
306 ];
307
308 // Debug: Check if amount is actually in the parameters
309 $this->log('Amount in parameters: ' . ($paypal_params['amount'] ?? 'NOT SET'));
310 $this->log('Amount parameter type: ' . gettype($paypal_params['amount']));
311 $this->log('Amount parameter value: "' . $paypal_params['amount'] . '"');
312
313 $this->log('PayPal parameters before URL creation: ' . json_encode($paypal_params));
314 $this->log('PayPal business email: ' . $paypal_email);
315 $this->log('PayPal item name: ' . $item_description);
316 $this->log('PayPal item name length: ' . strlen($item_description));
317 $this->log('PayPal amount: ' . sprintf('%.2f', $amount));
318 $this->log('PayPal amount type: ' . gettype(sprintf('%.2f', $amount)));
319
320 // Build URL manually to ensure proper encoding
321 $query_string = http_build_query($paypal_params);
322 $payment_url = $paypal_url_base . '?' . $query_string;
323
324 // Test URL length
325 $this->log('PayPal URL length: ' . strlen($payment_url));
326 if (strlen($payment_url) > 2048) {
327 $this->log('WARNING: PayPal URL is very long: ' . strlen($payment_url) . ' characters', 'warning');
328 }
329
330 $this->log('PayPal redirect URL created: ' . $payment_url);
331 $this->log('PayPal payment parameters: ' . json_encode([
332 'amount' => sprintf('%.2f', $amount),
333 'currency' => $currency,
334 'invoice_number' => $invoice_number,
335 'item_description' => $item_description,
336 'return_url' => $return_url,
337 'cancel_url' => $cancel_url
338 ]));
339
340 // Create initial payment record
341 try {
342 $payment_data = [
343 'invoice_id' => $data['invoice_id'],
344 'amount' => $amount,
345 'payment_method' => 'paypal',
346 'payment_date' => current_time('mysql'),
347 'notes' => 'Payment initiated via PayPal',
348 'status' => 'pending',
349 'payment_type' => 'gateway',
350 'transaction_id' => 'PAYPAL-' . time() . '-' . $data['invoice_id'],
351 'currency' => $currency,
352 'currency_symbol' => $currency_symbol,
353 'gateway_response' => json_encode([
354 'gateway' => 'paypal',
355 'mode' => $this->settings['mode'] ?? 'live',
356 'paypal_email' => $paypal_email,
357 'item_description' => $item_description,
358 'redirect_url' => $payment_url
359 ])
360 ];
361
362 // Create payment record using WordPress post creation
363 $payment_post_data = [
364 'post_title' => sprintf('PayPal Payment for Invoice #%s', $invoice_number),
365 'post_type' => 'easy_invoice_payment',
366 'post_status' => 'publish',
367 'post_author' => 1,
368 'meta_input' => [
369 '_invoice_id' => $data['invoice_id'],
370 '_amount' => $amount,
371 '_payment_method' => 'paypal',
372 '_status' => 'pending',
373 '_transaction_id' => 'PAYPAL-' . time() . '-' . $data['invoice_id'],
374 '_payment_date' => current_time('mysql'),
375 '_notes' => 'Payment initiated via PayPal',
376 '_payment_type' => 'gateway',
377 '_currency' => $currency,
378 '_currency_symbol' => $currency_symbol,
379 '_gateway_response' => json_encode([
380 'gateway' => 'paypal',
381 'mode' => $this->settings['mode'] ?? 'live',
382 'paypal_email' => $paypal_email,
383 'item_description' => $item_description,
384 'redirect_url' => $payment_url
385 ])
386 ]
387 ];
388
389 $payment_post_id = wp_insert_post($payment_post_data);
390 if ($payment_post_id && !is_wp_error($payment_post_id)) {
391 $this->log('Initial payment record created with ID: ' . $payment_post_id);
392 } else {
393 $this->log('Failed to create payment record: ' . (is_wp_error($payment_post_id) ? $payment_post_id->get_error_message() : 'Unknown error'), 'error');
394 }
395 } catch (\Exception $e) {
396 $this->log('Error creating initial payment record: ' . $e->getMessage(), 'error');
397 }
398
399 $this->log('PayPal payment URL created successfully: ' . $payment_url);
400
401 return [
402 'success' => true,
403 'redirect_url' => $payment_url,
404 'message' => 'Redirecting to PayPal to complete your payment...'
405 ];
406 }
407
408 /**
409 * Handle payment callback
410 *
411 * @param array $data
412 * @return array
413 */
414 public function handleCallback(array $data): array {
415 $this->log('Received PayPal callback/IPN');
416
417 $raw_post_data = file_get_contents('php://input');
418 $this->log('Raw IPN data: ' . $raw_post_data);
419
420 // Use the raw POST data for verification, not the $data array which might be processed already
421 if (empty($raw_post_data)) {
422 $this->log('No raw POST data received for IPN.', 'error');
423 return [
424 'success' => false,
425 'message' => 'Invalid callback data (no raw post)'
426 ];
427 }
428
429 // Determine PayPal verification URL based on mode
430 $paypal_verify_url = 'https://ipnpb.paypal.com/cgi-bin/webscr'; // Live IPN verification URL
431 if (isset($this->settings['mode']) && $this->settings['mode'] === 'sandbox') {
432 $paypal_verify_url = 'https://ipnpb.sandbox.paypal.com/cgi-bin/webscr'; // Sandbox IPN verification URL
433 $this->log('Using Sandbox PayPal IPN verification URL.');
434 } else {
435 $this->log('Using Live PayPal IPN verification URL.');
436 }
437
438 // Prepare data for verification by prepending cmd=_notify-validate
439 $params = 'cmd=_notify-validate&' . $raw_post_data;
440
441 $response = wp_remote_post($paypal_verify_url, [
442 'method' => 'POST',
443 'timeout' => 45,
444 'sslverify' => true, // Should be true in production
445 'body' => $params,
446 'headers' => [
447 'Connection' => 'Close'
448 ]
449 ]);
450
451 if (is_wp_error($response)) {
452 $this->log('IPN verification request failed: ' . $response->get_error_message(), 'error');
453 return [
454 'success' => false,
455 'message' => 'IPN verification request failed: ' . $response->get_error_message()
456 ];
457 }
458
459 $verification_result = wp_remote_retrieve_body($response);
460 $this->log('IPN Verification response: ' . $verification_result);
461
462 if (strcmp(trim($verification_result), 'VERIFIED') !== 0) {
463 $this->log('IPN verification failed. Result: ' . $verification_result, 'error');
464 return [
465 'success' => false,
466 'message' => 'IPN verification failed'
467 ];
468 }
469
470 $this->log('IPN VERIFIED.');
471
472 // IPN is verified, now parse the raw post data into an array
473 // The $data array passed to this function might already be this,
474 // but it's safer to parse from raw_post_data after verification.
475 parse_str($raw_post_data, $payload);
476 if (empty($payload)) {
477 $this->log('Failed to parse IPN payload after verification.', 'error');
478 return [
479 'success' => false,
480 'message' => 'Failed to parse IPN payload'
481 ];
482 }
483
484 // Check #1: Receiver Email (ensure payment went to your account)
485 $configured_paypal_email = $this->settings['email'] ?? '';
486 $ipn_receiver_email = $payload['receiver_email'] ?? $payload['business'] ?? '';
487 if (empty($configured_paypal_email) || strtolower(trim($ipn_receiver_email)) !== strtolower(trim($configured_paypal_email))) {
488 $this->log('IPN Receiver Email mismatch. Configured: ' . $configured_paypal_email . ' | Received: ' . $ipn_receiver_email, 'error');
489 return [
490 'success' => false,
491 'message' => 'IPN Receiver Email mismatch'
492 ];
493 }
494 $this->log('Receiver email verified: ' . $ipn_receiver_email);
495
496 // Check #2: Payment status (already in place, but now more reliable after verification)
497 if (($payload['payment_status'] ?? '') !== 'Completed') {
498 $this->log('Payment status not Completed. Status: ' . ($payload['payment_status'] ?? 'N/A'), 'warning');
499 return [
500 'success' => false,
501 'message' => 'Payment not completed. Status: ' . ($payload['payment_status'] ?? 'N/A')
502 ];
503 }
504 $this->log('Payment status VERIFIED as Completed.');
505
506 // (Optional but recommended: check txn_id for duplicates, mc_gross and mc_currency against invoice)
507
508 // Get invoice ID from custom data
509 $custom_data = json_decode($payload['custom'] ?? '{}', true);
510 $invoice_id = $custom_data['invoice_id'] ?? 0;
511 if (!$invoice_id) {
512 return [
513 'success' => false,
514 'message' => 'Invalid invoice ID'
515 ];
516 }
517
518 // Update invoice status
519 $invoice_post = get_post($invoice_id); // Fetch WP_Post object first
520 if (!$invoice_post) {
521 return [
522 'success' => false,
523 'message' => 'Invoice not found'
524 ];
525 }
526 $invoice = new \EasyInvoice\Models\Invoice($invoice_post); // Pass WP_Post to constructor
527
528 // Store payment details as meta
529 $paypal_payment_date = $payload['payment_date'] ?? current_time('mysql');
530 $paypal_txn_id = $payload['txn_id'] ?? '';
531 $payment_amount = floatval($payload['mc_gross'] ?? 0);
532
533 $invoice->setMeta('_easy_invoice_payment_method', 'paypal');
534 $invoice->setMeta('_easy_invoice_payment_date', $paypal_payment_date);
535 $invoice->setMeta('_easy_invoice_transaction_id', $paypal_txn_id);
536
537 // Update the overall payment status (as seen in PaymentController logic)
538 $invoice->setMeta('_payment_status', 'completed');
539
540 // Update invoice status to paid only if total payments are sufficient
541 // This will trigger 'easy_invoice_payment_completed' hook which sends admin notification
542 $this->updateInvoiceStatusIfPaid($invoice_id, $invoice, 'paypal');
543
544 // Update the WordPress post status to publish (if not already, or if 'paid' isn't a WP status)
545 $current_wp_status = $invoice->getPost()->post_status;
546 if ($current_wp_status !== 'publish' && $current_wp_status !== 'paid') { // Assuming 'paid' could be a custom registered status
547 wp_update_post(['ID' => $invoice_id, 'post_status' => 'publish']);
548 }
549
550 // Store payment details for the hook
551 $invoice->setMeta('_payment_method', 'paypal');
552 $invoice->setMeta('_transaction_id', $paypal_txn_id);
553
554 return [
555 'success' => true,
556 'message' => 'Payment processed successfully'
557 ];
558 }
559
560
561 }