mirror of
https://github.com/woocommerce/woocommerce-paypal-payments.git
synced 2025-09-04 08:47:23 +08:00
Merge branch 'trunk' into PCP-1968-subscriptions-api-renewals
This commit is contained in:
commit
250172a2ad
25 changed files with 301 additions and 68 deletions
|
@ -43,6 +43,20 @@ class ApiModule implements ModuleInterface {
|
||||||
WC()->session->set( 'ppcp_fees', $fees );
|
WC()->session->set( 'ppcp_fees', $fees );
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
add_filter(
|
||||||
|
'ppcp_create_order_request_body_data',
|
||||||
|
function( array $data ) use ( $c ) {
|
||||||
|
|
||||||
|
foreach ( $data['purchase_units'] as $purchase_unit_index => $purchase_unit ) {
|
||||||
|
foreach ( $purchase_unit['items'] as $item_index => $item ) {
|
||||||
|
$data['purchase_units'][ $purchase_unit_index ]['items'][ $item_index ]['name'] =
|
||||||
|
apply_filters( 'woocommerce_paypal_payments_cart_line_item_name', $item['name'], $item['cart_item_key'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
);
|
||||||
add_action(
|
add_action(
|
||||||
'woocommerce_paypal_payments_paypal_order_created',
|
'woocommerce_paypal_payments_paypal_order_created',
|
||||||
function ( Order $order ) use ( $c ) {
|
function ( Order $order ) use ( $c ) {
|
||||||
|
|
|
@ -121,7 +121,7 @@ class BillingAgreementsEndpoint {
|
||||||
*/
|
*/
|
||||||
public function reference_transaction_enabled(): bool {
|
public function reference_transaction_enabled(): bool {
|
||||||
try {
|
try {
|
||||||
if ( get_transient( 'ppcp_reference_transaction_enabled' ) === true ) {
|
if ( wc_string_to_bool( get_transient( 'ppcp_reference_transaction_enabled' ) ) === true ) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,7 +135,7 @@ class BillingAgreementsEndpoint {
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
$this->is_request_logging_enabled = true;
|
$this->is_request_logging_enabled = true;
|
||||||
set_transient( 'ppcp_reference_transaction_enabled', true, 3 * MONTH_IN_SECONDS );
|
set_transient( 'ppcp_reference_transaction_enabled', true, MONTH_IN_SECONDS );
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -202,7 +202,15 @@ class WebhookEndpoint {
|
||||||
|
|
||||||
$status_code = (int) wp_remote_retrieve_response_code( $response );
|
$status_code = (int) wp_remote_retrieve_response_code( $response );
|
||||||
if ( 204 !== $status_code ) {
|
if ( 204 !== $status_code ) {
|
||||||
$json = json_decode( $response['body'] ) ?? null;
|
$json = null;
|
||||||
|
/**
|
||||||
|
* Use in array as consistency check.
|
||||||
|
*
|
||||||
|
* @psalm-suppress RedundantConditionGivenDocblockType
|
||||||
|
*/
|
||||||
|
if ( is_array( $response ) ) {
|
||||||
|
$json = json_decode( $response['body'] );
|
||||||
|
}
|
||||||
throw new PayPalApiException(
|
throw new PayPalApiException(
|
||||||
$json,
|
$json,
|
||||||
$status_code
|
$status_code
|
||||||
|
|
|
@ -249,9 +249,12 @@ class Item {
|
||||||
'sku' => $this->sku(),
|
'sku' => $this->sku(),
|
||||||
'category' => $this->category(),
|
'category' => $this->category(),
|
||||||
'url' => $this->url(),
|
'url' => $this->url(),
|
||||||
'image_url' => $this->image_url(),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ( $this->image_url() ) {
|
||||||
|
$item['image_url'] = $this->image_url();
|
||||||
|
}
|
||||||
|
|
||||||
if ( $this->tax() ) {
|
if ( $this->tax() ) {
|
||||||
$item['tax'] = $this->tax()->to_array();
|
$item['tax'] = $this->tax()->to_array();
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,7 +98,7 @@ class PPECHelper {
|
||||||
set_transient(
|
set_transient(
|
||||||
'ppcp_has_ppec_subscriptions',
|
'ppcp_has_ppec_subscriptions',
|
||||||
! empty( $result ) ? 'true' : 'false',
|
! empty( $result ) ? 'true' : 'false',
|
||||||
3 * MONTH_IN_SECONDS
|
MONTH_IN_SECONDS
|
||||||
);
|
);
|
||||||
|
|
||||||
return ! empty( $result );
|
return ! empty( $result );
|
||||||
|
|
|
@ -64,7 +64,7 @@ class OnboardingUrl {
|
||||||
*
|
*
|
||||||
* @var int
|
* @var int
|
||||||
*/
|
*/
|
||||||
private $cache_ttl = 3 * MONTH_IN_SECONDS;
|
private $cache_ttl = MONTH_IN_SECONDS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The TTL for the previous token cache.
|
* The TTL for the previous token cache.
|
||||||
|
|
|
@ -241,7 +241,7 @@ class RenewalHandler {
|
||||||
* @param \WC_Customer $customer The customer.
|
* @param \WC_Customer $customer The customer.
|
||||||
* @param \WC_Order $wc_order The current WooCommerce order we want to process.
|
* @param \WC_Order $wc_order The current WooCommerce order we want to process.
|
||||||
*
|
*
|
||||||
* @return PaymentToken|null
|
* @return PaymentToken|null|false
|
||||||
*/
|
*/
|
||||||
private function get_token_for_customer( \WC_Customer $customer, \WC_Order $wc_order ) {
|
private function get_token_for_customer( \WC_Customer $customer, \WC_Order $wc_order ) {
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -45,6 +45,12 @@ return array(
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'uninstall.ppcp-all-action-names' => function( ContainerInterface $container ) : array {
|
||||||
|
return array(
|
||||||
|
'woocommerce_paypal_payments_uninstall',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
'uninstall.clear-db-endpoint' => function( ContainerInterface $container ) : string {
|
'uninstall.clear-db-endpoint' => function( ContainerInterface $container ) : string {
|
||||||
return 'ppcp-clear-db';
|
return 'ppcp-clear-db';
|
||||||
},
|
},
|
||||||
|
|
|
@ -31,4 +31,13 @@ class ClearDatabase implements ClearDatabaseInterface {
|
||||||
as_unschedule_action( $action_name );
|
as_unschedule_action( $action_name );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function clear_actions( array $action_names ): void {
|
||||||
|
foreach ( $action_names as $action_name ) {
|
||||||
|
do_action( $action_name );
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,4 +29,12 @@ interface ClearDatabaseInterface {
|
||||||
*/
|
*/
|
||||||
public function clear_scheduled_actions( array $action_names ): void;
|
public function clear_scheduled_actions( array $action_names ): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the given actions.
|
||||||
|
*
|
||||||
|
* @param string[] $action_names The list of action names.
|
||||||
|
* @throws RuntimeException If problem clearing.
|
||||||
|
*/
|
||||||
|
public function clear_actions( array $action_names ): void;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,8 +47,9 @@ class UninstallModule implements ModuleInterface {
|
||||||
$clear_db_endpoint = $container->get( 'uninstall.clear-db-endpoint' );
|
$clear_db_endpoint = $container->get( 'uninstall.clear-db-endpoint' );
|
||||||
$option_names = $container->get( 'uninstall.ppcp-all-option-names' );
|
$option_names = $container->get( 'uninstall.ppcp-all-option-names' );
|
||||||
$scheduled_action_names = $container->get( 'uninstall.ppcp-all-scheduled-action-names' );
|
$scheduled_action_names = $container->get( 'uninstall.ppcp-all-scheduled-action-names' );
|
||||||
|
$action_names = $container->get( 'uninstall.ppcp-all-action-names' );
|
||||||
|
|
||||||
$this->handleClearDbAjaxRequest( $request_data, $clear_db, $clear_db_endpoint, $option_names, $scheduled_action_names );
|
$this->handleClearDbAjaxRequest( $request_data, $clear_db, $clear_db_endpoint, $option_names, $scheduled_action_names, $action_names );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -69,17 +70,19 @@ class UninstallModule implements ModuleInterface {
|
||||||
* @param string $nonce The nonce.
|
* @param string $nonce The nonce.
|
||||||
* @param string[] $option_names The list of option names.
|
* @param string[] $option_names The list of option names.
|
||||||
* @param string[] $scheduled_action_names The list of scheduled action names.
|
* @param string[] $scheduled_action_names The list of scheduled action names.
|
||||||
|
* @param string[] $action_names The list of action names.
|
||||||
*/
|
*/
|
||||||
protected function handleClearDbAjaxRequest(
|
protected function handleClearDbAjaxRequest(
|
||||||
RequestData $request_data,
|
RequestData $request_data,
|
||||||
ClearDatabaseInterface $clear_db,
|
ClearDatabaseInterface $clear_db,
|
||||||
string $nonce,
|
string $nonce,
|
||||||
array $option_names,
|
array $option_names,
|
||||||
array $scheduled_action_names
|
array $scheduled_action_names,
|
||||||
|
array $action_names
|
||||||
): void {
|
): void {
|
||||||
add_action(
|
add_action(
|
||||||
"wc_ajax_{$nonce}",
|
"wc_ajax_{$nonce}",
|
||||||
static function () use ( $request_data, $clear_db, $nonce, $option_names, $scheduled_action_names ) {
|
static function () use ( $request_data, $clear_db, $nonce, $option_names, $scheduled_action_names, $action_names ) {
|
||||||
try {
|
try {
|
||||||
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
if ( ! current_user_can( 'manage_woocommerce' ) ) {
|
||||||
wp_send_json_error( 'Not admin.', 403 );
|
wp_send_json_error( 'Not admin.', 403 );
|
||||||
|
@ -91,6 +94,7 @@ class UninstallModule implements ModuleInterface {
|
||||||
|
|
||||||
$clear_db->delete_options( $option_names );
|
$clear_db->delete_options( $option_names );
|
||||||
$clear_db->clear_scheduled_actions( $scheduled_action_names );
|
$clear_db->clear_scheduled_actions( $scheduled_action_names );
|
||||||
|
$clear_db->clear_actions( $action_names );
|
||||||
|
|
||||||
wp_send_json_success();
|
wp_send_json_success();
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -56,10 +56,14 @@ return array(
|
||||||
'vaulting.payment-token-factory' => function( ContainerInterface $container ): PaymentTokenFactory {
|
'vaulting.payment-token-factory' => function( ContainerInterface $container ): PaymentTokenFactory {
|
||||||
return new PaymentTokenFactory();
|
return new PaymentTokenFactory();
|
||||||
},
|
},
|
||||||
|
'vaulting.payment-token-helper' => function( ContainerInterface $container ): PaymentTokenHelper {
|
||||||
|
return new PaymentTokenHelper();
|
||||||
|
},
|
||||||
'vaulting.payment-tokens-migration' => function( ContainerInterface $container ): PaymentTokensMigration {
|
'vaulting.payment-tokens-migration' => function( ContainerInterface $container ): PaymentTokensMigration {
|
||||||
return new PaymentTokensMigration(
|
return new PaymentTokensMigration(
|
||||||
$container->get( 'vaulting.payment-token-factory' ),
|
$container->get( 'vaulting.payment-token-factory' ),
|
||||||
$container->get( 'vaulting.repository.payment-token' ),
|
$container->get( 'vaulting.repository.payment-token' ),
|
||||||
|
$container->get( 'vaulting.payment-token-helper' ),
|
||||||
$container->get( 'woocommerce.logger.woocommerce' )
|
$container->get( 'woocommerce.logger.woocommerce' )
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
35
modules/ppcp-vaulting/src/PaymentTokenHelper.php
Normal file
35
modules/ppcp-vaulting/src/PaymentTokenHelper.php
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Payment Tokens helper methods.
|
||||||
|
*
|
||||||
|
* @package WooCommerce\PayPalCommerce\Vaulting
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace WooCommerce\PayPalCommerce\Vaulting;
|
||||||
|
|
||||||
|
use WC_Payment_Token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class PaymentTokenHelper
|
||||||
|
*/
|
||||||
|
class PaymentTokenHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if given token exist as WC Payment Token.
|
||||||
|
*
|
||||||
|
* @param WC_Payment_Token[] $wc_tokens WC Payment Tokens.
|
||||||
|
* @param string $token_id Payment Token ID.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function token_exist( array $wc_tokens, string $token_id ): bool {
|
||||||
|
foreach ( $wc_tokens as $wc_token ) {
|
||||||
|
if ( $wc_token->get_token() === $token_id ) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
|
@ -35,6 +35,13 @@ class PaymentTokensMigration {
|
||||||
*/
|
*/
|
||||||
private $payment_token_repository;
|
private $payment_token_repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The payment token helper.
|
||||||
|
*
|
||||||
|
* @var PaymentTokenHelper
|
||||||
|
*/
|
||||||
|
private $payment_token_helper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The logger.
|
* The logger.
|
||||||
*
|
*
|
||||||
|
@ -47,16 +54,19 @@ class PaymentTokensMigration {
|
||||||
*
|
*
|
||||||
* @param PaymentTokenFactory $payment_token_factory The payment token factory.
|
* @param PaymentTokenFactory $payment_token_factory The payment token factory.
|
||||||
* @param PaymentTokenRepository $payment_token_repository The payment token repository.
|
* @param PaymentTokenRepository $payment_token_repository The payment token repository.
|
||||||
|
* @param PaymentTokenHelper $payment_token_helper The payment token helper.
|
||||||
* @param LoggerInterface $logger The logger.
|
* @param LoggerInterface $logger The logger.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
PaymentTokenFactory $payment_token_factory,
|
PaymentTokenFactory $payment_token_factory,
|
||||||
PaymentTokenRepository $payment_token_repository,
|
PaymentTokenRepository $payment_token_repository,
|
||||||
|
PaymentTokenHelper $payment_token_helper,
|
||||||
LoggerInterface $logger
|
LoggerInterface $logger
|
||||||
) {
|
) {
|
||||||
$this->payment_token_factory = $payment_token_factory;
|
$this->payment_token_factory = $payment_token_factory;
|
||||||
$this->payment_token_repository = $payment_token_repository;
|
$this->payment_token_repository = $payment_token_repository;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
$this->payment_token_helper = $payment_token_helper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -72,7 +82,7 @@ class PaymentTokensMigration {
|
||||||
foreach ( $tokens as $token ) {
|
foreach ( $tokens as $token ) {
|
||||||
if ( isset( $token->source()->card ) ) {
|
if ( isset( $token->source()->card ) ) {
|
||||||
$wc_tokens = WC_Payment_Tokens::get_customer_tokens( $id, CreditCardGateway::ID );
|
$wc_tokens = WC_Payment_Tokens::get_customer_tokens( $id, CreditCardGateway::ID );
|
||||||
if ( $this->token_exist( $wc_tokens, $token ) ) {
|
if ( $this->payment_token_helper->token_exist( $wc_tokens, $token->id() ) ) {
|
||||||
$this->logger->info( 'Token already exist for user ' . (string) $id );
|
$this->logger->info( 'Token already exist for user ' . (string) $id );
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -97,7 +107,7 @@ class PaymentTokensMigration {
|
||||||
}
|
}
|
||||||
} elseif ( $token->source()->paypal ) {
|
} elseif ( $token->source()->paypal ) {
|
||||||
$wc_tokens = WC_Payment_Tokens::get_customer_tokens( $id, PayPalGateway::ID );
|
$wc_tokens = WC_Payment_Tokens::get_customer_tokens( $id, PayPalGateway::ID );
|
||||||
if ( $this->token_exist( $wc_tokens, $token ) ) {
|
if ( $this->payment_token_helper->token_exist( $wc_tokens, $token->id() ) ) {
|
||||||
$this->logger->info( 'Token already exist for user ' . (string) $id );
|
$this->logger->info( 'Token already exist for user ' . (string) $id );
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -126,21 +136,4 @@ class PaymentTokensMigration {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if given PayPal token exist as WC Payment Token.
|
|
||||||
*
|
|
||||||
* @param array $wc_tokens WC Payment Tokens.
|
|
||||||
* @param PaymentToken $token PayPal Token ID.
|
|
||||||
* @return bool
|
|
||||||
*/
|
|
||||||
private function token_exist( array $wc_tokens, PaymentToken $token ): bool {
|
|
||||||
foreach ( $wc_tokens as $wc_token ) {
|
|
||||||
if ( $wc_token->get_token() === $token->id() ) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,4 +44,14 @@ class GatewayRepository {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if a given gateway ID is registered.
|
||||||
|
*
|
||||||
|
* @param string $gateway_id The gateway ID.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function exists( string $gateway_id ): bool {
|
||||||
|
return in_array( $gateway_id, $this->ppcp_gateway_ids, true );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -227,7 +227,6 @@ class PayPalGateway extends \WC_Payment_Gateway {
|
||||||
|
|
||||||
if (
|
if (
|
||||||
( $this->config->has( 'vault_enabled' ) && $this->config->get( 'vault_enabled' ) )
|
( $this->config->has( 'vault_enabled' ) && $this->config->get( 'vault_enabled' ) )
|
||||||
|| ( $this->config->has( 'vault_enabled_dcc' ) && $this->config->get( 'vault_enabled_dcc' ) )
|
|
||||||
|| ( $this->config->has( 'subscriptions_mode' ) && $this->config->get( 'subscriptions_mode' ) === 'subscriptions_api' )
|
|| ( $this->config->has( 'subscriptions_mode' ) && $this->config->get( 'subscriptions_mode' ) === 'subscriptions_api' )
|
||||||
) {
|
) {
|
||||||
array_push(
|
array_push(
|
||||||
|
@ -244,6 +243,8 @@ class PayPalGateway extends \WC_Payment_Gateway {
|
||||||
'subscription_payment_method_change_admin',
|
'subscription_payment_method_change_admin',
|
||||||
'multiple_subscriptions'
|
'multiple_subscriptions'
|
||||||
);
|
);
|
||||||
|
} elseif ( $this->config->has( 'vault_enabled_dcc' ) && $this->config->get( 'vault_enabled_dcc' ) ) {
|
||||||
|
$this->supports[] = 'tokenization';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -135,12 +135,12 @@ class DCCProductStatus {
|
||||||
$this->settings->set( 'products_dcc_enabled', true );
|
$this->settings->set( 'products_dcc_enabled', true );
|
||||||
$this->settings->persist();
|
$this->settings->persist();
|
||||||
$this->current_status_cache = true;
|
$this->current_status_cache = true;
|
||||||
$this->cache->set( self::DCC_STATUS_CACHE_KEY, 'true', 3 * MONTH_IN_SECONDS );
|
$this->cache->set( self::DCC_STATUS_CACHE_KEY, 'true', MONTH_IN_SECONDS );
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$expiration = 3 * MONTH_IN_SECONDS;
|
$expiration = MONTH_IN_SECONDS;
|
||||||
if ( $this->dcc_applies->for_country_currency() ) {
|
if ( $this->dcc_applies->for_country_currency() ) {
|
||||||
$expiration = 3 * HOUR_IN_SECONDS;
|
$expiration = 3 * HOUR_IN_SECONDS;
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,11 +127,11 @@ class PayUponInvoiceProductStatus {
|
||||||
$this->settings->set( 'products_pui_enabled', true );
|
$this->settings->set( 'products_pui_enabled', true );
|
||||||
$this->settings->persist();
|
$this->settings->persist();
|
||||||
$this->current_status_cache = true;
|
$this->current_status_cache = true;
|
||||||
$this->cache->set( self::PUI_STATUS_CACHE_KEY, 'true', 3 * MONTH_IN_SECONDS );
|
$this->cache->set( self::PUI_STATUS_CACHE_KEY, 'true', MONTH_IN_SECONDS );
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->cache->set( self::PUI_STATUS_CACHE_KEY, 'false', 3 * MONTH_IN_SECONDS );
|
$this->cache->set( self::PUI_STATUS_CACHE_KEY, 'false', MONTH_IN_SECONDS );
|
||||||
|
|
||||||
$this->current_status_cache = false;
|
$this->current_status_cache = false;
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -116,6 +116,13 @@ class OrderProcessor {
|
||||||
*/
|
*/
|
||||||
private $order_helper;
|
private $order_helper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array to store temporary order data changes to restore after processing.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
private $restore_order_data = array();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OrderProcessor constructor.
|
* OrderProcessor constructor.
|
||||||
*
|
*
|
||||||
|
@ -292,8 +299,12 @@ class OrderProcessor {
|
||||||
* @return Order
|
* @return Order
|
||||||
*/
|
*/
|
||||||
public function patch_order( \WC_Order $wc_order, Order $order ): Order {
|
public function patch_order( \WC_Order $wc_order, Order $order ): Order {
|
||||||
|
$this->apply_outbound_order_filters( $wc_order );
|
||||||
$updated_order = $this->order_factory->from_wc_order( $wc_order, $order );
|
$updated_order = $this->order_factory->from_wc_order( $wc_order, $order );
|
||||||
$order = $this->order_endpoint->patch_order_with( $order, $updated_order );
|
$this->restore_order_from_filters( $wc_order );
|
||||||
|
|
||||||
|
$order = $this->order_endpoint->patch_order_with( $order, $updated_order );
|
||||||
|
|
||||||
return $order;
|
return $order;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -323,4 +334,48 @@ class OrderProcessor {
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies filters to the WC_Order, so they are reflected only on PayPal Order.
|
||||||
|
*
|
||||||
|
* @param WC_Order $wc_order The WoocOmmerce Order.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function apply_outbound_order_filters( WC_Order $wc_order ): void {
|
||||||
|
$items = $wc_order->get_items();
|
||||||
|
|
||||||
|
$this->restore_order_data['names'] = array();
|
||||||
|
|
||||||
|
foreach ( $items as $item ) {
|
||||||
|
if ( ! $item instanceof \WC_Order_Item ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$original_name = $item->get_name();
|
||||||
|
$new_name = apply_filters( 'woocommerce_paypal_payments_order_line_item_name', $original_name, $item->get_id(), $wc_order->get_id() );
|
||||||
|
|
||||||
|
if ( $new_name !== $original_name ) {
|
||||||
|
$this->restore_order_data['names'][ $item->get_id() ] = $original_name;
|
||||||
|
$item->set_name( $new_name );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores the WC_Order to it's state before filters.
|
||||||
|
*
|
||||||
|
* @param WC_Order $wc_order The WooCommerce Order.
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function restore_order_from_filters( WC_Order $wc_order ): void {
|
||||||
|
if ( is_array( $this->restore_order_data['names'] ?? null ) ) {
|
||||||
|
foreach ( $this->restore_order_data['names'] as $wc_item_id => $original_name ) {
|
||||||
|
$wc_item = $wc_order->get_item( $wc_item_id, false );
|
||||||
|
|
||||||
|
if ( $wc_item ) {
|
||||||
|
$wc_item->set_name( $original_name );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -211,7 +211,7 @@ class SettingsListener {
|
||||||
}
|
}
|
||||||
|
|
||||||
$merchant_id = sanitize_text_field( wp_unslash( $_GET['merchantIdInPayPal'] ) );
|
$merchant_id = sanitize_text_field( wp_unslash( $_GET['merchantIdInPayPal'] ) );
|
||||||
$merchant_email = sanitize_text_field( wp_unslash( $_GET['merchantId'] ) );
|
$merchant_email = $this->sanitize_onboarding_email( sanitize_text_field( wp_unslash( $_GET['merchantId'] ) ) );
|
||||||
$onboarding_token = sanitize_text_field( wp_unslash( $_GET['ppcpToken'] ) );
|
$onboarding_token = sanitize_text_field( wp_unslash( $_GET['ppcpToken'] ) );
|
||||||
$retry_count = isset( $_GET['ppcpRetry'] ) ? ( (int) sanitize_text_field( wp_unslash( $_GET['ppcpRetry'] ) ) ) : 0;
|
$retry_count = isset( $_GET['ppcpRetry'] ) ? ( (int) sanitize_text_field( wp_unslash( $_GET['ppcpRetry'] ) ) ) : 0;
|
||||||
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
// phpcs:enable WordPress.Security.NonceVerification.Missing
|
||||||
|
@ -278,6 +278,16 @@ class SettingsListener {
|
||||||
$this->onboarding_redirect();
|
$this->onboarding_redirect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes the onboarding email.
|
||||||
|
*
|
||||||
|
* @param string $email The onboarding email.
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function sanitize_onboarding_email( string $email ): string {
|
||||||
|
return str_replace( ' ', '+', $email );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirect to the onboarding URL.
|
* Redirect to the onboarding URL.
|
||||||
*
|
*
|
||||||
|
@ -401,9 +411,7 @@ class SettingsListener {
|
||||||
$this->webhook_registrar->unregister();
|
$this->webhook_registrar->unregister();
|
||||||
|
|
||||||
foreach ( $this->signup_link_ids as $key ) {
|
foreach ( $this->signup_link_ids as $key ) {
|
||||||
if ( $this->signup_link_cache->has( $key ) ) {
|
( new OnboardingUrl( $this->signup_link_cache, $key, get_current_user_id() ) )->delete();
|
||||||
$this->signup_link_cache->delete( $key );
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -613,4 +621,40 @@ class SettingsListener {
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prevent enabling tracking if it is not enabled for merchant account.
|
||||||
|
*
|
||||||
|
* @throws RuntimeException When API request fails.
|
||||||
|
*/
|
||||||
|
public function listen_for_tracking_enabled(): void {
|
||||||
|
if ( State::STATE_ONBOARDED !== $this->state->current_state() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$token = $this->bearer->bearer();
|
||||||
|
if ( ! $token->is_tracking_available() ) {
|
||||||
|
$this->settings->set( 'tracking_enabled', false );
|
||||||
|
$this->settings->persist();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch ( RuntimeException $exception ) {
|
||||||
|
$this->settings->set( 'tracking_enabled', false );
|
||||||
|
$this->settings->persist();
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles onboarding URLs deletion
|
||||||
|
*/
|
||||||
|
public function listen_for_uninstall(): void {
|
||||||
|
// Clear onboarding links from cache.
|
||||||
|
foreach ( $this->signup_link_ids as $key ) {
|
||||||
|
( new OnboardingUrl( $this->signup_link_cache, $key, get_current_user_id() ) )->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,6 +33,7 @@ use WooCommerce\PayPalCommerce\WcGateway\Checkout\DisableGateways;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint;
|
use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
|
use WooCommerce\PayPalCommerce\WcGateway\Exception\NotFoundException;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
|
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
|
||||||
|
use WooCommerce\PayPalCommerce\WcGateway\Gateway\GatewayRepository;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
|
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus;
|
use WooCommerce\PayPalCommerce\WcGateway\Helper\DCCProductStatus;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus;
|
use WooCommerce\PayPalCommerce\WcGateway\Helper\PayUponInvoiceProductStatus;
|
||||||
|
@ -356,6 +357,14 @@ class WCGatewayModule implements ModuleInterface {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$gateway_repository = $c->get( 'wcgateway.gateway-repository' );
|
||||||
|
assert( $gateway_repository instanceof GatewayRepository );
|
||||||
|
|
||||||
|
// Only allow to proceed if the payment method is one of our Gateways.
|
||||||
|
if ( ! $gateway_repository->exists( $wc_order->get_payment_method() ) ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$intent = strtoupper( (string) $wc_order->get_meta( PayPalGateway::INTENT_META_KEY ) );
|
$intent = strtoupper( (string) $wc_order->get_meta( PayPalGateway::INTENT_META_KEY ) );
|
||||||
$captured = wc_string_to_bool( $wc_order->get_meta( AuthorizedPaymentsProcessor::CAPTURED_META_KEY ) );
|
$captured = wc_string_to_bool( $wc_order->get_meta( AuthorizedPaymentsProcessor::CAPTURED_META_KEY ) );
|
||||||
if ( $intent !== 'AUTHORIZE' || $captured ) {
|
if ( $intent !== 'AUTHORIZE' || $captured ) {
|
||||||
|
@ -392,6 +401,16 @@ class WCGatewayModule implements ModuleInterface {
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
|
|
||||||
|
add_action(
|
||||||
|
'woocommerce_paypal_payments_uninstall',
|
||||||
|
static function () use ( $c ) {
|
||||||
|
$listener = $c->get( 'wcgateway.settings.listener' );
|
||||||
|
assert( $listener instanceof SettingsListener );
|
||||||
|
|
||||||
|
$listener->listen_for_uninstall();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
if ( defined( 'WP_CLI' ) && WP_CLI ) {
|
||||||
\WP_CLI::add_command(
|
\WP_CLI::add_command(
|
||||||
'pcp settings',
|
'pcp settings',
|
||||||
|
|
|
@ -80,6 +80,7 @@ return array(
|
||||||
$order_endpoint = $container->get( 'api.endpoint.order' );
|
$order_endpoint = $container->get( 'api.endpoint.order' );
|
||||||
$authorized_payments_processor = $container->get( 'wcgateway.processor.authorized-payments' );
|
$authorized_payments_processor = $container->get( 'wcgateway.processor.authorized-payments' );
|
||||||
$payment_token_factory = $container->get( 'vaulting.payment-token-factory' );
|
$payment_token_factory = $container->get( 'vaulting.payment-token-factory' );
|
||||||
|
$payment_token_helper = $container->get( 'vaulting.payment-token-helper' );
|
||||||
$refund_fees_updater = $container->get( 'wcgateway.helper.refund-fees-updater' );
|
$refund_fees_updater = $container->get( 'wcgateway.helper.refund-fees-updater' );
|
||||||
|
|
||||||
return array(
|
return array(
|
||||||
|
@ -95,7 +96,7 @@ return array(
|
||||||
new PaymentCaptureRefunded( $logger, $refund_fees_updater ),
|
new PaymentCaptureRefunded( $logger, $refund_fees_updater ),
|
||||||
new PaymentCaptureReversed( $logger ),
|
new PaymentCaptureReversed( $logger ),
|
||||||
new PaymentCaptureCompleted( $logger, $order_endpoint ),
|
new PaymentCaptureCompleted( $logger, $order_endpoint ),
|
||||||
new VaultPaymentTokenCreated( $logger, $prefix, $authorized_payments_processor, $payment_token_factory ),
|
new VaultPaymentTokenCreated( $logger, $prefix, $authorized_payments_processor, $payment_token_factory, $payment_token_helper ),
|
||||||
new VaultPaymentTokenDeleted( $logger ),
|
new VaultPaymentTokenDeleted( $logger ),
|
||||||
new PaymentCapturePending( $logger ),
|
new PaymentCapturePending( $logger ),
|
||||||
new PaymentSaleCompleted( $logger ),
|
new PaymentSaleCompleted( $logger ),
|
||||||
|
|
|
@ -13,6 +13,7 @@ use Psr\Log\LoggerInterface;
|
||||||
use WC_Payment_Token_CC;
|
use WC_Payment_Token_CC;
|
||||||
use WC_Payment_Tokens;
|
use WC_Payment_Tokens;
|
||||||
use WooCommerce\PayPalCommerce\Vaulting\PaymentTokenFactory;
|
use WooCommerce\PayPalCommerce\Vaulting\PaymentTokenFactory;
|
||||||
|
use WooCommerce\PayPalCommerce\Vaulting\PaymentTokenHelper;
|
||||||
use WooCommerce\PayPalCommerce\Vaulting\PaymentTokenPayPal;
|
use WooCommerce\PayPalCommerce\Vaulting\PaymentTokenPayPal;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
|
use WooCommerce\PayPalCommerce\WcGateway\Gateway\CreditCardGateway;
|
||||||
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
|
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
|
||||||
|
@ -54,6 +55,13 @@ class VaultPaymentTokenCreated implements RequestHandler {
|
||||||
*/
|
*/
|
||||||
protected $payment_token_factory;
|
protected $payment_token_factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The payment token helper.
|
||||||
|
*
|
||||||
|
* @var PaymentTokenHelper
|
||||||
|
*/
|
||||||
|
private $payment_token_helper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* VaultPaymentTokenCreated constructor.
|
* VaultPaymentTokenCreated constructor.
|
||||||
*
|
*
|
||||||
|
@ -61,17 +69,20 @@ class VaultPaymentTokenCreated implements RequestHandler {
|
||||||
* @param string $prefix The prefix.
|
* @param string $prefix The prefix.
|
||||||
* @param AuthorizedPaymentsProcessor $authorized_payments_processor The authorized payment processor.
|
* @param AuthorizedPaymentsProcessor $authorized_payments_processor The authorized payment processor.
|
||||||
* @param PaymentTokenFactory $payment_token_factory The payment token factory.
|
* @param PaymentTokenFactory $payment_token_factory The payment token factory.
|
||||||
|
* @param PaymentTokenHelper $payment_token_helper The payment token helper.
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
LoggerInterface $logger,
|
LoggerInterface $logger,
|
||||||
string $prefix,
|
string $prefix,
|
||||||
AuthorizedPaymentsProcessor $authorized_payments_processor,
|
AuthorizedPaymentsProcessor $authorized_payments_processor,
|
||||||
PaymentTokenFactory $payment_token_factory
|
PaymentTokenFactory $payment_token_factory,
|
||||||
|
PaymentTokenHelper $payment_token_helper
|
||||||
) {
|
) {
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
$this->prefix = $prefix;
|
$this->prefix = $prefix;
|
||||||
$this->authorized_payments_processor = $authorized_payments_processor;
|
$this->authorized_payments_processor = $authorized_payments_processor;
|
||||||
$this->payment_token_factory = $payment_token_factory;
|
$this->payment_token_factory = $payment_token_factory;
|
||||||
|
$this->payment_token_helper = $payment_token_helper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -123,33 +134,39 @@ class VaultPaymentTokenCreated implements RequestHandler {
|
||||||
|
|
||||||
if ( ! is_null( $request['resource'] ) && isset( $request['resource']['id'] ) ) {
|
if ( ! is_null( $request['resource'] ) && isset( $request['resource']['id'] ) ) {
|
||||||
if ( ! is_null( $request['resource']['source'] ) && isset( $request['resource']['source']['card'] ) ) {
|
if ( ! is_null( $request['resource']['source'] ) && isset( $request['resource']['source']['card'] ) ) {
|
||||||
$token = new WC_Payment_Token_CC();
|
$wc_tokens = WC_Payment_Tokens::get_customer_tokens( $wc_customer_id, CreditCardGateway::ID );
|
||||||
$token->set_token( $request['resource']['id'] );
|
if ( ! $this->payment_token_helper->token_exist( $wc_tokens, $request['resource']['id'] ) ) {
|
||||||
$token->set_user_id( $wc_customer_id );
|
$token = new WC_Payment_Token_CC();
|
||||||
$token->set_gateway_id( CreditCardGateway::ID );
|
$token->set_token( $request['resource']['id'] );
|
||||||
|
$token->set_user_id( $wc_customer_id );
|
||||||
|
$token->set_gateway_id( CreditCardGateway::ID );
|
||||||
|
|
||||||
$token->set_last4( $request['resource']['source']['card']['last_digits'] ?? '' );
|
$token->set_last4( $request['resource']['source']['card']['last_digits'] ?? '' );
|
||||||
$expiry = explode( '-', $request['resource']['source']['card']['expiry'] ?? '' );
|
$expiry = explode( '-', $request['resource']['source']['card']['expiry'] ?? '' );
|
||||||
$token->set_expiry_year( $expiry[0] ?? '' );
|
$token->set_expiry_year( $expiry[0] ?? '' );
|
||||||
$token->set_expiry_month( $expiry[1] ?? '' );
|
$token->set_expiry_month( $expiry[1] ?? '' );
|
||||||
$token->set_card_type( $request['resource']['source']['card']['brand'] ?? '' );
|
$token->set_card_type( $request['resource']['source']['card']['brand'] ?? '' );
|
||||||
$token->save();
|
$token->save();
|
||||||
WC_Payment_Tokens::set_users_default( $wc_customer_id, $token->get_id() );
|
WC_Payment_Tokens::set_users_default( $wc_customer_id, $token->get_id() );
|
||||||
} elseif ( isset( $request['resource']['source']['paypal'] ) ) {
|
|
||||||
$payment_token_paypal = $this->payment_token_factory->create( 'paypal' );
|
|
||||||
assert( $payment_token_paypal instanceof PaymentTokenPayPal );
|
|
||||||
|
|
||||||
$payment_token_paypal->set_token( $request['resource']['id'] );
|
|
||||||
$payment_token_paypal->set_user_id( $wc_customer_id );
|
|
||||||
$payment_token_paypal->set_gateway_id( PayPalGateway::ID );
|
|
||||||
|
|
||||||
$email = $request['resource']['source']['paypal']['payer']['email_address'] ?? '';
|
|
||||||
if ( $email && is_email( $email ) ) {
|
|
||||||
$payment_token_paypal->set_email( $email );
|
|
||||||
}
|
}
|
||||||
|
} elseif ( isset( $request['resource']['source']['paypal'] ) ) {
|
||||||
|
$wc_tokens = WC_Payment_Tokens::get_customer_tokens( $wc_customer_id, PayPalGateway::ID );
|
||||||
|
if ( ! $this->payment_token_helper->token_exist( $wc_tokens, $request['resource']['id'] ) ) {
|
||||||
|
$payment_token_paypal = $this->payment_token_factory->create( 'paypal' );
|
||||||
|
assert( $payment_token_paypal instanceof PaymentTokenPayPal );
|
||||||
|
|
||||||
$payment_token_paypal->save();
|
$payment_token_paypal->set_token( $request['resource']['id'] );
|
||||||
WC_Payment_Tokens::set_users_default( $wc_customer_id, $payment_token_paypal->get_id() );
|
$payment_token_paypal->set_user_id( $wc_customer_id );
|
||||||
|
$payment_token_paypal->set_gateway_id( PayPalGateway::ID );
|
||||||
|
|
||||||
|
$email = $request['resource']['source']['paypal']['payer']['email_address'] ?? '';
|
||||||
|
if ( $email && is_email( $email ) ) {
|
||||||
|
$payment_token_paypal->set_email( $email );
|
||||||
|
}
|
||||||
|
|
||||||
|
$payment_token_paypal->save();
|
||||||
|
WC_Payment_Tokens::set_users_default( $wc_customer_id, $payment_token_paypal->get_id() );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace WooCommerce\PayPalCommerce\Onboarding\Helper;
|
namespace WooCommerce\PayPalCommerce\Onboarding\Helper;
|
||||||
|
|
||||||
use PHPUnit\Framework\TestCase;
|
use WooCommerce\PayPalCommerce\TestCase;
|
||||||
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
|
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use function Brain\Monkey\Functions\when;
|
use function Brain\Monkey\Functions\when;
|
||||||
|
@ -15,7 +15,7 @@ class OnboardingUrlTest extends TestCase
|
||||||
private $user_id = 123;
|
private $user_id = 123;
|
||||||
private $onboardingUrl;
|
private $onboardingUrl;
|
||||||
|
|
||||||
protected function setUp(): void
|
public function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|
||||||
|
|
|
@ -57,6 +57,7 @@ class OrderProcessorTest extends TestCase
|
||||||
->andReturn($payments);
|
->andReturn($payments);
|
||||||
|
|
||||||
$wcOrder = Mockery::mock(\WC_Order::class);
|
$wcOrder = Mockery::mock(\WC_Order::class);
|
||||||
|
$wcOrder->expects('get_items')->andReturn([]);
|
||||||
$wcOrder->expects('update_meta_data')
|
$wcOrder->expects('update_meta_data')
|
||||||
->with(PayPalGateway::ORDER_PAYMENT_MODE_META_KEY, 'live');
|
->with(PayPalGateway::ORDER_PAYMENT_MODE_META_KEY, 'live');
|
||||||
$wcOrder->shouldReceive('get_id')->andReturn(1);
|
$wcOrder->shouldReceive('get_id')->andReturn(1);
|
||||||
|
@ -193,7 +194,8 @@ class OrderProcessorTest extends TestCase
|
||||||
->andReturn($payments);
|
->andReturn($payments);
|
||||||
|
|
||||||
$wcOrder = Mockery::mock(\WC_Order::class);
|
$wcOrder = Mockery::mock(\WC_Order::class);
|
||||||
$orderStatus = Mockery::mock(OrderStatus::class);
|
$wcOrder->expects('get_items')->andReturn([]);
|
||||||
|
$orderStatus = Mockery::mock(OrderStatus::class);
|
||||||
$orderStatus
|
$orderStatus
|
||||||
->shouldReceive('is')
|
->shouldReceive('is')
|
||||||
->with(OrderStatus::APPROVED)
|
->with(OrderStatus::APPROVED)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue