Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions inc/admin-pages/class-payment-edit-admin-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -1459,19 +1459,25 @@ public function handle_save(): bool {

$payment = $this->get_object();

$should_confirm_membership = wu_request('confirm_membership')
&& Payment_Status::PENDING === $payment->get_status()
$is_pending_to_completed = Payment_Status::PENDING === $payment->get_status()
&& Payment_Status::COMPLETED === wu_request('status');
$should_confirm_membership = wu_request('confirm_membership') && $is_pending_to_completed;
$should_trigger_payment_received = $is_pending_to_completed
|| (1 === (int) $payment->get_meta('wu_payment_received_pending') && Payment_Status::COMPLETED === wu_request('status'));

if ($is_pending_to_completed) {
$payment->update_meta('wu_payment_received_pending', 1);
}

$payment->recalculate_totals();

// Persist the payment first: failed saves and repeat submissions grant nothing.
$saved = parent::handle_save();

if ($saved && $should_confirm_membership && Payment_Status::COMPLETED === $payment->get_status()) {
if ($saved && $should_trigger_payment_received && Payment_Status::COMPLETED === $payment->get_status()) {
$membership = $payment->get_membership();

if ($membership) {
if ($should_confirm_membership && $membership) {
$is_initial_payment = Membership_Status::PENDING === $membership->get_status()
&& 0 === $membership->get_times_billed();

Expand Down Expand Up @@ -1502,6 +1508,23 @@ public function handle_save(): bool {
return false;
}
}

// The payment is already saved; a failing receipt listener stays retryable on the next save.
try {
\WP_Ultimo\Managers\Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership);
} catch (\Throwable $exception) {
wu_log_add(
'payment-received',
sprintf('Payment %d receipt dispatch failed after manual confirmation: %s', $payment->get_id(), $exception->getMessage()),
\Psr\Log\LogLevel::ERROR
);

WP_Ultimo()->notices->add(
__('The payment was saved, but the payment receipt could not be sent. Save the payment again to retry.', 'ultimate-multisite'),
'warning',
'network-admin'
);
}
}

return $saved;
Expand Down
39 changes: 39 additions & 0 deletions inc/managers/class-customer-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use WP_Ultimo\Checkout\Checkout;
use WP_Ultimo\Models\Customer;
use WP_Ultimo\Database\Memberships\Membership_Status;
use WP_Ultimo\Database\Payments\Payment_Status;

// Exit if accessed directly
defined('ABSPATH') || exit;
Expand Down Expand Up @@ -232,6 +233,42 @@ protected function maybe_send_verification_email(int $customer_id): void {
$customer->send_verification_email();
}

/**
* Sends the deferred receipt for a free signup once the customer is verified.
*
* Paid and manual payments are skipped: they are confirmed by their own flows.
*
* @since 2.18.1
*
* @param \WP_Ultimo\Models\Payment $payment The payment.
* @param \WP_Ultimo\Models\Membership $membership The membership.
* @return void
*/
protected function maybe_send_payment_received($payment, $membership): void {

if (Payment_Status::COMPLETED !== $payment->get_status() || 0 < (float) $payment->get_total()) {
return;
}

if (Membership_Status::ACTIVE !== $membership->get_status()) {
return;
}

/*
* The customer is already verified at this point, so a failing receipt
* listener must not block the redirect. The pending flag keeps it retryable.
*/
try {
Payment_Manager::get_instance()->maybe_trigger_payment_received($payment, $membership);
} catch (\Throwable $exception) {
wu_log_add(
'payment-received',
sprintf('Payment %d receipt dispatch failed after email verification: %s', $payment->get_id(), $exception->getMessage()),
\Psr\Log\LogLevel::ERROR
);
}
}

/**
* Verifies a customer by checking the email key.
*
Expand Down Expand Up @@ -317,6 +354,8 @@ public function maybe_verify_email_address(): void {
$payments = $membership->get_payments();

if ($payments) {
$this->maybe_send_payment_received($payments[0], $membership);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

$redirect_url = add_query_arg(
[
'payment' => $payments[0]->get_hash(),
Expand Down
12 changes: 11 additions & 1 deletion inc/managers/class-discount-code-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ public function maybe_add_use_on_payment_received($payment): void {
return;
}

/*
* The processed event can fire more than once for the same payment,
* e.g. a retried receipt dispatch, so count each payment only once.
*/
if ($payment->get_meta('wu_discount_use_counted')) {
return;
}

/*
* Refetch the object, as the original version
* might be too old and out-of-date by now.
Expand All @@ -105,7 +113,9 @@ public function maybe_add_use_on_payment_received($payment): void {
if ($discount_code) {
$discount_code->add_use();

$discount_code->save();
if (true === $discount_code->save()) {
$payment->update_meta('wu_discount_use_counted', 1);
}
}
}
}
71 changes: 71 additions & 0 deletions inc/managers/class-payment-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,77 @@ public function handle_payment_success($payment, $membership, $gateway): void {
}

wu_do_event('payment_received', $payload);

if ($payment->get_id()) {
$marked_sent = $payment->update_meta('wu_payment_received_sent', 1);

if ($marked_sent) {
$payment->delete_meta('wu_payment_received_pending');
}
}
}

/**
* Sends the payment received notifications once per payment.
*
* Used by flows that confirm a payment outside a gateway callback, such as
* manual payment confirmation and deferred free signups after email verification.
*
* @since 2.18.1
*
* @param \WP_Ultimo\Models\Payment $payment The payment.
* @param \WP_Ultimo\Models\Membership|null $membership The membership, if known.
* @return bool True when the notification was triggered now.
*/
public function maybe_trigger_payment_received($payment, $membership = null): bool {

if ( ! $payment || ! $payment->get_id()) {
return false;
}

$gateway = wu_get_gateway($payment->get_gateway() ?: 'manual');

if ( ! $gateway) {
$gateway = wu_get_gateway('free');
}

if ( ! $gateway) {
return false;
}

global $wpdb;

$lock = sprintf('wu_payment_received_%d_%d', get_current_network_id(), $payment->get_id());
static $in_progress = [];

if (isset($in_progress[ $lock ])) {
return false;
}

if ('1' !== (string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 0)', $lock))) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached.
return false;
}

$in_progress[ $lock ] = true;

try {
/*
* Another request may have sent the receipt and released the lock after
* this request primed the meta cache, so re-read the flag from the database.
*/
wp_cache_delete($payment->get_id(), 'wu_payment_meta');

if ($payment->get_meta('wu_payment_received_sent')) {
return false;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

$gateway->trigger_payment_processed($payment, $membership);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

return true;
} finally {
unset($in_progress[ $lock ]);
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- named lock, never cached.
}
}

/**
Expand Down
76 changes: 76 additions & 0 deletions tests/WP_Ultimo/Managers/Customer_Manager_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

namespace WP_Ultimo\Tests\Managers;

use WP_Ultimo\Database\Memberships\Membership_Status;
use WP_Ultimo\Database\Payments\Payment_Status;
use WP_Ultimo\Gateways\Free_Gateway;
use WP_Ultimo\Managers\Customer_Manager;

class Customer_Manager_Test extends \WP_UnitTestCase {
Expand Down Expand Up @@ -140,6 +143,79 @@ public function test_email_verification_allows_different_logged_in_user(): void
$this->assert_email_verification_request_succeeds($customer);
}

public function test_email_verification_dispatches_free_signup_receipt_once(): void {

$customer = $this->create_pending_customer_with_verification_key('free-signup-verification@example.com');
$product = wu_create_product(
[
'name' => 'Verified Free Plan',
'slug' => 'verified-free-plan-' . wp_generate_uuid4(),
'pricing_type' => 'free',
'amount' => 0,
'type' => 'plan',
]
);

$this->assertNotWPError($product);

$membership = wu_create_membership(
[
'customer_id' => $customer->get_id(),
'plan_id' => $product->get_id(),
'status' => Membership_Status::PENDING,
'date_trial_end' => gmdate('Y-m-d H:i:s', time() - HOUR_IN_SECONDS),
'skip_validation' => true,
]
);

$this->assertNotWPError($membership);

$payment = wu_create_payment(
[
'customer_id' => $customer->get_id(),
'membership_id' => $membership->get_id(),
'product_id' => $product->get_id(),
'currency' => 'USD',
'subtotal' => 0,
'total' => 0,
'status' => Payment_Status::COMPLETED,
'gateway' => 'free',
]
);

$this->assertNotWPError($payment);

$dispatch_count = 0;
$count_dispatch = static function () use (&$dispatch_count): void {
++$dispatch_count;
};
$resolve_gateway = static function ($gateway, $gateway_id) {
return ! $gateway && 'free' === $gateway_id ? new Free_Gateway() : $gateway;
};

add_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX, 0);
add_filter('wu_get_gateway', $resolve_gateway, 10, 2);

try {
$this->assert_email_verification_request_succeeds($customer);

$membership = wu_get_membership($membership->get_id());
$payment = wu_get_payment($payment->get_id());

$this->assertSame(Membership_Status::ACTIVE, $membership->get_status());
$this->assertSame(1, (int) $payment->get_meta('wu_payment_received_sent'));

$reflection = new \ReflectionClass($this->get_manager_instance());
$method = $reflection->getMethod('maybe_send_payment_received');
$method->invoke($this->get_manager_instance(), $payment, $membership);
} finally {
remove_action('wu_gateway_payment_processed', $count_dispatch, PHP_INT_MAX);
remove_filter('wu_get_gateway', $resolve_gateway, 10);
}

$this->assertSame(1, $dispatch_count);
}

/**
* Create a pending customer with a saved verification key.
*
Expand Down
Loading
Loading