| @@ -529,8 +529,25 @@ | ||
| 529 | 529 | $paypal_payment_date = $payload['payment_date'] ?? current_time('mysql'); |
| 530 | 530 | $paypal_txn_id = $payload['txn_id'] ?? ''; |
| 531 | 531 | $payment_amount = floatval($payload['mc_gross'] ?? 0); |
| 532 | 532 | |
| 533 | + // PayPal retries IPNs it thinks were not acknowledged. The txn_id is | |
| 534 | + // the one thing that identifies a payment, so a second delivery of | |
| 535 | + // one we have already booked changes nothing. | |
| 536 | + if ('' !== $paypal_txn_id && $this->completedPaymentForTransaction($invoice_id, $paypal_txn_id) > 0) { | |
| 537 | + $this->log('IPN for txn ' . $paypal_txn_id . ' already booked; nothing to do.'); | |
| 538 | + return [ | |
| 539 | + 'success' => true, | |
| 540 | + 'message' => 'Payment already recorded' | |
| 541 | + ]; | |
| 542 | + } | |
| 543 | + | |
| 544 | + // "Is the invoice paid?" is answered by summing *completed* payment | |
| 545 | + // records. The record created when the customer was sent to PayPal is | |
| 546 | + // still pending, so it has to be completed here -- otherwise a verified | |
| 547 | + // PayPal payment left the invoice unpaid until someone marked it by hand. | |
| 548 | + $this->settlePaymentRecord($invoice_id, $paypal_txn_id, $payment_amount, (string) ($payload['mc_currency'] ?? ''), $payload); | |
| 549 | + | |
| 533 | 550 | $invoice->setMeta('_easy_invoice_payment_method', 'paypal'); |
| 534 | 551 | $invoice->setMeta('_easy_invoice_payment_date', $paypal_payment_date); |
| 535 | 552 | $invoice->setMeta('_easy_invoice_transaction_id', $paypal_txn_id); |
| 536 | 553 | |
| @@ -555,7 +572,114 @@ | ||
| 555 | 572 | 'success' => true, |
| 556 | 573 | 'message' => 'Payment processed successfully' |
| 557 | 574 | ]; |
| 558 | 575 | } |
| 559 | - | |
| 576 | + | |
| 577 | + /** | |
| 578 | + * Completed payment record already holding this PayPal transaction, if any. | |
| 579 | + * | |
| 580 | + * @param int $invoice_id Invoice. | |
| 581 | + * @param string $txn_id PayPal txn_id. | |
| 582 | + * @return int Payment post ID, 0 when none. | |
| 583 | + */ | |
| 584 | + private function completedPaymentForTransaction(int $invoice_id, string $txn_id): int { | |
| 585 | + $ids = get_posts([ | |
| 586 | + 'post_type' => 'easy_invoice_payment', | |
| 587 | + 'post_status' => 'publish', | |
| 588 | + 'fields' => 'ids', | |
| 589 | + 'posts_per_page' => 1, | |
| 590 | + 'no_found_rows' => true, | |
| 591 | + 'meta_query' => [ | |
| 592 | + ['key' => '_invoice_id', 'value' => $invoice_id], | |
| 593 | + ['key' => '_transaction_id', 'value' => $txn_id], | |
| 594 | + ['key' => '_status', 'value' => 'completed'], | |
| 595 | + ], | |
| 596 | + ]); | |
| 597 | + | |
| 598 | + return empty($ids) ? 0 : (int) $ids[0]; | |
| 599 | + } | |
| 600 | + | |
| 601 | + /** | |
| 602 | + * Turn the pending record from checkout into a completed one, or book a | |
| 603 | + * fresh record when there is none to complete. | |
| 604 | + * | |
| 605 | + * The amount written is what PayPal says was paid, not what the invoice | |
| 606 | + * asked for. A short payment then shows as partial rather than paid. | |
| 607 | + * | |
| 608 | + * @param int $invoice_id Invoice. | |
| 609 | + * @param string $txn_id PayPal txn_id. | |
| 610 | + * @param float $amount Gross amount from the IPN. | |
| 611 | + * @param string $currency Currency code from the IPN. | |
| 612 | + * @param array $payload Verified IPN payload, kept for the audit trail. | |
| 613 | + * @return int Payment post ID, 0 on failure. | |
| 614 | + */ | |
| 615 | + private function settlePaymentRecord(int $invoice_id, string $txn_id, float $amount, string $currency, array $payload): int { | |
| 616 | + $pending = get_posts([ | |
| 617 | + 'post_type' => 'easy_invoice_payment', | |
| 618 | + 'post_status' => 'publish', | |
| 619 | + 'fields' => 'ids', | |
| 620 | + 'posts_per_page' => 1, | |
| 621 | + 'no_found_rows' => true, | |
| 622 | + 'orderby' => 'date', | |
| 623 | + 'order' => 'DESC', | |
| 624 | + 'meta_query' => [ | |
| 625 | + ['key' => '_invoice_id', 'value' => $invoice_id], | |
| 626 | + ['key' => '_payment_method', 'value' => 'paypal'], | |
| 627 | + ['key' => '_status', 'value' => 'pending'], | |
| 628 | + ], | |
| 629 | + ]); | |
| 630 | + | |
| 631 | + $gateway_response = [ | |
| 632 | + 'gateway' => 'paypal', | |
| 633 | + 'txn_id' => $txn_id, | |
| 634 | + 'payment_status' => $payload['payment_status'] ?? '', | |
| 635 | + 'mc_gross' => $payload['mc_gross'] ?? '', | |
| 636 | + 'mc_currency' => $currency, | |
| 637 | + 'payer_email' => $payload['payer_email'] ?? '', | |
| 638 | + ]; | |
| 639 | + | |
| 640 | + if (!empty($pending)) { | |
| 641 | + $payment_id = (int) $pending[0]; | |
| 642 | + update_post_meta($payment_id, '_status', 'completed'); | |
| 643 | + update_post_meta($payment_id, '_transaction_id', '' !== $txn_id ? $txn_id : get_post_meta($payment_id, '_transaction_id', true)); | |
| 644 | + if ($amount > 0) { | |
| 645 | + update_post_meta($payment_id, '_amount', $amount); | |
| 646 | + } | |
| 647 | + update_post_meta($payment_id, '_payment_date', current_time('mysql')); | |
| 648 | + update_post_meta($payment_id, '_notes', __('Payment confirmed by PayPal', 'easy-invoice')); | |
| 649 | + update_post_meta($payment_id, '_gateway_response', wp_json_encode($gateway_response)); | |
| 650 | + $this->log('Pending PayPal record #' . $payment_id . ' completed for txn ' . $txn_id); | |
| 651 | + | |
| 652 | + return $payment_id; | |
| 653 | + } | |
| 654 | + | |
| 655 | + $currency = '' !== $currency ? strtoupper($currency) : get_option('easy_invoice_currency_code', 'USD'); | |
| 656 | + $payment_id = wp_insert_post([ | |
| 657 | + 'post_title' => sprintf('PayPal Payment for Invoice #%s', $invoice_id), | |
| 658 | + 'post_type' => 'easy_invoice_payment', | |
| 659 | + 'post_status' => 'publish', | |
| 660 | + 'post_author' => 1, | |
| 661 | + 'meta_input' => [ | |
| 662 | + '_invoice_id' => $invoice_id, | |
| 663 | + '_amount' => $amount, | |
| 664 | + '_payment_method' => 'paypal', | |
| 665 | + '_status' => 'completed', | |
| 666 | + '_transaction_id' => $txn_id, | |
| 667 | + '_payment_date' => current_time('mysql'), | |
| 668 | + '_notes' => __('Payment confirmed by PayPal', 'easy-invoice'), | |
| 669 | + '_payment_type' => 'gateway', | |
| 670 | + '_currency' => $currency, | |
| 671 | + '_currency_symbol' => \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency), | |
| 672 | + '_gateway_response' => wp_json_encode($gateway_response), | |
| 673 | + ], | |
| 674 | + ]); | |
| 675 | + | |
| 676 | + if (is_wp_error($payment_id) || !$payment_id) { | |
| 677 | + $this->log('Failed to book PayPal payment for txn ' . $txn_id, 'error'); | |
| 678 | + return 0; | |
| 679 | + } | |
| 680 | + $this->log('PayPal payment record #' . $payment_id . ' booked for txn ' . $txn_id); | |
| 681 | + | |
| 682 | + return (int) $payment_id; | |
| 683 | + } | |
| 560 | 684 | |
| 561 | 685 | } |