Skip to content

Commit

Permalink
Add uncaptured transactions count badge to Transactions menu (#5046)
Browse files Browse the repository at this point in the history
* Add badge for uncaptured transactions count in Payments menu

This is a basic implementation without caching, which will come a bit later.

* Display badge only when manual capture is enabled

This will save us a tonne of unnecessary API calls to get authorizations count.

* Cache authorization summary data

We use Database_Cache to store authorization summary data (including
uncaptured count) as an option in database.

Justification for caching:

* Uncaptured transactions count is fetched via API when the Payments menu
is created in WP Admin, which is essentially on each page load of WP Admin.

* Not all merchants will receive multiple orders every minute.

* Invalidate auth summary cache based on webhook events

* Add changelog

* Invalidate cache based on intent rather than charge events

This will be things consistent with server. Besides, charge suceeded and
captured events are not currently being forwarded to the plugin.

* Remove duplicate badge format constant

* Add count badge to Uncaptured tab button

Without a proper support for badges in WP's TabPanel component,
implementing this barebones but functional approach.

* Fix duplicate import that came with merge with develop

* Add tests for badge in Transactions menu item

* Remove count badge from Uncaptured tab button

We'll revisit this later when we have a better solution.

* Hide the badge under a feature flag

* Minor refactors for stricter typing

* Remove empty line between variable and condition

* Fix tests breaking because of feature flag

* Update changelog

Co-authored-by: Miguel Gasca <miguel.gasca@automattic.com>
  • Loading branch information
anu-rock and mgascam authored Nov 8, 2022
1 parent ff3a6f8 commit 38a2936
Show file tree
Hide file tree
Showing 6 changed files with 200 additions and 5 deletions.
4 changes: 4 additions & 0 deletions changelog/add-4504-auth-count-badge
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: add

Add uncaptured transactions count badge to Transactions menu.
56 changes: 53 additions & 3 deletions includes/admin/class-wc-payments-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ class WC_Payments_Admin {
const MENU_NOTIFICATION_BADGE = ' <span class="wcpay-menu-badge awaiting-mod count-1"><span class="plugin-count">1</span></span>';

/**
* Dispute notification badge HTML format (with placeholder for the number of disputes).
* Badge with a count (number of unresolved items) displayed next to a menu item.
* Unresolved refers to items that are unread or need action.
*
* @var string
*/
const DISPUTE_NOTIFICATION_BADGE_FORMAT = ' <span class="wcpay-menu-badge awaiting-mod count-%1$s"><span class="plugin-count">%1$d</span></span>';
const UNRESOLVED_NOTIFICATION_BADGE_FORMAT = ' <span class="wcpay-menu-badge awaiting-mod count-%1$s"><span class="plugin-count">%1$d</span></span>';

/**
* WC Payments WordPress Admin menu slug.
Expand Down Expand Up @@ -420,6 +421,9 @@ public function add_payments_menu() {
$this->add_menu_notification_badge();
$this->add_update_business_details_task();
$this->add_disputes_notification_badge();
if ( \WC_Payments_Features::is_auth_and_capture_enabled() && $this->wcpay_gateway->get_option( 'manual_capture' ) === 'yes' ) {
$this->add_transactions_notification_badge();
}
}

/**
Expand Down Expand Up @@ -957,7 +961,33 @@ public function add_disputes_notification_badge() {
$submenu[ self::PAYMENTS_SUBMENU_SLUG ][ $index ][2] = admin_url( add_query_arg( [ 'filter' => 'awaiting_response' ], 'admin.php?page=' . $menu_item[2] ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited

// Append the dispute notification badge to indicate the number of disputes needing a response.
$submenu[ self::PAYMENTS_SUBMENU_SLUG ][ $index ][0] .= sprintf( self::DISPUTE_NOTIFICATION_BADGE_FORMAT, esc_html( $disputes_needing_response ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$submenu[ self::PAYMENTS_SUBMENU_SLUG ][ $index ][0] .= sprintf( self::UNRESOLVED_NOTIFICATION_BADGE_FORMAT, esc_html( $disputes_needing_response ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
break;
}
}
}

/**
* Adds a notification badge to the Payments > Transactions admin menu item to
* indicate the number of transactions that need to be captured.
*
* @return void
*/
public function add_transactions_notification_badge() {
global $submenu;

if ( ! isset( $submenu[ self::PAYMENTS_SUBMENU_SLUG ] ) ) {
return;
}

$uncaptured_transactions = $this->get_uncaptured_transactions_count();
if ( $uncaptured_transactions <= 0 ) {
return;
}

foreach ( $submenu[ self::PAYMENTS_SUBMENU_SLUG ] as $index => $menu_item ) {
if ( 'wc-admin&path=/payments/transactions' === $menu_item[2] ) {
$submenu[ self::PAYMENTS_SUBMENU_SLUG ][ $index ][0] .= sprintf( self::UNRESOLVED_NOTIFICATION_BADGE_FORMAT, esc_html( $uncaptured_transactions ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
break;
}
}
Expand All @@ -983,4 +1013,24 @@ private function get_disputes_awaiting_response_count() {
$needs_response_statuses = [ 'needs_response', 'warning_needs_response' ];
return (int) array_sum( array_intersect_key( $disputes_status_counts, array_flip( $needs_response_statuses ) ) );
}

/**
* Gets the number of uncaptured transactions, that is authorizations that need to be captured within 7 days.
*
* @return int The number of uncaptured transactions.
*/
private function get_uncaptured_transactions_count() {
$authorization_summary = $this->database_cache->get_or_add(
Database_Cache::AUTHORIZATION_SUMMARY_KEY,
[ $this->payments_api_client, 'get_authorizations_summary' ],
// We'll consider all array values to be valid as the cache is only invalidated when it is deleted or it expires.
'is_array'
);

if ( empty( $authorization_summary ) ) {
return 0;
}

return $authorization_summary['count'];
}
}
7 changes: 7 additions & 0 deletions includes/class-database-cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ class Database_Cache {
*/
const DISPUTE_STATUS_COUNTS_KEY = 'wcpay_dispute_status_counts_cache';

/**
* Cache key for authorization summary data like count, total amount, etc.
*
* @var string
*/
const AUTHORIZATION_SUMMARY_KEY = 'wcpay_authorization_summary_cache';

/**
* Refresh disabled flag, controlling the behaviour of the get_or_add function.
*
Expand Down
10 changes: 10 additions & 0 deletions includes/class-wc-payments-features.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class WC_Payments_Features {
const UPE_FLAG_NAME = '_wcpay_feature_upe';
const WCPAY_SUBSCRIPTIONS_FLAG_NAME = '_wcpay_feature_subscriptions';
const WOOPAY_EXPRESS_CHECKOUT_FLAG_NAME = '_wcpay_feature_woopay_express_checkout';
const AUTH_AND_CAPTURE_FLAG_NAME = '_wcpay_feature_auth_and_capture';

/**
* Checks whether the UPE gateway is enabled
Expand Down Expand Up @@ -130,6 +131,15 @@ public static function is_woopay_express_checkout_enabled() {
return '1' === get_option( self::WOOPAY_EXPRESS_CHECKOUT_FLAG_NAME, '0' ) && self::is_platform_checkout_eligible();
}

/**
* Checks whether Auth & Capture (uncaptured transactions tab, capture from payment details page) is enabled.
*
* @return bool
*/
public static function is_auth_and_capture_enabled() {
return '1' === get_option( self::AUTH_AND_CAPTURE_FLAG_NAME, '0' );
}

/**
* Returns feature flags as an array suitable for display on the front-end.
*
Expand Down
36 changes: 36 additions & 0 deletions includes/class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,12 @@ public function process( array $event_body ) {
case 'payment_intent.succeeded':
$this->process_webhook_payment_intent_succeeded( $event_body );
break;
case 'payment_intent.canceled':
$this->process_webhook_payment_intent_canceled( $event_body );
break;
case 'payment_intent.amount_capturable_updated':
$this->process_webhook_payment_intent_amount_capturable_updated( $event_body );
break;
case 'invoice.upcoming':
WC_Payments_Subscriptions::get_event_handler()->handle_invoice_upcoming( $event_body );
break;
Expand Down Expand Up @@ -312,6 +318,33 @@ private function process_webhook_expired_authorization( $event_body ) {

// TODO: Revisit this logic once we support partial captures or multiple charges for order. We'll need to handle the "payment_intent.canceled" event too.
$this->order_service->mark_payment_capture_expired( $order, $intent_id, $intent_status, $charge_id );

// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
}

/**
* Process webhook for a payment intent canceled event.
*
* @param array $event_body The event that triggered the webhook.
*
* @return void
*/
private function process_webhook_payment_intent_canceled( $event_body ) {
// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
}

/**
* Process webhook for a payment intent amount capturable updated event.
*
* @param array $event_body The event that triggered the webhook.
*
* @return void
*/
private function process_webhook_payment_intent_amount_capturable_updated( $event_body ) {
// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
}

/**
Expand Down Expand Up @@ -409,6 +442,9 @@ private function process_webhook_payment_intent_succeeded( $event_body ) {
];
$this->receipt_service->send_customer_ipp_receipt_email( $order, $merchant_settings, $charges_data[0] );
}

// Clear the authorization summary cache to trigger a fetch of new data.
$this->database_cache->delete( DATABASE_CACHE::AUTHORIZATION_SUMMARY_KEY );
}

/**
Expand Down
92 changes: 90 additions & 2 deletions tests/unit/admin/test-class-wc-payments-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,9 @@ public function test_disputes_notification_badge_display() {
$this->assertArrayHasKey( $dispute_url, $item_names_by_urls );

// The expected badge content should include 4 disputes needing a response.
$expected_badge = sprintf( WC_Payments_Admin::DISPUTE_NOTIFICATION_BADGE_FORMAT, 4 );
$expected_badge = sprintf( WC_Payments_Admin::UNRESOLVED_NOTIFICATION_BADGE_FORMAT, 4 );

$this->assertEquals( 'Disputes' . $expected_badge, $item_names_by_urls[ $dispute_url ] );
$this->assertSame( 'Disputes' . $expected_badge, $item_names_by_urls[ $dispute_url ] );
}

/**
Expand Down Expand Up @@ -318,4 +318,92 @@ public function test_disputes_notification_badge_no_display() {

$this->assertEquals( 'Disputes', $dispute_menu_item );
}

/**
* Tests WC_Payments_Admin::add_transactions_notification_badge()
*/
public function test_transactions_notification_badge_display() {
global $submenu;

update_option( \WC_Payments_Features::AUTH_AND_CAPTURE_FLAG_NAME, '1' );

// Mock the manual capture setting as being enabled.
$this->mock_gateway
->expects( $this->once() )
->method( 'get_option' )
->with( 'manual_capture' )
->willReturn( 'yes' );

// Mock the database cache returning authorizations summary.
$this->mock_database_cache
->expects( $this->any() )
->method( 'get_or_add' )
->willReturn(
[
'count' => 3,
'currency' => 'usd',
'total' => 5400,
'all_currencies' => [
'eur',
'usd',
],
]
);

$this->mock_current_user_is_admin();

// Make sure we render the menu with submenu items.
$this->mock_account->method( 'try_is_stripe_connected' )->willReturn( true );
$this->payments_admin->add_payments_menu();

$item_names_by_urls = wp_list_pluck( $submenu[ WC_Payments_Admin::PAYMENTS_SUBMENU_SLUG ], 0, 2 );

$transactions_url = 'wc-admin&path=/payments/transactions';

// Assert the submenu includes a transactions item that links directly to the Transactions screen.
$this->assertArrayHasKey( $transactions_url, $item_names_by_urls );

// The expected badge content should include 3 uncaptured transactions.
$expected_badge = sprintf( WC_Payments_Admin::UNRESOLVED_NOTIFICATION_BADGE_FORMAT, 3 );

$this->assertSame( 'Transactions' . $expected_badge, $item_names_by_urls[ $transactions_url ] );
}

/**
* Tests WC_Payments_Admin::add_transactions_notification_badge()
*/
public function test_transactions_notification_badge_no_display() {
global $submenu;

update_option( \WC_Payments_Features::AUTH_AND_CAPTURE_FLAG_NAME, '1' );

// Mock the manual capture setting as being enabled.
$this->mock_gateway
->expects( $this->once() )
->method( 'get_option' )
->with( 'manual_capture' )
->willReturn( 'yes' );

// Mock the database cache returning authorizations summary.
$this->mock_database_cache
->expects( $this->any() )
->method( 'get_or_add' )
->willReturn(
[
'count' => 0,
'total' => 0,
]
);

$this->mock_current_user_is_admin();

// Make sure we render the menu with submenu items.
$this->mock_account->method( 'try_is_stripe_connected' )->willReturn( true );
$this->payments_admin->add_payments_menu();

$item_names_by_urls = wp_list_pluck( $submenu[ WC_Payments_Admin::PAYMENTS_SUBMENU_SLUG ], 0, 2 );
$transactions_menu_item = $item_names_by_urls['wc-admin&path=/payments/transactions'];

$this->assertSame( 'Transactions', $transactions_menu_item );
}
}

0 comments on commit 38a2936

Please sign in to comment.